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
On native Windows, stop_run's preferred path — the engine's own SIGTERM handler — cannot fire. Every bmad-loop stop therefore burns the full _STOP_WAIT_S = 10.0 grace window doing nothing, force-kills, and completes through the fallback branch.
stop_run's docstring (runs.py:442) states the intent:
Prefers the engine's own SIGTERM handler so the engine stays the single writer of stopped (it marks the run, kills its in-flight agent window, and exits).
That preference is POSIX-only in practice.
Why it can't fire
WindowsProcessHost.terminate (process_host.py:195-199) is taskkill /PIDwithout/F, run with check=False, capture_output=True — so its failure is silently discarded.
Graceful taskkill posts WM_CLOSE. The engine is a console process with no message loop, so nothing receives it.
Engine._install_stop_signals does register SIGTERM (engine.py:541), but on Windows no external tool can deliver a Python-level SIGTERM: taskkill posts WM_CLOSE, taskkill /F calls TerminateProcess, and neither runs the interpreter's handler. (The win32 branch at engine.py:530 deliberately ignores SIGINT/SIGBREAK console-ctrl events, so those aren't a route either.)
stop_run then waits out the grace window, force-kills via taskkill /F /T, finds stopped unset, and falls through to the fallback that marks it externally.
Evidence
Measured on a Windows VM (PowerShell, CPython 3.13, stock PATH) while verifying #293 in PR #314:
> uv run pytest tests/test_runs.py::test_stop_run_signals_live_process -q --durations=0
1 passed in 11.19s
10.26s call tests/test_runs.py::test_stop_run_signals_live_process
10.26s is _STOP_WAIT_S consumed in full: host.is_alive(pid) stayed true for the entire window after terminate() returned. That is a direct measurement of graceful taskkill failing to close a console child, not an inference.
The same run on Linux completes in well under a second.
Impact
Correctness is preserved — the fallback marks stopped and kill_session backstops the agent window — so this is not a live defect and not a regression (it predates #314; sleep.exe had the identical shape). What it costs:
Every bmad-loop stop on native Windows takes ~10s.
The engine never performs its own teardown: it doesn't mark stopped, doesn't kill its in-flight agent window, and is force-killed mid-operation instead of unwinding through RunStopped. Any invariant that assumes the engine is the single writer of stopped holds only by fallback.
Every Windows stop journals run-stop with fallback=True (runs.py:505) — a usable signature if anyone wants to confirm this in the field.
tests/test_runs.py::test_stop_run_signals_live_process is ~10s on the Windows lane, by design rather than by fault.
GenerateConsoleCtrlEvent / CTRL_BREAK_EVENT. Reaches a console process properly, but requires the engine be spawned with CREATE_NEW_PROCESS_GROUP, and the win32 branch in _install_stop_signals currently ignores SIGBREAK — so both ends would have to change together.
Shorten _STOP_WAIT_S on win32. Treats the symptom only; the engine still never runs its own teardown. Cheapest, and defensible if the tier doesn't justify more.
Native Windows sits below the Linux/macOS/WSL support tier (setup-guide), so option 3 may well be the right scope.
Summary
On native Windows,
stop_run's preferred path — the engine's own SIGTERM handler — cannot fire. Everybmad-loop stoptherefore burns the full_STOP_WAIT_S = 10.0grace window doing nothing, force-kills, and completes through the fallback branch.stop_run's docstring (runs.py:442) states the intent:That preference is POSIX-only in practice.
Why it can't fire
WindowsProcessHost.terminate(process_host.py:195-199) istaskkill /PIDwithout/F, run withcheck=False, capture_output=True— so its failure is silently discarded.taskkillpostsWM_CLOSE. The engine is a console process with no message loop, so nothing receives it.Engine._install_stop_signalsdoes register SIGTERM (engine.py:541), but on Windows no external tool can deliver a Python-level SIGTERM:taskkillpostsWM_CLOSE,taskkill /FcallsTerminateProcess, and neither runs the interpreter's handler. (The win32 branch atengine.py:530deliberately ignores SIGINT/SIGBREAK console-ctrl events, so those aren't a route either.)stop_runthen waits out the grace window, force-kills viataskkill /F /T, findsstoppedunset, and falls through to the fallback that marks it externally.Evidence
Measured on a Windows VM (PowerShell, CPython 3.13, stock PATH) while verifying #293 in PR #314:
10.26sis_STOP_WAIT_Sconsumed in full:host.is_alive(pid)stayed true for the entire window afterterminate()returned. That is a direct measurement of gracefultaskkillfailing to close a console child, not an inference.The same run on Linux completes in well under a second.
Impact
Correctness is preserved — the fallback marks
stoppedandkill_sessionbackstops the agent window — so this is not a live defect and not a regression (it predates #314;sleep.exehad the identical shape). What it costs:bmad-loop stopon native Windows takes ~10s.stopped, doesn't kill its in-flight agent window, and is force-killed mid-operation instead of unwinding throughRunStopped. Any invariant that assumes the engine is the single writer ofstoppedholds only by fallback.run-stopwithfallback=True(runs.py:505) — a usable signature if anyone wants to confirm this in the field.tests/test_runs.py::test_stop_run_signals_live_processis ~10s on the Windows lane, by design rather than by fault.Possible directions (needs a design call)
request_graceful_stop,stop-request.json, feat: graceful stop for bmad-loop runs (finish the in-flight item, then stop — resumable) #219) already establishes a deterministic file-based channel the engine polls. Reusing that shape for hard stop would be cross-platform and wouldn't depend on signal semantics at all.GenerateConsoleCtrlEvent/CTRL_BREAK_EVENT. Reaches a console process properly, but requires the engine be spawned withCREATE_NEW_PROCESS_GROUP, and the win32 branch in_install_stop_signalscurrently ignores SIGBREAK — so both ends would have to change together._STOP_WAIT_Son win32. Treats the symptom only; the engine still never runs its own teardown. Cheapest, and defensible if the tier doesn't justify more.Native Windows sits below the Linux/macOS/WSL support tier (setup-guide), so option 3 may well be the right scope.
Refs: #293, PR #314