ADR-0050: Per-attempt deadlines kill the process group and fail measured
Status: accepted | Date: 2026-09-23
References: RFC-0002:C-PLAYBOOK-BUDGET
Context
RFC-0002:C-PLAYBOOK-BUDGET requires every task to run under a bounded deadline and requires the run's wall-clock ceiling to terminate attempts in flight. The executor only checked the ceiling between dispatches, so one hung agent turn or command held a run for as long as it hung. Three things had to be decided that the clause does not settle: where the operator's default deadline and maximum come from, how a runner ends an attempt, and what a timed-out attempt settles as.
A command task is sh -c <command>, and an agent turn under the command backend is too. Killing the shell alone leaves whatever it started running and holding the output pipes the runner is draining, so the runner stays blocked for as long as the grandchild lives. The local agent harness has the same shape: the CLI starts tool and MCP subprocesses of its own. The openshell turn has no local child at all; it is a gRPC exec whose unwinding already runs through a cancellation token.
Decision
The run's wall-clock ceiling is both the default deadline and the maximum. A task declares timeout to be bounded more tightly; an undeclared task is bounded by the ceiling alone, and a declared timeout longer than the ceiling is refused before dispatch. The executor computes each attempt's deadline as the task's timeout counted from that attempt's start, or the ceiling's instant if that is earlier, and hands it to the runner with the attempt. A batch member carries its own deadline.
A runner that spawns a process under a deadline spawns it as the leader of a new process group and kills the whole group with SIGKILL when the deadline passes. A watchdog thread does this while the caller drains the pipes; it is stopped before the leader is reaped, so a kill can never reach a recycled group id. The openshell turn trips its existing cancellation token at the deadline instead.
A killed attempt returns its own outcome, TimedOut(deadline), which settles as a measured failure whose note names the limit that ended it and is never retried. When that limit was the run's ceiling, the run halts on the ceiling rather than short-circuiting on the task. Without a deadline nothing changes: the child shares the engine's process group as before.
Consequences
A hung task costs at most its own limit, and a run past its ceiling ends at the ceiling rather than whenever its last attempt returns. The note says which limit fired, so an operator can tell a slow task from a run that was given too little time.
Children under a deadline leave the terminal's foreground process group, so a ctrl-c no longer reaches them through the terminal. plan run therefore installs a SIGINT handler that terminates every live group before exiting, and the groups are tracked in a process-wide list, which is the one piece of global state here: a signal handler has no other way to reach them. A group member that calls setsid escapes the kill, as it would escape any process-group based supervision.
SIGKILL gives an agent CLI no chance to flush. A timed-out turn loses whatever it had not written, which is acceptable for an attempt that is being failed anyway.
Operators who want a per-task default tighter than the run's ceiling have no knob for it; a pack states it per task. Adding one later is additive.
Alternatives Considered
The run's ceiling is the default and maximum; kill the process group; settle a timed-out attempt as a measured failure (accepted)
- Pros: No new operator knob: the one ceiling every playbook launch already requires bounds every task, A group kill ends a shell and everything it started, so the runner is never left draining a pipe a grandchild holds, A measured failure is never retried, so a slow task does not cost its limit three times
- Cons: Children leave the terminal's process group, so ctrl-c needs an explicit handler and a list of live groups, A task that calls setsid escapes the kill
Separate operator flags for a default per-task deadline and a maximum (rejected)
- Pros: Lets an operator bound undeclared tasks more tightly than the run
- Cons: Two more required launch parameters on every surface that launches a playbook: the CLI, the rendered pod, and the controller, A maximum above the run's ceiling is meaningless and one below it is rarely wanted
- Rejected because: The run's ceiling already has to be set and already bounds every task; a second pair of knobs adds launch surface without adding a bound anyone has asked for.
Settle a timed-out attempt as a transport failure (rejected)
- Pros: Retries would absorb a task that was slow only once
- Cons: Each retry spends the whole limit again, tripling the cost of a task that is simply too slow, Transport is for infrastructure that failed; a task that ran out of time is the work's own outcome
- Rejected because: A timeout is evidence about the task, not the transport. Retrying it repeats the time without learning anything.
Kill only the direct child (rejected)
- Pros: Children stay in the engine's process group, so ctrl-c reaches them without a handler
- Cons:
sh -ccommands and agent CLIs leave grandchildren holding the output pipes, and the runner blocks until they exit - Rejected because: It does not end the attempt, which is the whole point.