fix(cli): one-shot -q runs report their outcome in the exit code

The Kanban dispatcher spawns workers as `hermes ... chat -q <prompt>`
(`kanban_db.py::_default_spawn`). That path ran the turn and fell through
to an implicit 0 whatever happened — success, failure, or a provider
quota wall.

`detect_crashed_workers` reads rc=0 with the task still `running` as a
protocol violation, and protocol violations trip the breaker at
`failure_limit=1`, so a single HTTP 429 blocked the card permanently and
every card queued behind it stayed in `todo` forever waiting on a parent
that could never reach `done`.

`KANBAN_RATE_LIMIT_EXIT_CODE` (EX_TEMPFAIL) exists precisely to prevent
this: `_classify_worker_exit` maps it to a `rate_limited` kind and the
task is released back to `ready` without counting a failure. The consumer
end was complete and tested. The producer end was wired into the `-Q`
path only — the one the dispatcher does not use.

This extracts that mapping into `_single_query_exit_code()` and applies it
on both one-shot paths. `chat()` returns the rendered response string, so
the non-quiet path could not see the outcome; `_chat_settle_turn` now
records the raw turn result for it to read.

Scope is deliberately narrow. The non-quiet path only exits non-zero when
`HERMES_KANBAN_TASK` is set, so interactive runs and ordinary `hermes chat
-q` invocations still exit 0 exactly as before. For a dispatcher-spawned
worker the full contract now applies: 0 on success, 1 on failure, and the
sentinel on a rate-limit/billing wall.

Tests cover the path that was missed rather than the one that already
worked: 16 of the 17 new assertions fail on the parent commit, and the
key regression fails as `assert None == 75` — the exact rc=0 fall-through
— rather than on a missing symbol. The seventeenth asserts that a human's
one-shot run keeps exiting 0, and passes both before and after.
This commit is contained in:
dmelkk-secondbrain
2026-09-14 16:25:17 +02:00
committed by Teknium
parent 873a7bbb6f
commit be9d4369a7
3 changed files with 194 additions and 12 deletions
+34 -12
View File
@@ -4089,6 +4089,31 @@ def _sync_cli_session_id_from_agent(cli) -> None:
cli.session_id = cli.agent.session_id
def _single_query_exit_code(result) -> int:
"""Map a one-shot turn result onto a process exit code.
0 success, 1 failure, and ``KANBAN_RATE_LIMIT_EXIT_CODE`` (EX_TEMPFAIL) when a
Kanban worker failed purely because the provider rate-limited or the account hit a
billing/quota wall. The dispatcher's reap classifier maps that sentinel to a
``rate_limited`` exit and releases the task back to ``ready`` WITHOUT counting a
failure, so a multi-day quota window cannot trip the circuit breaker and
permanently block the card.
Shared by both one-shot paths. It previously lived inline in the ``-Q`` path only,
which is how the ``-q`` path — the one the Kanban dispatcher actually spawns —
ended up with no exit contract at all.
"""
if not (isinstance(result, dict) and result.get("failed")):
return 0
if os.environ.get("HERMES_KANBAN_TASK") and result.get("failure_reason") in ("rate_limit", "billing"):
try:
from hermes_cli.kanban_db import KANBAN_RATE_LIMIT_EXIT_CODE
return KANBAN_RATE_LIMIT_EXIT_CODE
except Exception:
return 1
return 1
def _run_quiet_single_query(cli, effective_query, emitter=None):
"""Quiet (-Q) one-shot turn: run, print the response (stderr for errors/session_id), then sys.exit with the automation exit code.
With a ``StreamJsonEmitter`` the final answer and the exit line become the terminal ``result`` JSONL record instead.
@@ -4174,18 +4199,7 @@ def _run_quiet_single_query(cli, effective_query, emitter=None):
if emitter is None:
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
# Exit code 0/1 for automation wrappers. Kanban workers that failed purely on
# rate-limit/billing exit with the EX_TEMPFAIL sentinel so the dispatcher releases
# the task without counting a failure (a quota window must not trip the breaker).
_exit_code = 0
if isinstance(result, dict) and result.get("failed"):
_exit_code = 1
if os.environ.get("HERMES_KANBAN_TASK") and result.get("failure_reason") in ("rate_limit", "billing"):
try:
from hermes_cli.kanban_db import KANBAN_RATE_LIMIT_EXIT_CODE as _RL_CODE
_exit_code = _RL_CODE
except Exception:
_exit_code = 1
_exit_code = _single_query_exit_code(result)
if emitter is not None:
_exit_code = emitter.emit_result(result, session_id=cli.session_id or "", exit_code=_exit_code)
sys.exit(_exit_code)
@@ -4529,6 +4543,14 @@ def _run_single_query_mode(cli, query, image, quiet, oneshot, stream_json: bool
cli._show_security_advisories()
cli.chat(query, images=single_query_images or None)
cli._print_exit_summary(clear_screen=False)
# A dispatcher-spawned Kanban worker must report its outcome in its exit code.
# This path fell through to an implicit 0 for every outcome, and the reap
# classifier reads rc=0 with the task still `running` as a protocol violation,
# which blocks the card on the FIRST occurrence. A provider quota wall
# therefore killed the card permanently, along with everything queued behind
# it. Interactive and plain `-q` runs are unaffected: they still exit 0.
if os.environ.get("HERMES_KANBAN_TASK"):
sys.exit(_single_query_exit_code(getattr(cli, "_last_turn_result", None)))
finally:
_finalize_single_query(cli)