Repository navigation
stop/attach: a run id typed in another letter case reports "stopped" while the agent session keeps running (psmux 3.4.0, Windows) #904
Description
Summary
On Windows, bmad-loop stop <run id> with the id typed in a different letter case (for example 20261013-150001-ABCD for run 20261013-150001-abcd) resolves the run directory, prints run … stopped and exits 0, but its backstop kill misses the run's agent session. When the run's engine is already gone (the case that backstop exists for), that session keeps running; a live engine attempts teardown under its persisted run ID (source-derived, not observed). bmad-loop attach with the same spelling answers nothing to attach while that session is live. The run ref is resolved through a case-insensitive filesystem and then replayed with the typed spelling as a multiplexer session name, and since psmux 3.4.0 session targets are matched case-exactly, like tmux. A stop that is reported as done and is not is the misleading-success shape: the operator walks away from a coding CLI that is still running.
Environment
psmux 3.4.0 (20aa1d7), Windows 11 Enterprise 10.0.26200, PowerShell 7.6.6 (launching shell; SHELL removed); bmad-loop 4c00c37 (identical runs.py and cli.py on main after the #900 merge).
Steps to reproduce
Launched from PowerShell 7, in a bmad-loop checkout (uv sync done). The agent session is created with raw psmux in the registry bmad-loop derives for the project, standing in for a run whose engine died before tearing its session down (the case the stop's backstop kill exists for). Zero LLM tokens.
Remove-Item Env:SHELL -ErrorAction SilentlyContinue
$env:BMAD_LOOP_MUX_BACKEND = 'psmux'; $env:PSMUX_NO_WARM = '1'; $env:PYTHONUTF8 = '1'
$env:BMAD_LOOP_STATE_DIR = "$env:TEMP\bl-case\state"
$Proj = "$env:TEMP\bl-case\proj"
New-Item -ItemType Directory -Force $Proj | Out-Null; git -C $Proj init -q -b main
Set-Content "$Proj\src.txt" x; git -C $Proj -c user.email=t@t -c user.name=t add -A; git -C $Proj -c user.email=t@t -c user.name=t commit -q -m init
$Reg = ((uv run bmad-loop mux --project $Proj) -match '^registry: ') -replace '^registry: (.*) \(derived.*$', '$1'
$rid = '20261013-150001-abcd'
New-Item -ItemType Directory -Force "$Proj\.bmad-loop\runs\$rid" | Out-Null
Set-Content "$Proj\.bmad-loop\runs\$rid\state.json" "{""run_id"": ""$rid"", ""project"": ""x"", ""started_at"": ""2026-10-13T14:00:00""}"
$env:PSMUX_DATA_DIR = $Reg; psmux new-session -d -s "bmad-loop-$rid"; Remove-Item Env:PSMUX_DATA_DIR # rc 0
uv run bmad-loop attach $rid.ToUpper() --project $Proj # rc 1: nothing to attach for run 20261013-150001-ABCD
uv run bmad-loop stop $rid.ToUpper() --project $Proj # rc 0: run 20261013-150001-ABCD stopped
$env:PSMUX_DATA_DIR = $Reg; psmux ls -F '#{session_name}'; Remove-Item Env:PSMUX_DATA_DIR
# bmad-loop-20261013-150001-abcd <- still running
# positive control: the id as minted
uv run bmad-loop stop $rid --project $Proj # rc 0: run 20261013-150001-abcd stopped
$env:PSMUX_DATA_DIR = $Reg; psmux ls -F '#{session_name}'
# psmux: no server running on <registry> <- session gone
psmux kill-server 2>$null; Remove-Item Env:PSMUX_DATA_DIRMeasured 3/3 (three fresh run ids in one script), positive control 3/3. Patching runs.resolve_run_dir in-process to return the on-disk spelling flips the mis-cased stop to killing the session (2/2), so the typed spelling flowing out of the resolver is the cause.
Expected vs actual
| actual | expected | |
|---|---|---|
stop <ID IN OTHER CASE> |
rc 0, run …-ABCD stopped, agent session bmad-loop-…-abcd still listed |
the run's own session killed, as with the minted spelling; or a refusal naming the run by its real id |
attach <ID IN OTHER CASE> |
rc 1, nothing to attach for run …-ABCD while the session is live |
attaches to bmad-loop-…-abcd |
stop <id as minted> (control) |
rc 0, session gone | same |
Source
bmad-loop 4c00c37:
src/bmad_loop/runs.py:1337-1343(resolve_run_dir): the exact branch recomposes the path from the raw ref and returns it whenstate.jsonexists. On NTFSruns\20261013-150001-ABCD\state.jsonexists for run…-abcd, so the returnedPathcarries the typed spelling.exact = run_dir_for(project, ref) ... if stat.S_ISREG((exact / STATE_FILE).stat().st_mode): return exact
src/bmad_loop/cli.py:6488-6500(cmd_stop):args.run_id = run_dir.name, thenruns.stop_run(run_dir);src/bmad_loop/runs.py:2996(_stop_run_once): the backstopkill_session(run_dir.name), which sendskill-session -t =bmad-loop-20261013-150001-ABCDwithcheck=False(adapters/tmux_base.py:391-399), so the missed target does not makestopfail.cmd_attach(cli.py:6437) feeds the samerun_dir.nametoattach_plan, whoseagent_session_exists(session_name(run_id))and ctl-window name match (m.group(1) != run_id) are both case-exact.src/bmad_loop/adapters/psmux_backend.py:351-359(session_name_key) still folds case, and its docstring states that a mis-cased target "answers has-session … and a kill through it takes the lowercase session down" (measured on 3.3.8). That stopped being true in 3.4.0.
psmux v3.4.0 (tag commit 20aa1d7; case-target fix 624ea6df, psmux/psmux#774): an explicit session target whose stored spelling differs is "not that session", src/main.rs:1359 (general -t path) and src/main.rs:3530 (kill-session):
if !std::path::Path::new(&port_path).exists()
|| crate::session::registry_case_variant(&session_name).is_some()
{
eprintln!("psmux: can't find session: {}", shown);registry_case_variant does not exist at v3.3.8 (source-derived, not observed: 3.3.8 is not installed here), where the target resolved by opening <name>.port case-insensitively, so this path worked by accident before 3.4.0.
Mixed signal inside bmad-loop, observed: runs.live_session_may_be_ours(project, "…-ABCD") answers True (it lists sessions and compares through the folding key), so the removal guard behind delete would see a live session for that spelling (source-derived, delete itself not run) that stop and attach with the same spelling cannot reach.
Impact on bmad-loop
bmad-loop stop and bmad-loop attach on Windows with psmux 3.4.0, whenever the run id is typed in a different case than it was minted: an uppercase-typed hex suffix, or a caller-supplied --run-id (for example MyRun) later typed as myrun. Source-derived, not observed here: partial refs use case-sensitive name matching and a miscased partial ref fails with no such run. The TUI's listed-ID path avoids this typed-spelling mismatch. Cleanup enumerates session names in their stored spelling and can reach this orphan through its normal ownership and liveness checks. On a case-sensitive filesystem, the miscased full ref misses the exact branch and fails unless another listed run matches. Issue #879 discusses case-only duplicate creation and deliberately retains the registry-slot folding key. This defect instead passes the operator's spelling through run-ref resolution into exact session targets.
Suggested fix
Not validated as a code change: make resolve_run_dir return the run dir under its on-disk spelling (for example, take the exact branch's answer from list_run_dirs by os.path.normcase equality, or Path.resolve() the hit on Windows), so every by-name verb downstream gets the minted run id. The in-process patch above shows the direction for stop; fixing it at the resolver covers stop, attach, resume, delete and every other resolve_run_dir caller at once.
Activity
fix(runs): resolve a miscased run ref to the run id's on-disk spelling