Skip to content

Hard stop cannot interrupt blocking post-session work on native Windows (file channel has no signal-path equivalent) #698

Description

@pbean

Split out of the #319 review (PR #697), where it was raised by codex as a P1 against engine.py's raise site B. The premise is correct and the gap is real, but the remedy proposed there is inert, and the remedy that works is a separate mechanism — so it is tracked here rather than bolted onto that PR.

The gap

#319 made a hard stop ride stop-request.json, honored at item boundaries and mid-session via a per-iteration poll in each adapter's wait loop. Between raise site B (the post-_save() check in _run_session) and the next item boundary, the engine performs deterministic post-session work that reads the control file nowhere:

  • the post_session plugin hook (plugins/model.py:75 — default timeout_sec = 120)
  • artifact processing / _verify_dev_artifacts
  • verify.run_verify_commands (engine.pyverify.py:3803)

COMMAND_TIMEOUT_S = 30 * 60 (verify.py:42) — thirty minutes, applied per command, looped over every entry in policy.verify.commands (verify.py:3813), with the suite run once on the dev leg and again on the review leg. So the unpolled window is worst-case N × 30 min, not 30 minutes.

_STOP_WAIT_S = 10.0 (runs.py). Any phase in this window exceeding 10 seconds ends in a force-kill.

Why it is a genuine parity gap, not just latency

The POSIX signal path does strictly better here. The handler raises, it does not set a flag consulted later (engine.py):

if self._stopping:
    return
self._stopping = True
raise RunStopped()

Measured: SIGTERM delivered 1.0s into a blocking subprocess.run shaped like run_verify_commands unwinds immediately, with no orphaned child. So on POSIX a stop during verify lands within a tick; on native Windows, where no inter-process signal is delivered at all, the file channel has unbounded latency in this same window. The file channel is weaker than the signal path precisely where it is meant to replace it.

Not a regression

Pre-#319, WindowsProcessHost.terminate shelled taskkill /PID without /F — a no-op against a console process — so native Windows force-killed after the full grace window in every phase. #319 narrowed that to this one window. This issue is about closing the remainder, not about a behavior that got worse.

The obvious fix does not work

Adding hard-request checks around the post-session phases changes nothing. The engine is inside subprocess.run and executes no bytecode until the child exits; a check placed before or after a blocking call cannot shorten it. Any single phase over 10 seconds force-kills regardless of how many checks bracket it — and the 30-minute command is exactly the case a bracketing check cannot touch. _thread.interrupt_main() is not a shortcut either: on Windows it takes effect only at the next bytecode boundary and will not break the main thread out of the handle wait in Popen.communicate, which is the platform this is about.

What would actually work — and its trap

A watchdog thread plus a child-process registry, so the blocking child itself becomes killable. The mechanism is sound; measured:

[watchdog] awake at t=1.00s, killed child
[main]     unblocked at t=1.00s with rc=-9

But a naive watchdog creates a worse bug than it fixes. The killed command surfaces as CommandResult(command, -9, ...) (verify.py:3835), and verify_command_results_outcome (verify.py:3872) classifies it:

if result.returncode != 0:
    return VerifyOutcome.retry(..., fixable=True)

So the engine would answer an operator's stop request by dispatching a repair session — spawning a fresh LLM session — on the strength of a "failure" it caused itself.

A workable design therefore needs three parts, and the second is the substantial one:

  1. a child-process registry + watchdog polling the control file;
  2. stop-awareness threaded into result classificationverify.py, plugins/bus.py, and the _run_git chokepoint each need to tell "killed because we are stopping" apart from "this failed";
  3. the main thread still has to unwind to a check point inside the grace window.

Note that (2) touches _run_git, which is a hard invariant chokepoint, and that the whole change alters behavior on every platform, not only the one with the gap.

A cheaper variant — a watchdog that performs teardown itself and calls os._exit, sidestepping verify entirely — was considered and rejected: it skips every finally block, leaking worktrees and temp state.

Scope check: what is actually at stake

Worth stating plainly, because it bounds how much this deserves. The current force-kill does not corrupt anything:

  • save_state writes a .json.tmp sibling then atomic_replace (journal.py:168), so SIGKILL cannot tear state.json.
  • The durability ordering at engine.py ("make the completed session durable before the usage read, post-session hooks, and follow-up verification") was written for this window.
  • The run stays resumable and _finish_inflight replays the interrupted phase.

The residue is an orphaned verify child on POSIX (force_kill signals the engine pid only); on Windows taskkill /F /T takes the tree, so there is none.

So the win from closing this is: the engine tears itself down rather than being killed, the "engine is the single writer of stopped" invariant holds without the fallback, no orphaned child on POSIX, and run-stop fallback=True keeps meaning what docs/FEATURES.md now says it means — a genuinely wedged engine. That is an invariant-and-accuracy win, not a data-safety one.

Acceptance

  • A hard stop landing while the engine is inside a long verify command is honored by the engine's own teardown on native Windows, within the grace window.
  • A command killed by that teardown is not classified as a verify failure and does not trigger a retry, repair session, or escalation.
  • Ablation-proven: deleting the stop-awareness in classification reddens a test that asserts no repair session is dispatched.

Related: #319, PR #697.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions