ADR-0004: Propagate Human Command Errors to the Thread
Status: Accepted — 2026-10-08
Decider: olivier-norture
Related: #804 (this change), #793 (soft-kill halt notice), #529 (enriched skip reason), #635 (command grammar), #679 (clean alias) — extended by ADR-0011 (#867), which resolves a command attempt before the cascade
Context
Section titled “Context”A human steers the workflow by posting /nebuko … commands. When the
invocation violates the grammar — an unknown flag (the issue’s own example,
/nebuko run workflow --default), a missing resource, a missing required flag
— parse_workflow_command deliberately returns None so the router falls
through the normal cascade. With no parse result, the resolver produces no
task, the guards phase sets SKIP_AGENT=true, and the run exits
ExitCode.TERMINAL (20).
That terminal skip was intentionally silent: the only skip that posted a notice was the exhausted soft-kill halt (ADR-0002 / issue #793). The consequence for a malformed command was that the only feedback lived in the CI log:
INFO:nebuko.engine.run_loop:[phase:guards] skipping agent — No valid task foundError: Process completed with exit code 20.The human saw a red check with no explanation of what they typed wrong. Two failure classes were affected:
- Malformed known command — the first line is a
/nebukoinvocation that breaks the grammar. - Unknown slash intent — the first line starts with
/but matches no command at all.
Decision
Section titled “Decision”-
Typed parse surface.
parse_command(body) -> CommandParseResultis the single source of truth for whether a comment’s first line is a well-formed/nebukocommand and, if not, why. It carries a machine-stabledetailtoken (e.g.unknown_flag,unknown_command,missing_required_flag) and a user-facingusagehint.parse_workflow_commandremains a thin compatibility wrapper (parse_command(body).command), so every existing consumer —classify_comment,build_trigger_commands, the resolver facade — keeps its signature and itsNone-on-malformed contract. -
Terminal skips caused by a human command speak up. The resolver transports the parse failure through the existing string-keyed channel (
config_command_error→REASON+COMMAND_ERROR=true, alongsideSKIP_AGENTand the #529config_skip_reason). In the guards phase, a command-caused terminal skip from a non-bot trigger posts a neutralized@authornotice via the generalizedEngine._post_terminal_notice()(which_post_soft_kill_haltnow delegates to). Routine resolver skips — auto-routing, mentions, scheduled triggers, bot comments — stay silent exactly as before. -
Exit-code contract unchanged. A malformed command still exits
TERMINAL = 20.
Alternatives considered and rejected
Section titled “Alternatives considered and rejected”- CI-level
if: failure()comment step innebuko-workflow.yaml(thecontinue-on-error-commentpattern). Rejected: the notice is generic (“the job failed”) with no knowledge of which command was wrong, it requires plumbing the event payload into yet another step, and it fires on genuine crashes where a raw dump is noise. - String-encoding the parse failure into
REASONat parse time. Rejected: it conflates “why routing skipped” with “why parsing failed”, gives no typed surface to test or extend, and is exactly the string/namespace anti-pattern the typedCommandParseResultavoids.
Consequences
Section titled “Consequences”- Zero LLM cost. The feedback path is pure parsing + one adapter
post(); no agent is invoked. This satisfies the issue’s explicit “if no LLM is involved it costs nothing” requirement. - Loop safety. The notice opens with
BOT_MARKERand is neutralized via_neutralize_agent_triggers, so it classifies as a bot comment and can never re-enter the posting path. - Accepted fragility. A non-command line that merely begins with
/now receives an “unrecognized command” reply. This is the issue’s acknowledged “fragile behaviour between a command and a trigger”, tolerable precisely because the cost is a single deterministic comment. - Extensibility. The closed
detailtoken set is the stable contract that future command feedback (extra actions, new flags) extends, rather than re-parsing free text.