From 55b4bf3df0752ae3f00bc0ba974fd272eb8f42ed Mon Sep 17 00:00:00 2001 From: Gilles d'Andrea Date: Fri, 4 Sep 2026 18:31:18 +0200 Subject: [PATCH 1/5] fix(state): keep one state store per herdr session herdr numbers every session's tabs from w1:t1, so the plugin running under `herdr --session work` and the one under `herdr --session home` wrote the same keys into the same state.json. Each full pass prunes the tab ids it did not see, which in the other session's store is every tab there, and a tab whose record is gone reads as renamed by hand on the next pass and opts out of naming for good. A machine running more than one session ended up with every tab frozen, and the shared lock dropped events on top of that. A named session now keeps its store, lock and rerun flag under sessions// inside the state directory, named from the socket path herdr exports to plugin commands and pane environments alike, so the herdr-invoked pass and the shell hooks resolve the same file. The default session keeps the store where it always was. Fixes #22. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 ++++ automatic-rename.sh | 14 +++++++++ docs/ARCHITECTURE.md | 2 ++ tests/test_session.sh | 70 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100755 tests/test_session.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c650a..f22f894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to herdr-automatic-rename are documented here. The format fo ## [Unreleased] +### Fixed + +- Two herdr sessions no longer share one state store ([#22](https://github.com/qu8n/herdr-automatic-rename/issues/22)). Every server numbers its tabs from `w1:t1`, so the plugin running under `herdr --session work` and the one under `herdr --session home` wrote the same keys into the same `state.json`, and each full pass pruned the other session's tabs as closed. A tab whose record is gone reads as renamed by hand on the next pass and opts out of naming for good, which is how a machine running more than one session ended up with every tab frozen on whatever it was called when the other session last ran, and the lock they also shared dropped events on top of that. + + A named session now keeps its store under `sessions//` inside the state directory, named from the socket path herdr exports to plugin commands and pane environments alike, so the herdr-invoked pass and the shell hooks resolve the same file. The default session keeps the store where it always was, and an older store is left in place rather than migrated: its records belonged to every session at once, so none of them was right. A tab that opted out under the shared store is still opted out in its own, because the label it carries is one the new store has never written; the reset action, or clearing the label, hands it back as before. + ## [0.8.0] - 2026-08-28 ### Added diff --git a/automatic-rename.sh b/automatic-rename.sh index 7b6596b..bff3fe2 100755 --- a/automatic-rename.sh +++ b/automatic-rename.sh @@ -62,6 +62,20 @@ AR_ROOT="${HERDR_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)}" HERDR="${HERDR_BIN_PATH:-herdr}" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/herdr-automatic-rename" +# One store per herdr session. Every server numbers its own tabs from w1:t1, so +# two sessions writing one file overwrite each other's records, and each pass +# prunes the other session's tabs as closed: a tab whose record is gone reads as +# renamed by hand on the next pass and opts out for good. A named session +# (`herdr --session NAME`) keeps its socket under `sessions/NAME/`, which is what +# ar_herdr_session_dir strips the filename off, so the same variable names the +# store here: the herdr-invoked pass and the shell hooks both receive it. The +# default session has no such directory and keeps the store where it always was. +_ar_sock_dir="${HERDR_SOCKET_PATH:+${HERDR_SOCKET_PATH%/*}}" +_ar_sock_parent="${_ar_sock_dir%/*}" +if [ "${_ar_sock_parent##*/}" = "sessions" ] && [ -n "${_ar_sock_dir##*/}" ]; then + STATE_DIR="$STATE_DIR/sessions/${_ar_sock_dir##*/}" +fi +unset _ar_sock_dir _ar_sock_parent STATE_FILE="$STATE_DIR/state.json" LOCK_DIR="$STATE_DIR/lock" RERUN_FLAG="$STATE_DIR/rerun" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67f7aef..cfaa75e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,6 +22,8 @@ The delimiter is the ASCII unit separator, not a tab, because bash counts a tab State (`~/.local/state/herdr-automatic-rename/`) and config (`~/.config/herdr-automatic-rename/config.sh`) use fixed paths, not `$HERDR_PLUGIN_STATE_DIR` / `$HERDR_PLUGIN_CONFIG_DIR`. The live shell hooks run `preexec`/`precmd` under your shell, not under herdr, so they never receive the `HERDR_PLUGIN_*` variables. The herdr-invoked pass and the shell-invoked fast path must share one config and one state store, which forces a path both can name without herdr's help. `$HERDR_AUTOMATIC_RENAME_CONFIG` overrides the config location. +The state path is fixed per session, not per machine. herdr numbers every session's tabs from `w1:t1`, so two servers on one `state.json` overwrite each other's records and prune each other's tabs as closed, and a tab whose record is gone opts out of naming. A named session keeps its socket under `sessions//`, and `$HERDR_SOCKET_PATH` is the one variable the herdr-invoked pass and the shell hooks both receive, so the engine reads the session name off it and keeps that session's store, lock, and rerun flag under `sessions//` in the state directory. The default session has no such directory and keeps the store at the root. + herdr exposes no per-tab metadata and no auto/manual flag, so the manual-rename opt-out lives in a small JSON state file keyed by `tab_id`: the last base the plugin set, and whether auto-naming is still on for that tab. That recorded base is the plugin's only evidence of what it named a tab, so it is written only for a name the tab actually **carries**: the label already matches, or the `rename` reported success. A base recorded for a rename that never landed is indistinguishable, one pass later, from a name typed by hand, so the tab opts out of naming and only `reset` brings it back. diff --git a/tests/test_session.sh b/tests/test_session.sh new file mode 100755 index 0000000..9796825 --- /dev/null +++ b/tests/test_session.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Unit tests for where the state store lives: one per herdr session. +# +# herdr numbers every session's tabs from w1:t1, so two servers on one state +# file were each other's corruption. Each full pass prunes the tab ids it did not +# see, which in another session's store is every tab there, and a tab whose +# record is gone reads as renamed by hand on the next pass and opts out for good. +# The socket path is the one variable both the herdr-invoked pass and the shell +# hooks receive, and a named session keeps its socket under `sessions/NAME/`. + +here=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=tests/lib.sh +. "$here/lib.sh" + +SB=$(mktemp -d "${TMPDIR:-/tmp}/hal-session.XXXXXX") +export XDG_STATE_HOME="$SB/xdg" +ENGINE="$here/../automatic-rename.sh" +LEGACY="$XDG_STATE_HOME/herdr-automatic-rename" + +# state_dir_for -> the STATE_DIR the engine resolves. +# A fresh bash per case, so the socket path of one never leaks into the next. +state_dir_for() { + # shellcheck disable=SC2016 # the $STATE_DIR is the child bash's, read after it sourced the engine + env -u HERDR_SOCKET_PATH ${1:+HERDR_SOCKET_PATH="$1"} \ + bash -c '. "$1"; printf %s "$STATE_DIR"' _ "$ENGINE" +} + +# ---- resolution ---- +check "no socket path: the store stays where it was" \ + "$LEGACY" "$(state_dir_for "")" +check "default session: socket beside config, store unchanged" \ + "$LEGACY" "$(state_dir_for "$SB/config/herdr/herdr.sock")" +check "named session: its own store under sessions/" \ + "$LEGACY/sessions/work" "$(state_dir_for "$SB/config/herdr/sessions/work/herdr.sock")" +check "another named session: another store" \ + "$LEGACY/sessions/home" "$(state_dir_for "$SB/config/herdr/sessions/home/herdr.sock")" +# A directory merely called sessions is not a session inside it. +check "a socket directly under sessions/ is not a session" \ + "$LEGACY" "$(state_dir_for "$SB/config/herdr/sessions/herdr.sock")" +# The lock and the rerun flag follow the store, or two sessions would still +# refuse each other's passes. +check "the lock follows the store" \ + "$LEGACY/sessions/work/lock" \ + "$(HERDR_SOCKET_PATH="$SB/config/herdr/sessions/work/herdr.sock" \ + bash -c '. "$1"; printf %s "$LOCK_DIR"' _ "$ENGINE")" + +# ---- the regression: one session's prune leaves the other's records alone ---- +# Session work names w1:t1 and records it. Session home then runs a pass that sees +# only its own w1:t1, which is a different tab, and prunes everything else. The +# record work wrote has to survive, or work's next pass finds an owned tab with no +# record, reads its label as typed by hand, and stops naming it. +in_session() { # + local name=$1; shift + HERDR_SOCKET_PATH="$SB/config/herdr/sessions/$name/herdr.sock" \ + bash -c '. "$1"; mkdir -p "$STATE_DIR"; eval "$2"' _ "$ENGINE" "$*" +} +in_session work 'ar_state_set w1:t1 nvim true' +in_session home 'ar_state_set w1:t1 claude true; ar_state_prune w1:t1' +check "work keeps its record after home prunes" \ + "nvim" "$(in_session work 'ar_state_get w1:t1 auto')" +check "home sees only its own tab" \ + "claude" "$(in_session home 'ar_state_get w1:t1 auto')" +in_session home 'ar_state_prune w9:t9' +check "home pruning every tab it knows still leaves work alone" \ + "nvim" "$(in_session work 'ar_state_get w1:t1 auto')" +check "and work still owns it" \ + "0" "$(in_session work 'ar_name_eligible w1:t1 nvim; echo $?')" + +rm -rf "$SB" 2>/dev/null || true +t_summary From aebaa7cd9af70f28ddfde4dceb3de2fdc90c2e64 Mon Sep 17 00:00:00 2001 From: Gilles d'Andrea Date: Fri, 4 Sep 2026 19:24:57 +0200 Subject: [PATCH 2/5] fix(state): seed a session's first store, and resolve it as the CLI does Review of the per-session store turned up three things. An upgrade froze every tab a named session was already naming, not only the ones the shared store had broken: the new store started empty, and a tab carrying a label the store never wrote reads as renamed by hand. A named session's store is now created once from the owned records of the shared one. A matching record keeps the tab named, a mismatched one opts out exactly as no record would, and opted-out records stay behind so a placeholder label is adopted as it would be from nothing. The copy is a link, which refuses an existing file, so a burst of first events cannot cover a pass that already wrote. The store was keyed off the socket path alone, while the herdr CLI also picks its server from HERDR_SESSION when no socket path is set. A hand run with only the name set talked to that session and pruned the default session's records. The resolver now follows the CLI: the socket path when set, the session name otherwise, and `default` means the root store. A hand-set socket path could alias the store: a relative one with no parent landed in sessions/sessions, and a dot segment put the store in the shared directory or the root. The name is now one real path segment. Docs say what both sides actually receive, that the uninstall strip runs per session, and that a deleted session's store is safe to delete. The test unsets the runner's own session first, pins the rerun flag and the new cases, and uses the suite's exit-status helper. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 ++- README.md | 2 +- automatic-rename.sh | 56 +++++++++++++++---- docs/ARCHITECTURE.md | 4 +- tests/test_session.sh | 122 ++++++++++++++++++++++++++++++------------ 5 files changed, 140 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f22f894..968cc19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,11 @@ All notable changes to herdr-automatic-rename are documented here. The format fo ### Fixed -- Two herdr sessions no longer share one state store ([#22](https://github.com/qu8n/herdr-automatic-rename/issues/22)). Every server numbers its tabs from `w1:t1`, so the plugin running under `herdr --session work` and the one under `herdr --session home` wrote the same keys into the same `state.json`, and each full pass pruned the other session's tabs as closed. A tab whose record is gone reads as renamed by hand on the next pass and opts out of naming for good, which is how a machine running more than one session ended up with every tab frozen on whatever it was called when the other session last ran, and the lock they also shared dropped events on top of that. +- Two herdr sessions no longer share one state store ([#22](https://github.com/qu8n/herdr-automatic-rename/issues/22)). Every server numbers its tabs from `w1:t1`, so the plugin running under `herdr --session work` and the one under `herdr --session home` wrote the same keys into the same `state.json`, and each full pass pruned the other session's tabs as closed. A tab whose record is gone reads as renamed by hand on the next pass and opts out of naming until reset, which is how a machine running more than one session ended up with every tab frozen on whatever it was called when the other session last ran, and the lock they also shared dropped events on top of that. - A named session now keeps its store under `sessions//` inside the state directory, named from the socket path herdr exports to plugin commands and pane environments alike, so the herdr-invoked pass and the shell hooks resolve the same file. The default session keeps the store where it always was, and an older store is left in place rather than migrated: its records belonged to every session at once, so none of them was right. A tab that opted out under the shared store is still opted out in its own, because the label it carries is one the new store has never written; the reset action, or clearing the label, hands it back as before. + A named session now keeps its store under `sessions//` inside the state directory, resolved the way the herdr CLI picks its server: from the session directory in the socket path herdr exports to plugin commands and pane environments alike, or from `HERDR_SESSION` when no socket path is set, so the herdr-invoked pass and the shell hooks resolve one file. The default session keeps the store where it always was. + + Upgrading costs nothing in the common case. A named session's store is created once from the owned records of the old shared one, so a tab the plugin was naming goes on being named. A record the shared store got wrong, which on a multi-session machine is most of them, ends where an empty store would put it: a tab whose label matches nothing the store owns is opted out, and the reset action or clearing the label hands it back as before. Nothing removes a session's store when the session is deleted, and the old shared file stays as the default session's; both are safe to delete by hand. ## [0.8.0] - 2026-08-28 diff --git a/README.md b/README.md index 93fd89f..ad58d76 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ herdr plugin action invoke herdr-automatic-rename.reset ## Uninstall -Strip the labels first, else `clear`'s renames re-fire the hooks. Then remove the plugin: +Strip the labels first, else `clear`'s renames re-fire the hooks. The strip reaches one herdr session, the one the shell it runs in belongs to, so run it once per session before removing the plugin: ```sh bash "$(herdr plugin list --json \ diff --git a/automatic-rename.sh b/automatic-rename.sh index bff3fe2..cb802e4 100755 --- a/automatic-rename.sh +++ b/automatic-rename.sh @@ -62,20 +62,31 @@ AR_ROOT="${HERDR_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)}" HERDR="${HERDR_BIN_PATH:-herdr}" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/herdr-automatic-rename" -# One store per herdr session. Every server numbers its own tabs from w1:t1, so -# two sessions writing one file overwrite each other's records, and each pass -# prunes the other session's tabs as closed: a tab whose record is gone reads as -# renamed by hand on the next pass and opts out for good. A named session -# (`herdr --session NAME`) keeps its socket under `sessions/NAME/`, which is what -# ar_herdr_session_dir strips the filename off, so the same variable names the -# store here: the herdr-invoked pass and the shell hooks both receive it. The -# default session has no such directory and keeps the store where it always was. +AR_LEGACY_STATE_FILE="$STATE_DIR/state.json" +# One store per herdr session, because every server numbers its tabs from w1:t1 +# (docs/ARCHITECTURE.md, "Why config and state sit at fixed paths"). The name is +# read the way the herdr CLI picks its server: from the `sessions//` +# directory in $HERDR_SOCKET_PATH whenever that is set, the same directory +# ar_herdr_session_dir reads, and from $HERDR_SESSION only when it is not. Both +# reach plugin commands and pane shells alike. A socket path that names no +# session directory is the default session's, whatever name the shell inherited. +# The default session, which herdr also calls `default`, keeps the store here. _ar_sock_dir="${HERDR_SOCKET_PATH:+${HERDR_SOCKET_PATH%/*}}" _ar_sock_parent="${_ar_sock_dir%/*}" -if [ "${_ar_sock_parent##*/}" = "sessions" ] && [ -n "${_ar_sock_dir##*/}" ]; then - STATE_DIR="$STATE_DIR/sessions/${_ar_sock_dir##*/}" +if [ -z "$_ar_sock_dir" ]; then + _ar_session="${HERDR_SESSION:-}" +elif [ "$_ar_sock_parent" != "$_ar_sock_dir" ] && [ "${_ar_sock_parent##*/}" = "sessions" ]; then + _ar_session="${_ar_sock_dir##*/}" +else + _ar_session="" fi -unset _ar_sock_dir _ar_sock_parent +# A name is one path segment, never a dot entry: herdr refuses those as session +# names, and a hand-set socket path must not alias the store onto another dir. +case "$_ar_session" in + "" | default | . | ..) ;; + *) STATE_DIR="$STATE_DIR/sessions/$_ar_session" ;; +esac +unset _ar_sock_dir _ar_sock_parent _ar_session STATE_FILE="$STATE_DIR/state.json" LOCK_DIR="$STATE_DIR/lock" RERUN_FLAG="$STATE_DIR/rerun" @@ -447,6 +458,28 @@ ar_unlock() { rmdir "$LOCK_DIR" 2>/dev/null || true } +# ar_state_seed - a named session's first store starts from the ownership records +# of the shared store it replaces. Without this an upgrade opts every named tab +# out: the tab carries a label the empty store never wrote, which is what a hand +# rename looks like. Only enabled records are copied. A matching one keeps the +# tab named, a mismatched one opts out exactly as no record would, and an +# opted-out one is left behind so a placeholder label is adopted as it would be +# from nothing. Ids this session lacks go on the first prune. The link is what +# makes the copy safe under a burst of first events: it refuses an existing +# file, so a pass that already wrote is never covered over. +ar_state_seed() { + [ "$STATE_FILE" != "$AR_LEGACY_STATE_FILE" ] || return 0 + [ -e "$STATE_FILE" ] && return 0 + [ -f "$AR_LEGACY_STATE_FILE" ] || return 0 + local tmp + tmp=$(mktemp "$STATE_DIR/.state.XXXXXX") || return 0 + if jq -c 'with_entries(select(.value.enabled == true))' "$AR_LEGACY_STATE_FILE" > "$tmp" 2>/dev/null \ + && jq -es 'length == 1 and (.[0] | type == "object")' "$tmp" >/dev/null 2>&1; then + ln "$tmp" "$STATE_FILE" 2>/dev/null || true + fi + rm -f "$tmp" +} + # ====================================================================== # naming state (atomic temp+mv; jq keyed by tab_id; only NAME_TABS uses it) # ====================================================================== @@ -1798,6 +1831,7 @@ ar_main() { command -v jq >/dev/null 2>&1 || exit 0 command -v "$HERDR" >/dev/null 2>&1 || exit 0 mkdir -p "$STATE_DIR" 2>/dev/null || exit 0 + ar_state_seed # Config overrides must load BEFORE naming.sh (its defaults only fill unset vars). # The config path is the user's, resolved at runtime, so shellcheck has no file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cfaa75e..c6968c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,9 +20,9 @@ The delimiter is the ASCII unit separator, not a tab, because bash counts a tab ## Why config and state sit at fixed paths -State (`~/.local/state/herdr-automatic-rename/`) and config (`~/.config/herdr-automatic-rename/config.sh`) use fixed paths, not `$HERDR_PLUGIN_STATE_DIR` / `$HERDR_PLUGIN_CONFIG_DIR`. The live shell hooks run `preexec`/`precmd` under your shell, not under herdr, so they never receive the `HERDR_PLUGIN_*` variables. The herdr-invoked pass and the shell-invoked fast path must share one config and one state store, which forces a path both can name without herdr's help. `$HERDR_AUTOMATIC_RENAME_CONFIG` overrides the config location. +State (`~/.local/state/herdr-automatic-rename/`) and config (`~/.config/herdr-automatic-rename/config.sh`) use fixed paths, not `$HERDR_PLUGIN_STATE_DIR` / `$HERDR_PLUGIN_CONFIG_DIR`. The live shell hooks run `preexec`/`precmd` under your shell, not under herdr, so they never receive the `HERDR_PLUGIN_*` variables. The herdr-invoked pass and the shell-invoked fast path must share one config and one state store, which forces a path both can name from the environment they do share. `$HERDR_AUTOMATIC_RENAME_CONFIG` overrides the config location. -The state path is fixed per session, not per machine. herdr numbers every session's tabs from `w1:t1`, so two servers on one `state.json` overwrite each other's records and prune each other's tabs as closed, and a tab whose record is gone opts out of naming. A named session keeps its socket under `sessions//`, and `$HERDR_SOCKET_PATH` is the one variable the herdr-invoked pass and the shell hooks both receive, so the engine reads the session name off it and keeps that session's store, lock, and rerun flag under `sessions//` in the state directory. The default session has no such directory and keeps the store at the root. +The state path is fixed per session, not per machine. herdr numbers every session's tabs from `w1:t1`, so two servers on one `state.json` overwrite each other's records and prune each other's tabs as closed, and a tab whose record is gone opts out of naming. The session is resolved the way the herdr CLI picks its server: from the `sessions//` directory in `$HERDR_SOCKET_PATH` whenever that is set, the same one `ar_herdr_session_dir` reads, and from `$HERDR_SESSION` only when it is not. herdr exports both to plugin commands and pane environments alike, so the herdr-invoked pass and the shell hooks resolve one store, and a hand run against another session's socket lands on that session's store whatever name the shell inherited. That session's store, lock, and rerun flag live under `sessions//` in the state directory. The default session, which herdr also calls `default`, keeps the store at the root. A named session's store is created once from the owned records of the root store (`ar_state_seed`), because a store that starts empty reads every label the old store wrote as a hand rename and opts the tab out. Nothing removes a session's directory when the session is deleted; it is safe to delete by hand, and a session created later under the same name starts from it. herdr exposes no per-tab metadata and no auto/manual flag, so the manual-rename opt-out lives in a small JSON state file keyed by `tab_id`: the last base the plugin set, and whether auto-naming is still on for that tab. diff --git a/tests/test_session.sh b/tests/test_session.sh index 9796825..7939f29 100755 --- a/tests/test_session.sh +++ b/tests/test_session.sh @@ -1,12 +1,7 @@ #!/usr/bin/env bash -# Unit tests for where the state store lives: one per herdr session. -# -# herdr numbers every session's tabs from w1:t1, so two servers on one state -# file were each other's corruption. Each full pass prunes the tab ids it did not -# see, which in another session's store is every tab there, and a tab whose -# record is gone reads as renamed by hand on the next pass and opts out for good. -# The socket path is the one variable both the herdr-invoked pass and the shell -# hooks receive, and a named session keeps its socket under `sessions/NAME/`. +# Unit tests for where the state store lives: one per herdr session, resolved +# the way the herdr CLI picks its server (docs/ARCHITECTURE.md, "Why config and +# state sit at fixed paths"), and seeded once from the shared store it replaces. here=$(cd "$(dirname "$0")" && pwd) # shellcheck source=tests/lib.sh @@ -14,46 +9,69 @@ here=$(cd "$(dirname "$0")" && pwd) SB=$(mktemp -d "${TMPDIR:-/tmp}/hal-session.XXXXXX") export XDG_STATE_HOME="$SB/xdg" +# The runner's own pane would otherwise name a session for every case below. +unset HERDR_SOCKET_PATH HERDR_SESSION ENGINE="$here/../automatic-rename.sh" LEGACY="$XDG_STATE_HOME/herdr-automatic-rename" +CFG="$SB/config/herdr" -# state_dir_for -> the STATE_DIR the engine resolves. -# A fresh bash per case, so the socket path of one never leaks into the next. -state_dir_for() { - # shellcheck disable=SC2016 # the $STATE_DIR is the child bash's, read after it sourced the engine - env -u HERDR_SOCKET_PATH ${1:+HERDR_SOCKET_PATH="$1"} \ - bash -c '. "$1"; printf %s "$STATE_DIR"' _ "$ENGINE" +# in_env -> runs the command in a +# fresh bash that sourced the engine under exactly those two variables (an +# empty value is what an unset one resolves to). One process per case, so no +# case sees another's resolution. +in_env() { + HERDR_SOCKET_PATH="$1" HERDR_SESSION="$2" \ + bash -c '. "$1"; mkdir -p "$STATE_DIR"; eval "$2"' _ "$ENGINE" "$3" } +# resolve -> that engine variable's value. +resolve() { in_env "$1" "$2" "printf %s \"\$$3\""; } +state_dir_for() { resolve "$1" "${2:-}" STATE_DIR; } -# ---- resolution ---- +# ---- resolution off the socket path ---- check "no socket path: the store stays where it was" \ "$LEGACY" "$(state_dir_for "")" check "default session: socket beside config, store unchanged" \ - "$LEGACY" "$(state_dir_for "$SB/config/herdr/herdr.sock")" + "$LEGACY" "$(state_dir_for "$CFG/herdr.sock")" check "named session: its own store under sessions/" \ - "$LEGACY/sessions/work" "$(state_dir_for "$SB/config/herdr/sessions/work/herdr.sock")" + "$LEGACY/sessions/work" "$(state_dir_for "$CFG/sessions/work/herdr.sock")" check "another named session: another store" \ - "$LEGACY/sessions/home" "$(state_dir_for "$SB/config/herdr/sessions/home/herdr.sock")" -# A directory merely called sessions is not a session inside it. + "$LEGACY/sessions/home" "$(state_dir_for "$CFG/sessions/home/herdr.sock")" check "a socket directly under sessions/ is not a session" \ - "$LEGACY" "$(state_dir_for "$SB/config/herdr/sessions/herdr.sock")" + "$LEGACY" "$(state_dir_for "$CFG/sessions/herdr.sock")" +check "a relative path with no parent is not a session" \ + "$LEGACY" "$(state_dir_for "sessions/herdr.sock")" +check "a doubled slash names no session" \ + "$LEGACY" "$(state_dir_for "$CFG/sessions//herdr.sock")" +check "a dot segment cannot alias the store" \ + "$LEGACY" "$(state_dir_for "$CFG/sessions/./herdr.sock")" +check "nor a dot-dot segment" \ + "$LEGACY" "$(state_dir_for "$CFG/sessions/../herdr.sock")" + +# ---- resolution off the session name ---- +check "a session name alone names the store" \ + "$LEGACY/sessions/work" "$(state_dir_for "" work)" +check "the default session's name is the root store" \ + "$LEGACY" "$(state_dir_for "" default)" +check "the socket path wins over the name, as it does for the CLI" \ + "$LEGACY/sessions/home" "$(state_dir_for "$CFG/sessions/home/herdr.sock" work)" +check "a default-shaped socket is not overridden by the name" \ + "$LEGACY" "$(state_dir_for "$CFG/herdr.sock" work)" + # The lock and the rerun flag follow the store, or two sessions would still -# refuse each other's passes. +# refuse each other's passes and raise each other's flags. check "the lock follows the store" \ "$LEGACY/sessions/work/lock" \ - "$(HERDR_SOCKET_PATH="$SB/config/herdr/sessions/work/herdr.sock" \ - bash -c '. "$1"; printf %s "$LOCK_DIR"' _ "$ENGINE")" + "$(resolve "$CFG/sessions/work/herdr.sock" "" LOCK_DIR)" +check "so does the rerun flag" \ + "$LEGACY/sessions/work/rerun" \ + "$(resolve "$CFG/sessions/work/herdr.sock" "" RERUN_FLAG)" # ---- the regression: one session's prune leaves the other's records alone ---- -# Session work names w1:t1 and records it. Session home then runs a pass that sees -# only its own w1:t1, which is a different tab, and prunes everything else. The -# record work wrote has to survive, or work's next pass finds an owned tab with no -# record, reads its label as typed by hand, and stops naming it. -in_session() { # - local name=$1; shift - HERDR_SOCKET_PATH="$SB/config/herdr/sessions/$name/herdr.sock" \ - bash -c '. "$1"; mkdir -p "$STATE_DIR"; eval "$2"' _ "$ENGINE" "$*" -} +# Session work names w1:t1 and records it. Session home then runs a pass that +# sees only its own w1:t1, a different tab, and prunes everything else. The +# record work wrote has to survive, or work's next pass finds an owned tab with +# no record, reads its label as typed by hand, and stops naming it. +in_session() { in_env "$CFG/sessions/$1/herdr.sock" "" "$2"; } in_session work 'ar_state_set w1:t1 nvim true' in_session home 'ar_state_set w1:t1 claude true; ar_state_prune w1:t1' check "work keeps its record after home prunes" \ @@ -63,8 +81,44 @@ check "home sees only its own tab" \ in_session home 'ar_state_prune w9:t9' check "home pruning every tab it knows still leaves work alone" \ "nvim" "$(in_session work 'ar_state_get w1:t1 auto')" -check "and work still owns it" \ - "0" "$(in_session work 'ar_name_eligible w1:t1 nvim; echo $?')" +check_rc "and work still owns it" 0 \ + "$(in_session work 'ar_name_eligible w1:t1 nvim; echo $?')" + +# ---- seeding: a session's first store starts from the shared one ---- +# An empty store reads every label as typed by hand and opts the tab out, so +# an upgrade would freeze every tab a named session already had named. The +# shared store's owned records come across; opted-out ones do not, so a tab at +# a placeholder label is adopted as it would be from nothing. +rm -rf "$LEGACY" +mkdir -p "$LEGACY" +printf '{"w1:t1":{"auto":"nvim","enabled":true},"w1:t2":{"auto":"","enabled":false},"ws:w1":{"auto":"proj","enabled":true}}' \ + >"$LEGACY/state.json" +in_session work 'ar_state_seed' +check "an owned tab record is seeded" "nvim" "$(in_session work 'ar_state_get w1:t1 auto')" +check "and so is an owned workspace record" "proj" "$(in_session work 'ar_state_get ws:w1 auto')" +check "an opted-out record is not" "" "$(in_session work 'ar_state_get w1:t2 enabled')" +check_rc "so the seeded tab is still owned" 0 \ + "$(in_session work 'ar_name_eligible w1:t1 nvim; echo $?')" +check_rc "and the unseeded one adopts a placeholder" 0 \ + "$(in_session work 'ar_name_eligible w1:t2 3; echo $?')" + +# A store that exists is never seeded over, whatever the shared one holds. +in_session work 'ar_state_set w1:t1 htop true' +in_session work 'ar_state_seed' +check "an existing store is left alone" "htop" "$(in_session work 'ar_state_get w1:t1 auto')" + +# A shared store jq cannot use seeds nothing, and the session starts empty. +printf '{"w1:t1": {"auto": "nvim", "enab' >"$LEGACY/state.json" +in_session home 'ar_state_seed' +check "an unreadable shared store seeds nothing" "" "$(in_session home 'ar_state_get w1:t1 auto')" +check "and leaves no file behind" "no" \ + "$([ -e "$LEGACY/sessions/home/state.json" ] && printf yes || printf no)" + +# The default session is the shared store itself, so it has nothing to seed from. +printf '{"w1:t1":{"auto":"nvim","enabled":true}}' >"$LEGACY/state.json" +in_env "$CFG/herdr.sock" "" 'ar_state_seed' +check "the root store is never seeded onto itself" "nvim" \ + "$(in_env "$CFG/herdr.sock" "" 'ar_state_get w1:t1 auto')" rm -rf "$SB" 2>/dev/null || true t_summary From 6deed32093d5071a9931418efb8b44b235810c1b Mon Sep 17 00:00:00 2001 From: Gilles d'Andrea Date: Fri, 4 Sep 2026 20:06:52 +0200 Subject: [PATCH 3/5] fix(state): read the session's own files on a name-only run, and pin it end to end ar_herdr_session_dir fell back to the default session's directory whenever no socket path was set, while the herdr CLI picks its server from HERDR_SESSION in that case, so a hand run with only the name set talked to one server and read another's session.json and config.toml. It now follows the same order as the store. The two-session regression is also run through ar_main against the mock herdr, so the steps only an executed pass takes are pinned: the nested store's mkdir, the lock, and the prune at the end of the pass. The scenario fails on the unpatched engine and passes here. Docs say why the socket path is the key rather than the name, that an override server outside any sessions directory shares the root store, that config.sh must not set HERDR_* variables, and that the state directory can be deleted after the uninstall strip. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- automatic-rename.sh | 18 +++++++++---- docs/ARCHITECTURE.md | 2 +- tests/test_session.sh | 60 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ad58d76..1cd8066 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ herdr plugin action invoke herdr-automatic-rename.reset ## Uninstall -Strip the labels first, else `clear`'s renames re-fire the hooks. The strip reaches one herdr session, the one the shell it runs in belongs to, so run it once per session before removing the plugin: +Strip the labels first, else `clear`'s renames re-fire the hooks. The strip reaches one herdr session, the one the shell it runs in belongs to, so run it once per session before removing the plugin. The state directory, `~/.local/state/herdr-automatic-rename/`, can then be deleted: ```sh bash "$(herdr plugin list --json \ diff --git a/automatic-rename.sh b/automatic-rename.sh index cb802e4..29cfd7d 100755 --- a/automatic-rename.sh +++ b/automatic-rename.sh @@ -50,8 +50,9 @@ # exclusion is tracked here: a JSON state file remembers the last base we set # per tab_id and whether auto-naming is still enabled for it. Config and state # live at FIXED paths (not $HERDR_PLUGIN_{CONFIG,STATE}_DIR) so the herdr-invoked -# and shell-invoked runs share one store: the preexec/precmd runs are launched by -# the shell, not herdr, and never receive the HERDR_PLUGIN_* env vars. Needs jq. +# and shell-invoked runs share the same store, one per herdr session: the +# preexec/precmd runs are launched by the shell, not herdr, and never receive +# the HERDR_PLUGIN_* env vars. Needs jq. # # Targets bash 3.2 (macOS /bin/bash): no associative arrays, no namerefs. @@ -67,8 +68,9 @@ AR_LEGACY_STATE_FILE="$STATE_DIR/state.json" # (docs/ARCHITECTURE.md, "Why config and state sit at fixed paths"). The name is # read the way the herdr CLI picks its server: from the `sessions//` # directory in $HERDR_SOCKET_PATH whenever that is set, the same directory -# ar_herdr_session_dir reads, and from $HERDR_SESSION only when it is not. Both -# reach plugin commands and pane shells alike. A socket path that names no +# ar_herdr_session_dir reads, and from $HERDR_SESSION only when it is not. herdr +# injects the socket path into plugin commands and pane shells on purpose; the +# name reaches both by inheritance from the server. A socket path that names no # session directory is the default session's, whatever name the shell inherited. # The default session, which herdr also calls `default`, keeps the store here. _ar_sock_dir="${HERDR_SOCKET_PATH:+${HERDR_SOCKET_PATH%/*}}" @@ -952,7 +954,13 @@ ar_herdr_session_dir() { if [ -n "${HERDR_SOCKET_PATH:-}" ]; then printf '%s' "${HERDR_SOCKET_PATH%/*}" else - printf '%s/herdr' "${XDG_CONFIG_HOME:-$HOME/.config}" + # No socket path: the CLI picks its server from $HERDR_SESSION next, so the + # files read here have to come from the same session, or a hand run with + # only the name set would talk to one server and read another's session.json. + case "${HERDR_SESSION:-}" in + "" | default | . | ..) printf '%s/herdr' "${XDG_CONFIG_HOME:-$HOME/.config}" ;; + *) printf '%s/herdr/sessions/%s' "${XDG_CONFIG_HOME:-$HOME/.config}" "$HERDR_SESSION" ;; + esac fi } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c6968c8..dd8fc04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ The delimiter is the ASCII unit separator, not a tab, because bash counts a tab State (`~/.local/state/herdr-automatic-rename/`) and config (`~/.config/herdr-automatic-rename/config.sh`) use fixed paths, not `$HERDR_PLUGIN_STATE_DIR` / `$HERDR_PLUGIN_CONFIG_DIR`. The live shell hooks run `preexec`/`precmd` under your shell, not under herdr, so they never receive the `HERDR_PLUGIN_*` variables. The herdr-invoked pass and the shell-invoked fast path must share one config and one state store, which forces a path both can name from the environment they do share. `$HERDR_AUTOMATIC_RENAME_CONFIG` overrides the config location. -The state path is fixed per session, not per machine. herdr numbers every session's tabs from `w1:t1`, so two servers on one `state.json` overwrite each other's records and prune each other's tabs as closed, and a tab whose record is gone opts out of naming. The session is resolved the way the herdr CLI picks its server: from the `sessions//` directory in `$HERDR_SOCKET_PATH` whenever that is set, the same one `ar_herdr_session_dir` reads, and from `$HERDR_SESSION` only when it is not. herdr exports both to plugin commands and pane environments alike, so the herdr-invoked pass and the shell hooks resolve one store, and a hand run against another session's socket lands on that session's store whatever name the shell inherited. That session's store, lock, and rerun flag live under `sessions//` in the state directory. The default session, which herdr also calls `default`, keeps the store at the root. A named session's store is created once from the owned records of the root store (`ar_state_seed`), because a store that starts empty reads every label the old store wrote as a hand rename and opts the tab out. Nothing removes a session's directory when the session is deleted; it is safe to delete by hand, and a session created later under the same name starts from it. +The state path is fixed per session, not per machine. herdr numbers every session's tabs from `w1:t1`, so two servers on one `state.json` overwrite each other's records and prune each other's tabs as closed, and a tab whose record is gone opts out of naming. The session is resolved the way the herdr CLI picks its server: from the `sessions//` directory in `$HERDR_SOCKET_PATH` whenever that is set, the same one `ar_herdr_session_dir` reads, and from `$HERDR_SESSION` only when it is not. herdr injects the socket path into plugin commands and pane environments on purpose, and the name reaches both by inheritance from the server process, so the herdr-invoked pass and the shell hooks resolve one store, and a hand run against another session's socket lands on that session's store whatever name the shell inherited. The socket path is the key rather than the name because a server started with a socket path override inherits whatever name the launching shell had, which would route it into a live session's store; such an override server, whose socket sits in no `sessions//` directory, shares the root store with the default session instead. The resolution happens when the engine is sourced, before `config.sh` is read, so a `config.sh` must not set `HERDR_*` variables. That session's store, lock, and rerun flag live under `sessions//` in the state directory. The default session, which herdr also calls `default`, keeps the store at the root. A named session's store is created once from the owned records of the root store (`ar_state_seed`), because a store that starts empty reads every label the old store wrote as a hand rename and opts the tab out. Nothing removes a session's directory when the session is deleted; it is safe to delete by hand, and a session created later under the same name starts from it. herdr exposes no per-tab metadata and no auto/manual flag, so the manual-rename opt-out lives in a small JSON state file keyed by `tab_id`: the last base the plugin set, and whether auto-naming is still on for that tab. diff --git a/tests/test_session.sh b/tests/test_session.sh index 7939f29..92d5cb1 100755 --- a/tests/test_session.sh +++ b/tests/test_session.sh @@ -9,6 +9,7 @@ here=$(cd "$(dirname "$0")" && pwd) SB=$(mktemp -d "${TMPDIR:-/tmp}/hal-session.XXXXXX") export XDG_STATE_HOME="$SB/xdg" +export XDG_CONFIG_HOME="$SB/config" # The runner's own pane would otherwise name a session for every case below. unset HERDR_SOCKET_PATH HERDR_SESSION ENGINE="$here/../automatic-rename.sh" @@ -57,6 +58,16 @@ check "the socket path wins over the name, as it does for the CLI" \ check "a default-shaped socket is not overridden by the name" \ "$LEGACY" "$(state_dir_for "$CFG/herdr.sock" work)" +# The files the engine reads beside the socket follow the same rule, or a hand +# run with only the name set would talk to one server and read another's +# session.json. +check "the session dir follows the socket path" \ + "$CFG/sessions/home" "$(in_env "$CFG/sessions/home/herdr.sock" work 'ar_herdr_session_dir')" +check "and the name when there is no socket path" \ + "$CFG/sessions/work" "$(in_env "" work 'ar_herdr_session_dir')" +check "and the default session's name means the config dir" \ + "$CFG" "$(in_env "" default 'ar_herdr_session_dir')" + # The lock and the rerun flag follow the store, or two sessions would still # refuse each other's passes and raise each other's flags. check "the lock follows the store" \ @@ -120,5 +131,54 @@ in_env "$CFG/herdr.sock" "" 'ar_state_seed' check "the root store is never seeded onto itself" "nvim" \ "$(in_env "$CFG/herdr.sock" "" 'ar_state_get w1:t1 auto')" +# ---- end to end: two sessions through the real reconcile ---- +# The same regression through ar_main against tests/mocks/herdr, so the steps +# only an executed pass takes (the mkdir of the nested store, the lock, the +# prune at the end of the pass) are pinned too. Each session has its own +# fixtures, as each server answers for its own tabs, and both number from w1:t1. +MOCK="$here/mocks/herdr" +export HERDR_BIN_PATH="$MOCK" HERDR_MOCK_LOG="$SB/renames.log" +export HERDR_AUTOMATIC_RENAME_CONFIG="$SB/none.sh" # absent -> env toggles win +export HERDR_CONFIG_FILE="$SB/herdr.toml" +printf 'agent_panel_sort = "spaces"\n' >"$HERDR_CONFIG_FILE" +export NAME_TABS=1 AUTO_INDEX=0 SHELL_NAME=zsh +unset HIDE_SHELL HERDR_TAB_ID HERDR_PLUGIN_CONTEXT_JSON +rm -rf "$LEGACY" + +# session_fixtures