Found by codex during the review gate on #518, verified by reproduction. Filed rather than fixed there: #518 does not touch cli.py, and the root cause predates it.
The root cause: an unlocked check-then-act on engine liveness
cmd_resume gates on runs.engine_liveness(run_dir) and then launches. Nothing holds a lock across the two steps, and engine.pid is not written until the detached child actually starts — so two resumes of the same paused run can both read a non-alive liveness and both proceed. The result is two engines driving one run dir, which is the outcome the gate's own message exists to prevent:
run <id> is still live — resuming would double-drive it; stop it first
This is on main (cli.py, the live == "alive" branch) and is reachable from both the CLI and the TUI. The TUI widens the window further, because resume/resolve read state, raise a confirm modal, and launch from the callback — a human-length gap between the check and the act.
The symptom codex reported
Downstream of that, the ctl-window record introduced by #518 goes stale. Two launches mint two windows under the same <kind>-<run_id> name and write the record unsynchronized, so the writes can land out of mint order:
launch._record_ctl_window(project, "RID", "@7") # the LATER window
launch._record_ctl_window(project, "RID", "@5") # the earlier one lands after
# both windows listed:
ctl_window_id(project, "RID") -> '@5' # the OLDER window wins
And a launch that fails to record removes the other launch's record wholesale, since the cleanup is unconditional:
launch._record_ctl_window(project, "RID", "@7")
launch._forget_ctl_window(project, "RID")
# record exists: False
ctl_window_id(project, "RID") -> '@5' # back to listing order
So a attaches to, and x kills, a parked or failed launcher while the other window drives the run.
Why the symptom should not be fixed on its own
Making the record generation-aware would restore correct window targeting for one of two engines that are both writing the same run dir. That is a worse state to be in than an ambiguous attach, because it looks correct. Mint order also cannot be recovered after the fact without either a generation source or the lock that is missing in the first place.
ctl_window_id re-proves the record against the live listing, so a stale record only wins while that window is still listed under the run id — which is exactly the concurrent-launch case.
Suggested shape
Serialize launch-per-run: take an exclusive lock on the run dir across the liveness check and the launch, so the second resume observes the first and refuses. platform_util.file_lock already provides the primitive (an advisory lock released by the kernel if the holder dies) and is used this way by install.py. That fixes the double-drive and makes the record ordering question disappear rather than needing its own mechanism.
Worth deciding at the same time whether the CLI's unknown liveness path (which warns and proceeds, deliberately, so resume stays usable when a pid is unverifiable) should also be serialized — the lock and the liveness verdict answer different questions.
Related
Found by codex during the review gate on #518, verified by reproduction. Filed rather than fixed there: #518 does not touch
cli.py, and the root cause predates it.The root cause: an unlocked check-then-act on engine liveness
cmd_resumegates onruns.engine_liveness(run_dir)and then launches. Nothing holds a lock across the two steps, andengine.pidis not written until the detached child actually starts — so two resumes of the same paused run can both read a non-aliveliveness and both proceed. The result is two engines driving one run dir, which is the outcome the gate's own message exists to prevent:This is on
main(cli.py, thelive == "alive"branch) and is reachable from both the CLI and the TUI. The TUI widens the window further, because resume/resolve read state, raise a confirm modal, and launch from the callback — a human-length gap between the check and the act.The symptom codex reported
Downstream of that, the ctl-window record introduced by #518 goes stale. Two launches mint two windows under the same
<kind>-<run_id>name and write the record unsynchronized, so the writes can land out of mint order:And a launch that fails to record removes the other launch's record wholesale, since the cleanup is unconditional:
So
aattaches to, andxkills, a parked or failed launcher while the other window drives the run.Why the symptom should not be fixed on its own
Making the record generation-aware would restore correct window targeting for one of two engines that are both writing the same run dir. That is a worse state to be in than an ambiguous attach, because it looks correct. Mint order also cannot be recovered after the fact without either a generation source or the lock that is missing in the first place.
ctl_window_idre-proves the record against the live listing, so a stale record only wins while that window is still listed under the run id — which is exactly the concurrent-launch case.Suggested shape
Serialize launch-per-run: take an exclusive lock on the run dir across the liveness check and the launch, so the second resume observes the first and refuses.
platform_util.file_lockalready provides the primitive (an advisory lock released by the kernel if the holder dies) and is used this way byinstall.py. That fixes the double-drive and makes the record ordering question disappear rather than needing its own mechanism.Worth deciding at the same time whether the CLI's
unknownliveness path (which warns and proceeds, deliberately, so resume stays usable when a pid is unverifiable) should also be serialized — the lock and the liveness verdict answer different questions.Related