Skip to content

Run-dir removal can strand a live session, and the untagged run-id-collision prune is untested (residuals of #419 after #523) #526

Description

@pbean

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclearea:psmuxpsmux terminal-multiplexer backendbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions