Repository navigation
Conversation
…et route The exit-code contract (rc=1 on a failed turn, EX_TEMPFAIL for a kanban rate-limit/billing wall) existed only in the -Q branch. The kanban dispatcher spawns workers as `hermes -p <profile> --cli chat -q ...` (no -Q unless goal_mode), so a worker whose turn died on a provider failure exited 0 while still 'running' and the dispatcher booked it as a protocol violation (clean exit without kanban_complete/kanban_block) — hiding provider crashes and quota walls behind a task-failure label and burning the violation retry budget on retries that rerun the whole task. - extract `_single_query_exit_code(result)`; both one-shot routes use it - chat() retains the turn result as `cli._last_turn_result` - the non-quiet route exits with it (init/credential failure => 1) Tests: `tests/hermes_cli/test_single_query_exit_code.py` pins the shared decision and the non-quiet route's exit code (red on base: the route produced no exit code at all). Two existing tests (`test_human_single_query_main_finalizes_after_query`, `test_single_query_main_skips_clear_on_exit_summary`) pinned the route as *returning*; their stubs now retain a turn result and the tests keep their original intent (session finalized / exit summary not cleared) while asserting the route exits 0 on success. Verified end-to-end on this host: base exits 0 / patched exits 1 for the same failed turn; success still 0; -Q unchanged (1).
Duplicate of #101238 for triage tracking: same defect (non-quiet |
|
Landed on main at 1b2a75f via #112500, which salvaged #110917 (@dmelkk-secondbrain) — the same fix as this PR: Credit where due: the earliest PR fixing this exact fall-through was #97623 by @rodrigogs (Aug 29), and this cluster grew to ten PRs before one landed — my sweep should have picked up the earliest, and I have noted the precedence on the merged PR. Closing this as implemented on main; thank you for the fix. The unconditional non-zero for a human's |
What does this PR do?
hermes chat -q— the non-quiet one-shot route — exits 0 on a failed turn. Thekanban dispatcher spawns every non-
goal_modeworker on exactly that route(
hermes -p <profile> --cli --accept-hooks chat -q "work kanban task <id>";-Qisappended only when
task.goal_mode), so a worker whose turn died mid-flight on a providerfailure was booked as "worker exited cleanly (rc=0) without calling
kanban_complete/kanban_block — protocol violation": the real cause is hidden, the failure
is charged to the worker's protocol-violation budget, and the retry re-runs the whole task
from zero.
The exit-code contract already existed — but only in the
-Qbranch (rc=1on a failedturn,
EX_TEMPFAIL(75) for a kanban rate-limit/billing wall). This PR extracts thatexisting decision into one helper and has both one-shot routes use it.
Related Issue
No issue is closed by this PR. Same defect class, open and not authored by me: #91177, #101800.
Duplicate audit (repo PR checklist item)
The template asks me to search for existing PRs, so here is the honest result: this defect
has a long open queue, and I am not claiming to be first. Open PRs on the same route:
#101238, #107944, #104351, #98969, #100771, #107493, #97623, #102318, #86345, #91206
(plus the dispatcher-side classifiers #107432, #109468). Several carry the
duplicatelabel.Why this one may still be the cheapest to land — and I am happy to close it in favour of
whichever you prefer, or to fold its evidence into your pick:
it reuses the decision that is already in
cli.pyand introduces no new module, no newenv var, no new config key. fix(cli): non-interactive
chat -qreports failures in its exit code #101238 addshermes_cli/single_query_exit.py(+82 lines);several others add a second copy of the decision.
routes, so a provider transport death, a non-retryable 4xx and a quota wall all stop
exiting 0 — not only the
rate_limit/billingbranch.mainwith the reproduction re-captured on it (ee4452991d), and itis the only one in the queue carrying a fleet-level impact measurement (below).
Type of Change
Changes Made
cli.py_single_query_exit_code(result)from the-Qbranch; the quiet route now calls it(behaviour unchanged), so there is one decision instead of two.
chat()+_print_exit_summary(), exit with_single_query_exit_code(cli._last_turn_result);1when no turn result exists at all(credentials / agent-init failure), matching the quiet route.
hermes_cli/cli_chat_turn_mixin.py—chat()retains the turn result asself._last_turn_result(class defaultNone), so the one-shot caller can read it.tests/hermes_cli/test_single_query_exit_code.py(new) — invariant tests for the shareddecision and for the non-quiet route's exit code.
does; both keep their original intent and now assert the exit code too:
tests/hermes_cli/test_single_query_session_finalize.py::test_human_single_query_main_finalizes_after_query— session still finalized, now asserts
SystemExit.code == 0on success.tests/hermes_cli/test_chat_q_exit_clear.py::test_single_query_main_skips_clear_on_exit_summary— exit summary still not cleared, now asserts
SystemExit.code == 0on success.Their stubs also retain a turn result, because production
chat()does.Line-level cause (current
main,ee4452991d)cli.py:4442-4443— the non-quiet branch callscli.chat(...)thencli._print_exit_summary(clear_screen=False)and falls out of thetry/finallyintomain(): no exit code is computed at all, so the process exits 0 whatever the turnresult was.
cli.py:4114-4126— the same contract implemented only in_run_quiet_single_query.hermes_cli/kanban_db_dispatch.py:2127-2131—-Qis appended only whentask.goal_mode,so every normal worker takes the broken route.
How to Test
All of the following is fresh output from this branch, produced with the repo's own runner
(
scripts/run_tests.sh) against the shared venv,TZ=UTC, tempHERMES_HOME.1. Red on base, green patched
tests/hermes_cli/test_single_query_exit_code.pyonmain@ee4452991d(fix not applied):The non-quiet route produced no exit code at all — the failure is not "wrong code", it is
"no code". Same file on this branch:
2. End-to-end A/B on the real CLI route
Same command shape, same host, same provider error (
-m definitely-not-a-real-model-xyz→ non-retryable 404 inside a single-query turn), base tree vs patched tree, exit code captured
from the process:
ee4452991d--cli --accept-hooks chat -q "…"06fc56b21e--cli --accept-hooks chat -q "…"ee4452991d--cli --accept-hooks chat -q "…"06fc56b21e--cli --accept-hooks chat -q "…"-Qee4452991d… chat -q "…" -Q-Q06fc56b21e… chat -q "…" -QThe quota-wall arm (75 /
EX_TEMPFAIL,HERMES_KANBAN_TASKset) is pinned by unit testrather than this probe: the probe's failure mode is a 404, not a 429/billing wall.
3. No regressions
scripts/run_tests.sh tests/hermes_cli/on this branch (1088 files):That directory has pre-existing failures on this host (environment: no managed
uv, networkand PTY-dependent files), so the honest check is the same failing file set run on both trees
— base
ee4452991dvs this branch, identical inputs, identical counts:So the branch introduces zero additional failures; every remaining failure in that
directory is pre-existing on
mainin this environment.Checklist
Code
fix(cli): …)scripts/run_tests.sh tests/hermes_cli/, 1088 files) — no new failures vs base, see §3Documentation & Housekeeping
cli-config.yaml.example, tool schema orarchitecture change: the exit code is already the documented contract on the
-QrouteScreenshots / Logs
[red-on-base][green-patched][regression]Why this matters beyond one board (measured, not estimated)
Across the reporting board's whole history there are 42 runs recorded as clean-exit
protocol violations (2026-07-27 → 2026-09-13; harvey 16, sloane 12, craig 5, vance 4, mira 4,
reid 1). Matching each run's own
[start, end+3min]window against that profile's retainedagent.log*finds a terminal provider failure inside the run's own window for 13 of the 42— e.g.
API call failed after 3 retries — Upstream error from Together: Stream error: h2 protocol errorand Gemini 503/429, plus one credit wall and one broken pipe.13/42 is a lower bound, not a rate: profile log retention reaches back only to roughly
Aug 10 – Sep 8 while the run set starts in July, so the July/August runs are unmatchable by
construction. The mechanism is structural either way — on this route every terminal turn
failure exits 0 and lands in the protocol-violation bucket.
After this fix the same event is booked as
pid <n> exited with code 1(outcomecrashed,with the real error text), and a quota wall exits 75, which the dispatcher already releases
without counting a failure.