Skip to content

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

Description

@dracic

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:

  1. every agent session is created untagged;
  2. a crash (or any run whose session outlives its engine) leaves the session behind;
  3. the operator runs clean / archives the run, removing the directory;
  4. 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

  1. 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.
  2. 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.
  3. 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 NAME
tag = tags.get(name, "")
if tag:
    if tag != mine:
        continue
elif not is_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.

Two notes on how this interacts with #418:

  • It is not a regression, but it is not a wash either. Before the gate, an untransportable path stored a corrupted tag, which is non-empty and so was skipped unconditionally. Refusing moves those sessions from always skipped into a bucket that is only usually skipped. The trade is still right — a corrupted tag is unprunable forever, which is the defect psmux: the session-level project tag may bleed the same way the window tag did (#310) #320 was filed for — but "the untagged path skips foreign ones" is true only under the collision proviso, and fix(adapters): gate the psmux session project tag on transportability (#320) #418's own comment now says so.
  • 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.

Refs: #320, #418

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: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