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
Status 2026-09-20 (v0.12.0). Narrowed, not closed: the cross-project kill now reaches only tmux; on psmux every arm degrades to a skip, which leaves the stranding this issue names. #537 (65183872, PR #728, in 0.12.0) gave psmux a per-project registry root: prune_sessions (src/bmad_loop/runs.py:1577) passes require_tag=not _registry_proves_ownership(project) (src/bmad_loop/runs.py:1612; helper at src/bmad_loop/runs.py:1521), and the legacy pass always runs with require_tag=True (src/bmad_loop/runs.py:1618). Consequences:
psmux, own registry — the listing is already this project's sessions, so the untagged run-dir fallback is evidence, not a guess; a run-id collision cannot reach another project's session. Cross-project kill closed; an untagged orphan whose run dir is gone is still skipped at src/bmad_loop/runs.py:1509 (elif require_tag or not is_run(run_dir)), so the stranding remains.
psmux, shared registry (export degraded, leaving an ambient PSMUX_DATA_DIR or psmux's <home>\.psmux default in force — otherwise export_psmux_registry_root always derives the root and overrides any ambient value, src/bmad_loop/runs.py:544) — the tag is mandatory, so an untagged session is skipped rather than claimed. The cross-project kill is closed; an untagged session of ours has no proof left to read, stays standing, and is not reported: legacy_registry_leftovers (src/bmad_loop/runs.py:1635) walks only _legacy_registries() (src/bmad_loop/runs.py:1741; def at :1775), never the primary pass's registry. That stranding is this issue.
tmux — no registry namespace, so _registry_proves_ownership keeps the historical reach (return not mux.has_registry_namespace(), src/bmad_loop/runs.py:1568) and prunable_sessions' untagged arm (src/bmad_loop/runs.py:1509) still claims on is_run alone. The exposure below is unchanged there.
(2) A second ownership proof that outlives the run dir — not shipped, and now with no open
issue. This one.
Filing it so the direction has a home. This is not a new defect report: the exposure is already
pinned in-tree as characterization. What is new is that the code and the test both point a future
reader at a closed issue for the remedy.
The exposure, as pinned today
prunable_sessions (src/bmad_loop/runs.py:365-411) proves ownership of an untagged session by
run-id collision on the filesystem, not by identity: the run id is parsed out of the session name,
and is_run(run_dir_for(project, run_id)) decides. Two projects that share a run id therefore
disagree about one live session — pinned by test_prunable_sessions_claims_an_untagged_session_on_a_run_id_collision (tests/test_runs.py),
which asserts both views: ours classifies it prunable while theirs is still running it. prune_sessions (runs.py:414-426) kills every id in that list. The collision needs no luck — --run-id is caller-supplied and validated for shape only.
_refuse_live_session guards the opposite sequence (removing the run dir out from under a live
session) and its own docstring declines to call the remedy sound (runs.py:618-625): it points the
operator at bmad-loop cleanup, which is the prune above, "so on the untagged case it can prune
another project's session on a shared run id". It cannot do better — nothing available at that point
proves the session ours, and minting a proof that outlives the run dir is this direction.
The cause #419 did not enumerate: a degraded read, not a failed write
prunable_sessions' own docstring already records it (runs.py:382-384) — untagged is reachable
"when the option read degrades (session_options reads unset as 'no answer', never as proof nothing
was written)". BaseTmuxBackend.session_options (src/bmad_loop/adapters/tmux_base.py:173-191)
returns {} for a missing binary, an exception, or any nonzero exit. So a single failed read
makes every live session read untagged at once, not one — the fallback stops being an edge case
and becomes the whole ownership signal, which is exactly the end-state #489 Q2 predicted for a reap,
reachable with no reap involved.
Worth stating precisely, because the three causes are not equally live after #523:
Cause
Agent sessions
Notes
Tag write failed
not silent on tmux — set_session_option goes through the strict _tmux (tmux_base.py:101-110, :144-147), which raises
unlike ctl windows, where set_window_option swallows (tmux_base.py:401-405) — that half is #531
psmux refuses the value
cleared for ordinary paths by #523's digest; the gate remains as the general contract for every @ session option (psmux_backend.py:505-524)
Legacy / pre-digest sessions
still reachable
accepted_tags keeps the legacy path tag readable (runs.py:331-350)
Option read degrades
reachable on both backends, affects every session at once
tmux_base.py:173-191
Scope
Not a change to prunable_sessions. A durable record keyed by session name that a prune can consult
when the tag is unreadable — larger than the tag, and it touches cleanup semantics, which is why #419 ranked it below (1).
When it lands, flip test_prunable_sessions_claims_an_untagged_session_on_a_run_id_collision
deliberately (its docstring says so) and update the two in-tree pointers that currently name #419, now closed: runs.py:625 and that test's docstring.
Refs: #419 (closed), #523, #526, #531 (the control-window consumer of the same weak ownership), #489
#419 listed three candidate directions. Two shipped and it was closed:
814f0a2): ordinary paths now tag by hexdigest, so the psmux transportability refusal stops being reachable for them.
(
_refuse_live_session,src/bmad_loop/runs.py:598).issue. This one.
Filing it so the direction has a home. This is not a new defect report: the exposure is already
pinned in-tree as characterization. What is new is that the code and the test both point a future
reader at a closed issue for the remedy.
The exposure, as pinned today
prunable_sessions(src/bmad_loop/runs.py:365-411) proves ownership of an untagged session byrun-id collision on the filesystem, not by identity: the run id is parsed out of the session name,
and
is_run(run_dir_for(project, run_id))decides. Two projects that share a run id thereforedisagree about one live session — pinned by
test_prunable_sessions_claims_an_untagged_session_on_a_run_id_collision(tests/test_runs.py),which asserts both views:
oursclassifies it prunable whiletheirsis still running it.prune_sessions(runs.py:414-426) kills every id in that list. The collision needs no luck —--run-idis caller-supplied and validated for shape only.Why #526's backstop does not close it
_refuse_live_sessionguards the opposite sequence (removing the run dir out from under a livesession) and its own docstring declines to call the remedy sound (
runs.py:618-625): it points theoperator at
bmad-loop cleanup, which is the prune above, "so on the untagged case it can pruneanother project's session on a shared run id". It cannot do better — nothing available at that point
proves the session ours, and minting a proof that outlives the run dir is this direction.
The cause #419 did not enumerate: a degraded read, not a failed write
prunable_sessions' own docstring already records it (runs.py:382-384) — untagged is reachable"when the option read degrades (
session_optionsreads unset as 'no answer', never as proof nothingwas written)".
BaseTmuxBackend.session_options(src/bmad_loop/adapters/tmux_base.py:173-191)returns
{}for a missing binary, an exception, or any nonzero exit. So a single failed readmakes every live session read untagged at once, not one — the fallback stops being an edge case
and becomes the whole ownership signal, which is exactly the end-state #489 Q2 predicted for a reap,
reachable with no reap involved.
Worth stating precisely, because the three causes are not equally live after #523:
set_session_optiongoes through the strict_tmux(tmux_base.py:101-110,:144-147), which raisesset_window_optionswallows (tmux_base.py:401-405) — that half is #531@session option (psmux_backend.py:505-524)accepted_tagskeeps the legacy path tag readable (runs.py:331-350)tmux_base.py:173-191Scope
Not a change to
prunable_sessions. A durable record keyed by session name that a prune can consultwhen the tag is unreadable — larger than the tag, and it touches cleanup semantics, which is why
#419 ranked it below (1).
When it lands, flip
test_prunable_sessions_claims_an_untagged_session_on_a_run_id_collisiondeliberately (its docstring says so) and update the two in-tree pointers that currently name
#419, now closed:
runs.py:625and that test's docstring.Refs: #419 (closed), #523, #526, #531 (the control-window consumer of the same weak ownership), #489