Skip to content

fix(state): keep one state store per herdr session - #21

Merged
qu8n merged 5 commits into
qu8n:mainfrom
gillesdandrea:fix/per-session-state
Sep 9, 2026
Merged

qu8n merged 5 commits into
qu8n:mainfrom
gillesdandrea:fix/per-session-state

Conversation

@gillesdandrea

@gillesdandrea gillesdandrea commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #22.

Before

herdr numbers every session's tabs from w1:t1. With two servers up, say herdr --session work and herdr --session home, both plugins write the same keys into the one ~/.local/state/herdr-automatic-rename/state.json, and each full pass prunes the tab ids it did not see, which in the other session's store is every tab there. A tab whose record is gone reads as renamed by hand on the next pass and opts out of naming for good.

On a machine running three sessions I found the store flipping between two sets of keys within seconds, every tab record it held at enabled: false, and the current session's tabs absent from it altogether. Titles stopped following the foreground program and the reset action was the only way back, once per tab, until the next pass from another session wiped it again. The shared lock directory drops events as well: a traced tab.focused pass exited at once because another session's pass held it.

After

A named session keeps its store, lock and rerun flag under sessions/<name>/ inside the state directory. The session is resolved the way the herdr CLI picks its server: from the sessions/<name>/ directory in $HERDR_SOCKET_PATH whenever that is set, the same directory ar_herdr_session_dir already reads, and from $HERDR_SESSION only when it is not. herdr exports both to plugin commands and pane environments alike (checked on the live [[startup]] children of three 0.8.2 servers), so the herdr-invoked pass and the shell hooks resolve one file. The default session, which herdr also calls default, keeps the store exactly where it was, so a single-session install sees no change.

A named session's store is created once from the owned records of the old shared one (ar_state_seed). Without that, the first pass after the upgrade would read every label the old store wrote as a hand rename and opt every named tab out, which would hurt exactly the single-named-session user who never had the bug. Only enabled: true records come across, so a matching one keeps the tab named, a mismatched one opts out as no record would, and a placeholder label is adopted as it would be from nothing. The copy is a hard link, which refuses an existing file, so a burst of first events cannot cover a pass that already wrote. Ids the session lacks go on the first prune.

The extracted name must be one real path segment, so a relative socket path with no parent, a doubled slash, or a dot segment resolves to the root store rather than aliasing it onto sessions/ itself or the root. Nothing removes a session's directory when the session is deleted; the docs say it is safe to delete by hand.

ar_herdr_session_dir, which locates session.json and config.toml, follows the same order, so a name-only run reads the files of the server it talks to.

tests/test_session.sh pins the resolution (no socket path, default-shaped socket, named session, the dot and relative cases, the name fallback and the socket path winning over it), that the lock and the rerun flag follow the store, the regression itself (one session's prune leaves the other session's record alone and that tab stays eligible), and the seeding (owned records come across, opted-out ones do not, an existing store is never seeded over, an unreadable shared store seeds nothing, the root store never seeds onto itself). It ends with the two-session regression run through ar_main against tests/mocks/herdr, a work pass, a home pass, then a work pass with the program changed, which renames on this branch and renames nothing on main.

Two contrived layouts still collapse onto one store and are left as they are: a server started with a socket path override outside any sessions/<name>/ directory, and a config directory whose own parent happens to be named sessions.

Checks

  • ./tests/run.sh: all files pass, including the new one, under bash 3.2 as well. The second and third commits answer a review of the first; their messages list what changed.
  • shellcheck -x -s bash over the Makefile's file list: clean (the one suppression names its code and reason).
  • make lint-md: clean.

🤖 Generated with Claude Code

@gillesdandrea
gillesdandrea force-pushed the fix/per-session-state branch 4 times, most recently from 5c1bae1 to cc5881b Compare September 4, 2026 16:39
gillesdandrea and others added 3 commits September 6, 2026 14:38
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/<name>/ 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 qu8n#22.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
qu8n added 2 commits September 8, 2026 20:13
Seeding had no upgrade boundary. The root store is not only the store
an upgrade leaves behind: it is the default session's live store, and it
stays populated for as long as anybody uses that session. So a session
created later seeded from it too, and because every server numbers its
tabs from w1:t1 the copied record landed on a tab that had nothing to
do with it. That tab still carried herdr's generated number, an owned
record against a placeholder label is what a hand rename looks like,
and the first tab of every new session opted itself out for good --
the reset action, once per tab, this bug one layer along.

Copied records are marked. A mark says the record is a claim about
another store's tab rather than a write of ours, so the label is asked
to confirm it: where it disagrees the record is dropped and the tab is
examined as the unseen one it is. That makes the invariant the seeding
was documented with actually hold -- a mismatched record now opts out
exactly as no record would, and a placeholder label is adopted as it
would be from nothing.

The workspace path gets the same rule. "ws:w1" collides across sessions
the way "w1:t1" does, and its first branch covers most of it already,
but opting out is permanent there with no reset action to undo it.

Nothing clears the mark: ar_state_set writes a record whole, so our
first write replaces it.
The socket route takes the segment after the last separator and cannot
carry one. $HERDR_SESSION is whatever the variable says, and it was
interpolated: a value with a slash in it put the store, and the config
directory the session's files are read from, outside `sessions/`
altogether.

Nobody is protected from their own environment by this -- anyone who
can set that variable can set XDG_STATE_HOME too. It is that a name
which is not one segment names no session, and the two routes claimed
to agree while only one of them checked.

Refs qu8n#21
@qu8n

qu8n commented Sep 9, 2026

Copy link
Copy Markdown
Owner

LGTM. Thank you!

I pushed two commits to the branch rather than send it back round.

The seeding had no upgrade boundary. The root store is the default session's live store as well as the one an upgrade leaves behind, so a session created later seeded from it too, and since every server numbers from w1:t1 the copied record landed on a tab it had nothing to do with. That tab still carried herdr's generated number, which is what a hand rename looks like against an owned record, so the first tab of every session created after the upgrade opted itself out. Copied records now carry a mark, and the label has to confirm one before it counts. That also makes the invariant you documented hold: a mismatched record opts out exactly as no record would.

The other is small. HERDR_SESSION went into the path verbatim where the socket route takes one segment, so a name with a slash in it put the store outside sessions/ altogether.

Tests went from 37 to 58, nine of which fail against the old code.

@qu8n
qu8n merged commit c4c40af into qu8n:main Sep 9, 2026
1 check passed
@gillesdandrea
gillesdandrea deleted the fix/per-session-state branch September 9, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Running more than one herdr session makes every tab opt out of naming

2 participants