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
78 lines
3.4 KiB
Python
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
|