ADR-0015: Human-Actionable Stops Do Not Fail CI
Status: Accepted — 2026-10-21 Decider: olivier-norture Related: #1057 (this change), #1003 (armed gate hold), #1012 (gate no-op), #804 (command error notice), #793 (soft-kill halt) — supersedes ADR-0004 clause 3 (“exit-code contract unchanged”)
Context
Section titled “Context”The engine reported a run that stopped waiting for a human the same way it
reported a crash: a non-zero process exit. main() returned
int(outcome.exit_code), so ExitCode.HOLD (10, armed root gate) and
ExitCode.TERMINAL (20, mistyped command / failed resolution) both failed the
GitHub Actions job:
INFO:nebuko.engine.run_loop:[phase:guards] gate held — human_gate:context held — use /nebuko approve gate or /nebuko reject gateError: Process completed with exit code 10.Two problems compounded. The red job sends the maintainer an error
notification for a situation that only needs a command; and the reason lived
only in the Actions log, with no link back to the discussion. Some terminal
skips (force-step: workflow 'x' not found, an invalid force-step name)
posted nothing at all.
The comments in models.py and run_loop.py claimed exit 10/20 “keeps the host
run green” — that was false for any host that maps a non-zero process status to
a failure, which is every CI host.
Decision
Section titled “Decision”- Outcome codes stay; the process status is derived.
ExitCodekeepsADVANCE = 0,ERROR = 1,HOLD = 10,TERMINAL = 20— machine callers and parity tests keep reading the same run-outcome codes.RunOutcome.process_exit_code()returns1only forExitCode.ERRORand0otherwise, andmain()returns it. CI is red only when the engine itself broke, not when it deliberately stopped. - A hold announces itself. The
HUMAN_GATE_HOLDbranch of the guards phase posts<bot marker>\n@<trigger-author> <reason>throughEngine._post_gate_hold_notice; the reason already names the next command (use /nebuko approve gate or /nebuko reject gate). Bot-authored triggers stay silent so engine notices never re-enter the chain. - No human-caused stop is silent. In the terminal-skip branch a
non-benign, classified skip (a
CONFIG_ERROR: missing workflow, invalidforce-step, malformed config) and aCOMMAND_ERRORalso post_post_terminal_notice(reason). Routine resolver skips (no_next_agent,terminal, an unclassified “nothing to do”) stay silent as before. - Notices remain best-effort (
log.warningon a failed post) and pass through_neutralize_agent_triggers.
Alternatives considered
Section titled “Alternatives considered”- Normalize the code in
.github/workflows/nebuko-workflow.yaml(map 10/20 → 0 in the shell, orcontinue-on-error). Rejected for the same reason ADR-0004 rejected a CI-level step: the policy would live in CI YAML, would have to be duplicated per host, andcontinue-on-erroralso masks genuine crashes. - Collapse
ExitCodeto 0/1. Rejected: it breaks theHOLD == 10/TERMINAL == 20identity assertions and loses the held-vs-failed signal for machine callers.
Consequences
Section titled “Consequences”- A genuine config error (malformed workflow YAML, schema violation) posts its
reason and exits green. Only an uncaught failure (
ExitCode.ERROR, a traceback) stays red. The thread, not the check colour, is the feedback channel for human mistakes. - The engine’s own workflow must not rely on the process status to detect a hold; it was not doing so, but any future host integration must read the guard verdict rather than the exit code.
- Machine callers keep reading
RunOutcome.exit_code; the process code is a host-facing derivation on top of it.