Skip to content

ADR 0180: Every spawn surface declares its interrupt contract

Status: accepted; extends ADR 0054's subprocess funnels with an interrupt-coverage contract, applying the ADR 0051/ADR 0176 registry discipline to process spawning.

Context

§

The engine's spawned children carry an invariant nothing stated in one place: a signal delivered to the engine's PID must stop and reap every potentially long-running child's whole process tree. Two of the three long-running spawners honored it, each with its own machinery and its own hand-written E2E — gate jobs (detached groups, the engine's run watcher) and the owned-child boundary behind Project Scripts. The third, the worktree lifecycle runner (runShellRouted, behind [worktree.setup].steps/ensure, [repository].ensure, and resource create/destroy/ensure), had none of it: a SIGTERM mid-step — a dependency install, a database provision — killed the engine and orphaned the command's tree against a half-created worktree, still burning CPU, still holding ports and locks.

The audit that surfaced the orphan also surfaced why it could exist unnoticed. ADR 0054's guard matches literal "git"/"sh" spawns and deliberately excluded dynamically named binaries — a residual the docs pager already inhabited. Nothing recorded what each spawner owed on interrupt, so interrupt E2Es existed for exactly the two surfaces someone had once thought about, and a new spawner joined the codebase owing nothing.

Decision

§

One supervision core. superviseSpawn (engine/owned_child.ts) owns the interrupt lifecycle for a long-running child: scoped signal listeners for the child's lifetime, the same signal delivered to the child — its whole process group when it leads one — SIGKILL escalation after a grace period, a group sweep once the leader is reaped, and a re-raise so the engine dies with the conventional killed-by-signal status unless the caller resumes (the desk does). runOwnedChild and runShellRouted are its callers; the grace durations live once in process_signals.ts beside the other signal conventions. A lifecycle command's child is never interactive (stdin is null), so on POSIX it always leads its own group.

A spawn-surface registry. tests/spawn_surfaces.ts declares every file permitted to construct Deno.Command, with an exact constructor-site count and the interrupt contract the home owes: either the E2E surfaces that prove the invariant black-box, or a written exempt justification a reviewer can audit (a bounded git subcommand, a probe, the terminal-foreground pager). The subprocess guard now scans src/ for every constructor site — any binary, literal or variable, plus node:child_process — and fails on an unregistered home or a stale count; the git/sh funnel guards derive their sanctioned sets from the same registry.

A suite typed over the registry. The interrupt E2Es are one parameterized suite whose scenario table is a Record over the registry's declared surface union: declaring a surface without a scenario, or orphaning a scenario, fails deno check. Five surfaces are proven per watched signal — gate jobs, Project Scripts, worktree lifecycle commands, the with-gotchas wrapper, and the desk's launch wiring, whose contract inverts: the child tree dies while the desk session survives. Each scenario plants a leader plus a forked descendant (the script one also holds a server port) and proves the whole tree dies.

A fourth spawner therefore auto-enrols from both ends: it cannot exist unregistered, and it cannot register without either an interrupt E2E or a reviewed exemption. The registry is itself an entry in the canonical-sets meta-registry, claimed by the interruption-safety canon node.

Consequences

§
  • The orphan class is cured, not patched. The worktree runner got the machinery, and the guard that would have caught it now watches every spawn surface — including ones added by an author who never saw this bug.
  • Exemptions are prose a reviewer can audit, in one diff-reviewed place, not silence. runShell's entry states its condition explicitly: no engine caller today; supervision and a surface before an operator command routes through it.
  • A new spawn site anywhere costs a registry edit. Deliberate friction — the moment a spawner is born is the moment its interrupt story is decided.
  • The residual is named. The scan covers Deno.Command and node:child_process under src/; a child spawned through some other mechanism (FFI, a future runtime API) is invisible to it, as are tests/ (the suite driving the engine) and templates/ (which run under other projects' stacks). Windows has no process groups, so tree-reap assertions hold on POSIX; Windows keeps direct-child kill semantics.
choose openEsc close