Files
hermes-agent/tests/hermes_cli/test_single_query_exit_contract.py
teknium1 c20432fffa fix(kanban): worker exits EX_CONFIG on a terminal provider error
A dispatcher-spawned worker whose turn failed on a provider error that no
retry can heal (credential rejected: auth / auth_permanent, model_not_found,
ssl_cert_verification) now exits KANBAN_TERMINAL_PROVIDER_EXIT_CODE (78,
BSD EX_CONFIG) instead of a plain 1, so the dispatcher can tell "the
provider will reject every further spawn" from "this attempt crashed".
The classification is the agent's own FailoverReason verdict already on
the turn result (failure_reason) — no error-text regex. billing stays in
the transient set: credit comes back, a revoked key does not.

Shared by both one-shot paths (-q and -Q) and gated on HERMES_KANBAN_TASK,
so a person's `hermes chat -q` keeps exiting 1 on the same error.

Part of #114587
2026-09-18 20:34:16 -07:00

78 lines
3.4 KiB
Python

"""One-shot ``chat -q`` runs report their outcome in the exit code, like ``-Q`` always did.
The non-quiet one-shot path used to fall through to an implicit rc=0 for every
outcome, so scripts could not tell a failed run from a good one and the Kanban
dispatcher (which spawns ``chat -q`` workers) booked a provider quota wall as a
protocol violation (#111770, #101800; salvage of #110917 / #97623).
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import cli
from hermes_cli.kanban_db import KANBAN_RATE_LIMIT_EXIT_CODE, KANBAN_TERMINAL_PROVIDER_EXIT_CODE
@pytest.fixture(autouse=True)
def _no_inherited_kanban_env(monkeypatch):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
def _run_non_quiet(monkeypatch, turn_result):
"""Drive ``_run_single_query_mode`` down the non-quiet tail; return the exit code (None = fell through)."""
monkeypatch.setattr(cli, "_should_seed_interactive", lambda *a, **k: False)
monkeypatch.setattr(cli, "_collect_query_images", lambda q, i: (q, []))
monkeypatch.setattr(cli, "_collect_kanban_task_images", lambda imgs: [])
monkeypatch.setattr(cli, "_finalize_single_query", lambda c: None)
stub = SimpleNamespace(
_single_query_mode=False,
_claim_active_session=lambda *a, **k: True,
console=SimpleNamespace(print=lambda *a, **k: None),
_show_security_advisories=lambda: None,
chat=lambda *a, **k: "response",
_print_exit_summary=lambda **k: None,
_last_turn_result=turn_result,
)
try:
cli._run_single_query_mode(stub, "do the thing", None, False, True)
except SystemExit as exc:
return exc.code
return None
@pytest.mark.parametrize(
"reason", ["rate_limit", "upstream_rate_limit", "billing", "overloaded", "server_error", "timeout"]
)
def test_dispatcher_spawned_worker_signals_a_provider_outage_not_a_protocol_violation(monkeypatch, reason):
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc123")
code = _run_non_quiet(monkeypatch, {"failed": True, "failure_reason": reason})
assert code == KANBAN_RATE_LIMIT_EXIT_CODE
@pytest.mark.parametrize("reason", ["auth", "auth_permanent", "model_not_found", "ssl_cert_verification"])
def test_dispatcher_spawned_worker_signals_a_terminal_provider_error(monkeypatch, reason):
"""A revoked credential / missing model cannot be retried into working: the worker says so
with EX_CONFIG so the dispatcher parks the card after one spawn. A person's run keeps 1."""
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc123")
assert _run_non_quiet(monkeypatch, {"failed": True, "failure_reason": reason}) == KANBAN_TERMINAL_PROVIDER_EXIT_CODE
monkeypatch.delenv("HERMES_KANBAN_TASK")
assert _run_non_quiet(monkeypatch, {"failed": True, "failure_reason": reason}) == 1
@pytest.mark.parametrize(
("turn_result", "expected"),
[
({"final_response": "done", "completed": True}, 0),
({"failed": True, "failure_reason": "rate_limit"}, 1), # a person's run: a wall is just a failure
({"final_response": "half", "completed": False, "partial": True}, 1),
({"completed": False, "interrupted": True}, 130),
(None, 1), # credentials / agent init failed before any turn ran
],
)
def test_a_plain_one_shot_run_reports_its_outcome(monkeypatch, turn_result, expected):
assert _run_non_quiet(monkeypatch, turn_result) == expected