You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Untagged sessions are weak ownership: they leak once their run dir is gone, and can be pruned by the wrong project on a run-id collision (the fallback behind #320) #419
Split out of #320 (PR #418) by review, deliberately not folded into that fix: the resolution needs an intent decision, not a wider diff.
The gap
#418 gates the psmux session project tag on _transportable(): a value that cannot survive psmux's CLI→server control line is refused rather than stored corrupted, leaving the option unset. That is the correct degradation, because runs.prunable_sessions falls back to the run dir for an untagged session — it claims our own dead runs and skips foreign ones.
That fallback holds only while the run dir exists.delete_run (runs.py:512) and archive_run (runs.py:526) both rmtree it, and neither kills an already-orphaned session. So for a project whose tag is untransportable:
every agent session is created untagged;
a crash (or any run whose session outlives its engine) leaves the session behind;
the operator runs clean / archives the run, removing the directory;
every later prune reads the session as untagged with no run dir under this project, hits runs.py:351-352's elif not is_run(run_dir): continue, and skips it — from every project, including its own.
Result: one leaked psmux session, and its server, per run, for the life of the machine.
Note the asymmetry this exposes. A tagged orphan whose run dir is gone is prunable — that is the orphan-1 case in test_prunable_sessions_partitions. The tag is the only ownership proof that survives run-dir removal, so the untagged branch is strictly weaker by design.
Why this is not just theoretical, and not just a psmux problem
The refusal shapes look exotic until you filter them through Path.resolve() on Windows:
" is illegal in Windows filenames — unreachable in a resolved project path.
A mid-path \\ and a trailing \ are normalized away by resolve().
A standalone ; token needs a directory literally named ;.
The one shape that survives all of that is a spaced UNC path — \\srv\share name\proj. That is an ordinary corporate setup, not a corner case, and there every session for the project goes untagged.
Worth stating plainly: this is not a regression from #418. Before the gate, the same path stored a corrupted tag, which prunable_sessions skipped for exactly as long. #418 does not make it worse; it just makes the ceiling visible and worth naming.
Candidate directions
Transportable surrogate tag. Store a digest of the resolved path instead of the path itself, so every project identity is transportable by construction and the refusal path stops being reachable for ordinary paths. Both sides already route through runs.project_tag, so the change is centralized — but it is shared with the tmux backend, changes a persisted value's shape, and needs a read-side transition for sessions tagged the old way. This is the direction I would take, and it is why the work did not ride fix(adapters): gate the psmux session project tag on transportability (#320) #418.
Second ownership proof that outlives the run dir — e.g. have the prune consult a durable record keyed by session name. Larger, and it touches cleanup semantics rather than the tag.
Accept and document the ceiling, and have delete_run / archive_run refuse (or warn) while a matching session is still live, so the evidence is never removed out from under an orphan. Smallest, and it closes the sequence rather than the underlying weakness.
(1) and (3) are not exclusive; (3) is a cheap backstop for whatever happens to (1).
The fallback's other edge: ownership by run-id collision, not by identity
Split out of the #418 review alongside the lifecycle ceiling above. Same fallback, same root cause, same preferred remedy — recorded here rather than as a second issue because direction (1) closes both at once.
The lifecycle ceiling is about an untagged session that can never be pruned. The mirror failure is an untagged session pruned by the wrong project. prunable_sessions proves ownership for an untagged session by run-id collision on the filesystem, not by identity (runs.py:344-352):
run_dir=run_dir_for(project, run_id) # run_id parsed out of the SESSION NAMEtag=tags.get(name, "")
iftag:
iftag!=mine:
continueelifnotis_run(run_dir):
continue
If project A's session bmad-loop-<id> is untagged and project B holds a run dir with that same <id> whose engine reads dead, B's prune classifies A's session as prunable and kills it. B also reads B's pid file to make that call.
Two things bound it, neither of which closes it:
new_run_id() (runs.py:59) is %Y%m%d-%H%M%S plus two random bytes, so a natural cross-project collision is remote.
But --run-id is an accepted CLI flag (cli.py:2993, cli.py:3018) validated only for shape (cli.py:158-164), and the TUI pre-assigns ids. A script reusing a fixed --run-id across two projects reproduces it deterministically.
No test covers it.test_prunable_sessions_partitions (tests/test_runs.py:626) pins untagged-with-our-own-dead-run-dir → prunable and untagged-with-no-dir-here → skipped. There is no case for untagged, a run dir with the same id exists here, but the session belongs to another project — because the fixture cannot tell those apart, which is exactly the point. tests/test_tui_launch.py:391-409 covers the analogous window case only for a path-traversal id.
Direction (1) — a transportable surrogate tag — removes the reachable refusal shapes for ordinary paths, so sessions stay tagged and neither edge is reachable. Direction (3) does nothing for this edge.
Verification notes
Measured on Windows 11, psmux 3.3.7 (05cc5d4) — the round-trip table and the one-server-per-session finding are in #320 (comment). Nothing in this issue depends on a psmux release: the transport behavior is the documented model, not a bug awaiting a tag.
Split out of #320 (PR #418) by review, deliberately not folded into that fix: the resolution needs an intent decision, not a wider diff.
The gap
#418 gates the psmux session project tag on
_transportable(): a value that cannot survive psmux's CLI→server control line is refused rather than stored corrupted, leaving the option unset. That is the correct degradation, becauseruns.prunable_sessionsfalls back to the run dir for an untagged session — it claims our own dead runs and skips foreign ones.That fallback holds only while the run dir exists.
delete_run(runs.py:512) andarchive_run(runs.py:526) bothrmtreeit, and neither kills an already-orphaned session. So for a project whose tag is untransportable:clean/ archives the run, removing the directory;runs.py:351-352'selif not is_run(run_dir): continue, and skips it — from every project, including its own.Result: one leaked psmux session, and its server, per run, for the life of the machine.
Note the asymmetry this exposes. A tagged orphan whose run dir is gone is prunable — that is the
orphan-1case intest_prunable_sessions_partitions. The tag is the only ownership proof that survives run-dir removal, so the untagged branch is strictly weaker by design.Why this is not just theoretical, and not just a psmux problem
The refusal shapes look exotic until you filter them through
Path.resolve()on Windows:"is illegal in Windows filenames — unreachable in a resolved project path.\\and a trailing\are normalized away byresolve().;token needs a directory literally named;.The one shape that survives all of that is a spaced UNC path —
\\srv\share name\proj. That is an ordinary corporate setup, not a corner case, and there every session for the project goes untagged.Worth stating plainly: this is not a regression from #418. Before the gate, the same path stored a corrupted tag, which
prunable_sessionsskipped for exactly as long. #418 does not make it worse; it just makes the ceiling visible and worth naming.Candidate directions
runs.project_tag, so the change is centralized — but it is shared with the tmux backend, changes a persisted value's shape, and needs a read-side transition for sessions tagged the old way. This is the direction I would take, and it is why the work did not ride fix(adapters): gate the psmux session project tag on transportability (#320) #418.delete_run/archive_runrefuse (or warn) while a matching session is still live, so the evidence is never removed out from under an orphan. Smallest, and it closes the sequence rather than the underlying weakness.(1) and (3) are not exclusive; (3) is a cheap backstop for whatever happens to (1).
The fallback's other edge: ownership by run-id collision, not by identity
Split out of the #418 review alongside the lifecycle ceiling above. Same fallback, same root cause, same preferred remedy — recorded here rather than as a second issue because direction (1) closes both at once.
The lifecycle ceiling is about an untagged session that can never be pruned. The mirror failure is an untagged session pruned by the wrong project.
prunable_sessionsproves ownership for an untagged session by run-id collision on the filesystem, not by identity (runs.py:344-352):If project A's session
bmad-loop-<id>is untagged and project B holds a run dir with that same<id>whose engine reads dead, B's prune classifies A's session as prunable and kills it. B also reads B's pid file to make that call.Two things bound it, neither of which closes it:
new_run_id()(runs.py:59) is%Y%m%d-%H%M%Splus two random bytes, so a natural cross-project collision is remote.--run-idis an accepted CLI flag (cli.py:2993,cli.py:3018) validated only for shape (cli.py:158-164), and the TUI pre-assigns ids. A script reusing a fixed--run-idacross two projects reproduces it deterministically.Two notes on how this interacts with #418:
test_prunable_sessions_partitions(tests/test_runs.py:626) pins untagged-with-our-own-dead-run-dir → prunable and untagged-with-no-dir-here → skipped. There is no case for untagged, a run dir with the same id exists here, but the session belongs to another project — because the fixture cannot tell those apart, which is exactly the point.tests/test_tui_launch.py:391-409covers the analogous window case only for a path-traversal id.Direction (1) — a transportable surrogate tag — removes the reachable refusal shapes for ordinary paths, so sessions stay tagged and neither edge is reachable. Direction (3) does nothing for this edge.
Verification notes
Measured on Windows 11, psmux 3.3.7 (
05cc5d4) — the round-trip table and the one-server-per-session finding are in #320 (comment). Nothing in this issue depends on a psmux release: the transport behavior is the documented model, not a bug awaiting a tag.Refs: #320, #418