Split out of #419 ahead of PR #523. #523 implements #419's direction (1) — runs.project_tag
becomes a 16-hex sha256 of the resolved path, so a project identity clears psmux's transport
gate by construction — and its body carries a GitHub closing keyword aimed at the parent, so
#419 will not stay open past that merge. Two pieces named in #419 are not in that diff. This
issue carries them forward so the merge does not orphan them.
Line references below are at #523's head (42643ac), i.e. what main will look like after it
lands.
Residual A — direction (3): run-dir removal can still strand a live session
runs.delete_run (runs.py:529) and runs.archive_run (runs.py:534) still rmtree a run
directory with no check for a live bmad-loop-<run-id> session. Both docstrings say "Callers
enforce the live guard", and they do — but every caller's guard is keyed on engine pid
liveness, not on session liveness:
cli._stop_or_block_live_engine (cli.py:2599), used by cmd_delete and cmd_archive,
blocks only when runs.engine_liveness(run_dir) == "alive"; unknown warns and proceeds.
- The TUI's
action_delete_run / action_archive_run (tui/app.py:948, tui/app.py:988)
block only on data.liveness(run_dir) == "alive".
cmd_clean's retention path (cli.py:2777, cli.py:2781) warns on unknown and proceeds.
An orphaned session — engine dead, session still alive in the multiplexer — is exactly the
state that passes all three. It is also the one state in which the run directory is the only
remaining ownership proof for an untagged session, which is what makes the removal load-bearing
rather than incidental. Note the --force asymmetry: forcing past a live engine routes
through runs.stop_run, which kills the session; the orphan never reaches that branch, because
its engine already reads dead.
Why this survives #523. The digest makes ordinary paths tag successfully, so the leak is
unreachable for them. But untagged state stays reachable by construction, and #523 deliberately
keeps the run-dir fallback for it — see the prunable_sessions docstring at that head: a tag
write that failed, an option read that degraded (session_options reads unset as "no answer",
never as proof nothing was written), or a session predating a working tag write. For those,
removing the run dir still erases the last ownership proof, and the session leaks for the life
of the machine — one psmux server per leaked session, per #419's verification notes.
#419 states the relationship directly: directions (1) and (3) are not exclusive, and (3) is a
cheap backstop for whatever happens to (1). #523 is (1); this is (3).
Shape sketch (not a spec). Have the run-dir removal paths refuse, or warn, when a live
bmad-loop-<run-id> session matches the id being removed, so the evidence is never taken out
from under an orphan. Repo doctrine applies: observation may degrade, repair writes raise
(AGENTS.md), and the guard should stay minimal — no widening of cleanup semantics.
Residual B — the missing untagged-collision characterization test
#419's second edge is ownership proven by run-id collision on the filesystem rather than by
identity: 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 — reading B's pid file to make the call.
tests/test_runs.py::test_prunable_sessions_partitions (test_runs.py:627 at that head) pins
the two neighbouring cases and not this one:
untag-fin — untagged, our own dead run dir here → prunable.
untag-orphan — untagged, no run dir here → skipped.
- absent: untagged, a run dir with the same id exists here, but the session belongs to
another project.
#523 does not add it; its new tests (test_project_tag_is_transportable_whatever_the_path,
test_prunable_sessions_accepts_legacy_path_tag) cover the digest shape and legacy path-tag
acceptance instead. The gap is reachable deterministically rather than by chance: --run-id is
an accepted CLI flag (cli.py:3219, cli.py:3244) validated only for shape (cli.py:158-164),
and the TUI pre-assigns ids, so a script reusing one fixed run id across two projects reproduces
the collision. The analogous ctl-window case in tests/test_tui_launch.py is covered only for a
path-traversal id.
The test should pin the actual outcome, whatever it is once Residual A lands, so the
behavior is characterized rather than assumed — the fixture genuinely cannot tell the two
untagged cases apart, which is #419's point.
Scope note
Residual A is a backstop for a sequence; Residual B is coverage for an edge the backstop does
not touch. They are filed together because they share the same root (untagged sessions are weak
ownership) and the same test module, not because one depends on the other.
Refs: #419, #523, #418, #320
Split out of #419 ahead of PR #523. #523 implements #419's direction (1) —
runs.project_tagbecomes a 16-hex sha256 of the resolved path, so a project identity clears psmux's transport
gate by construction — and its body carries a GitHub closing keyword aimed at the parent, so
#419 will not stay open past that merge. Two pieces named in #419 are not in that diff. This
issue carries them forward so the merge does not orphan them.
Line references below are at #523's head (
42643ac), i.e. what main will look like after itlands.
Residual A — direction (3): run-dir removal can still strand a live session
runs.delete_run(runs.py:529) andruns.archive_run(runs.py:534) stillrmtreea rundirectory with no check for a live
bmad-loop-<run-id>session. Both docstrings say "Callersenforce the live guard", and they do — but every caller's guard is keyed on engine pid
liveness, not on session liveness:
cli._stop_or_block_live_engine(cli.py:2599), used bycmd_deleteandcmd_archive,blocks only when
runs.engine_liveness(run_dir) == "alive";unknownwarns and proceeds.action_delete_run/action_archive_run(tui/app.py:948,tui/app.py:988)block only on
data.liveness(run_dir) == "alive".cmd_clean's retention path (cli.py:2777,cli.py:2781) warns onunknownand proceeds.An orphaned session — engine dead, session still alive in the multiplexer — is exactly the
state that passes all three. It is also the one state in which the run directory is the only
remaining ownership proof for an untagged session, which is what makes the removal load-bearing
rather than incidental. Note the
--forceasymmetry: forcing past a live engine routesthrough
runs.stop_run, which kills the session; the orphan never reaches that branch, becauseits engine already reads dead.
Why this survives #523. The digest makes ordinary paths tag successfully, so the leak is
unreachable for them. But untagged state stays reachable by construction, and #523 deliberately
keeps the run-dir fallback for it — see the
prunable_sessionsdocstring at that head: a tagwrite that failed, an option read that degraded (
session_optionsreads unset as "no answer",never as proof nothing was written), or a session predating a working tag write. For those,
removing the run dir still erases the last ownership proof, and the session leaks for the life
of the machine — one psmux server per leaked session, per #419's verification notes.
#419 states the relationship directly: directions (1) and (3) are not exclusive, and (3) is a
cheap backstop for whatever happens to (1). #523 is (1); this is (3).
Shape sketch (not a spec). Have the run-dir removal paths refuse, or warn, when a live
bmad-loop-<run-id>session matches the id being removed, so the evidence is never taken outfrom under an orphan. Repo doctrine applies: observation may degrade, repair writes raise
(
AGENTS.md), and the guard should stay minimal — no widening of cleanup semantics.Residual B — the missing untagged-collision characterization test
#419's second edge is ownership proven by run-id collision on the filesystem rather than by
identity: if project A's session
bmad-loop-<id>is untagged and project B holds a run dir withthat same
<id>whose engine reads dead, B's prune classifies A's session as prunable and killsit — reading B's pid file to make the call.
tests/test_runs.py::test_prunable_sessions_partitions(test_runs.py:627at that head) pinsthe two neighbouring cases and not this one:
untag-fin— untagged, our own dead run dir here → prunable.untag-orphan— untagged, no run dir here → skipped.another project.
#523 does not add it; its new tests (
test_project_tag_is_transportable_whatever_the_path,test_prunable_sessions_accepts_legacy_path_tag) cover the digest shape and legacy path-tagacceptance instead. The gap is reachable deterministically rather than by chance:
--run-idisan accepted CLI flag (
cli.py:3219,cli.py:3244) validated only for shape (cli.py:158-164),and the TUI pre-assigns ids, so a script reusing one fixed run id across two projects reproduces
the collision. The analogous ctl-window case in
tests/test_tui_launch.pyis covered only for apath-traversal id.
The test should pin the actual outcome, whatever it is once Residual A lands, so the
behavior is characterized rather than assumed — the fixture genuinely cannot tell the two
untagged cases apart, which is #419's point.
Scope note
Residual A is a backstop for a sequence; Residual B is coverage for an edge the backstop does
not touch. They are filed together because they share the same root (untagged sessions are weak
ownership) and the same test module, not because one depends on the other.
Refs: #419, #523, #418, #320