ADR 0105: Interruption reaches the gate's detached job groups
Vocabulary amendment: Current pointers use
finish→done,graduate→accept; the decision and reasoning are unchanged.
Status: accepted; follows ADR 0028 (the result envelope the cancelled run still serializes into) and ADR 0051 (the guards are parameterized off the single signal list).
Context
§Gate jobs are spawned detached, each leading its own process group, so the runner can tree-kill a whole job — grandchildren included — with one group signal. That mechanism had exactly one trigger: a fail-fast sibling failure inside runParallel. No externally-initiated shutdown reached it:
- MCP request cancellation. A client cancelling a
discern_donecall (notifications/cancelled) aborted nothing — the tool handlers dropped the SDK's per-request abort signal, so the gate ran invisibly to completion inside the still-alive server. Cancel twice and retry, and three concurrent gates raced one another over the same build caches — an observed failure on a project whose gate takes minutes. - OS interrupts. The detachment that enables tree-killing also removes the free delivery a foreground child gets: the terminal's Ctrl-C reaches discern and never its jobs. With no signal listener anywhere, an interrupted
discern doneorphaned every in-flight job. - Server shutdown. stdin EOF closed the transport and exited the process, leaving an in-flight call's detached jobs running.
runSerial (the fix stage) passed no signal at all, so a serial job could not be cancelled by anything.
Decision
§Every shutdown path funnels into the run's one abort controller — the single source the tree-kill already listens to.
- The runner accepts an external
AbortSignal(RunOptions.signal), chained into the run's controller;runSerialgains the same controller and stops between jobs after an abort. Jobs killed this way reportcancelled, like a fail-fast-killed sibling. - The gate cores (
finishResult/prepareResult/testResult) accept and forward that signal. The MCP tool contract carries it: each handler forwards the SDK's per-request signal merged (AbortSignal.any) with a server-level controller aborted on stdin EOF — so a cancelled call and a dying server both kill the jobs before the process exits. - OS interrupts are the runner's own concern: a reference-counted watcher (
jobs/interrupt.ts) installs SIGINT/SIGTERM/SIGHUP listeners only while at least one stage run is in flight, aborts every active run's controller on an interrupt, and — once the last run has settled and reaped its children — re-raises the signal with the default disposition restored, so the process still dies with the conventional killed-by-signal status. Living in the runner, it covers every gate caller (the CLI verbs, setup's probes, the gate re-run insideaccept, the MCP server process) with no per-entry-point wiring to forget.
SIGKILL is explicitly out of scope: no handler runs, so its orphans are unpreventable; the covered paths are every interruption a user or agent actually performs.
Consequences
§- Cancelling an MCP gate call, interrupting the CLI, or killing the MCP server now stops the gate's processes instead of orphaning them.
- An interrupted CLI run prints its
cancelledbanners, then dies by the signal — scripts observe the conventional status, not a fabricated exit code. The full result tail may not render; an interrupt asked the process to stop, not to report. - The guards are class-shaped (ADR 0051): the E2E interrupt test iterates the exported
INTERRUPT_SIGNALS, so a newly watched signal auto-enrols; the MCP tests drive the real stdio server through a cancellation and a mid-call shutdown and assert the recorded job PID dies. - While a gate run is in flight the default signal disposition is overridden (restored the moment the last run settles); a second interrupt during the brief reap window is coalesced rather than escalating.