feat(turnover): skip registry-known-exhausted subs, and leave a durable handoff when a turn is cut - #813
Conversation
|
Review round 1 addressed in 430e7b0.
Verification:
No live tree deployed or synced. |
|
🔁 re-enqueue for FleetReview (Apollo): head's terminal record was stamped ERROR by a router restart (release cut 20260921T104203Z / sibling cuts 10:25-10:32Z), not by a review verdict; close/reopen re-submits the unchanged head. No code change. |
|
Review round 2 rework pushed at
RED before implementation: next-turn context was empty after command dispatch. GREEN after implementation: focused feature test 10/10; broader turnover/turn-context/command suite 174/174. Ruff, Windows footgun scan, and |
|
Round-3 rework pushed at The handoff now reads the runtime-shaped TDD evidence: the four new cases first failed at Verification:
No live tree deployed or synced. |
|
Round-4 rework @ Finding 4 (blocking) fixed — the gate was inert on 8 of 9 gateways. Same chain pruned identically under both homes now: 21 entries -> 16 skipped / 5 eligible under the root home AND under Three regressions added, all RED first: profile-shaped home resolves to the root snapshot; the non-profile layout resolves byte-identically to today; one chain prunes identically under both homes. Fail-open on a genuinely missing file is pinned separately and unchanged. Class-sweep honored: Non-blocking item closed rather than deferred. Verification: |
…ing it A mid-turn 5h/7d cap sent the walker through the whole fallback chain (claude-apx-1..15), paying a request round-trip and one "Rate limited — switching to fallback provider..." status line per entry — ~60s and ten lines to discover what the usage registry already had on disk. agent/quota_registry_gate.py reads the published usage snapshot (var/usage-portal/site/usage.json) and prunes, in ONE pass before any client is constructed, every entry whose 5h or 7d window is measurably rejected with a reset beyond a 5-minute horizon. The loop's quota-failover site emits one "skipping N exhausted subs" line instead of N. Conservative by construction — missing file, corrupt JSON, unknown provider, stale observation, a rejection with no reset time, and scoped per-model (Fable) allowances all fail OPEN to the historical walk. Verified: 34 tests in tests/agent/test_quota_registry_gate.py (25, truth table + loader) and tests/agent/test_quota_gate_fallback_wiring.py (9, live try_activate_fallback seam) pass under scripts/run_tests.sh.
…quota gate
(B) A turn cut by an unrecoverable provider failure now leaves a
machine-readable handoff instead of a bare "Operation interrupted".
agent/turn_handoff.py persists the in-flight state at the cut —
$HERMES_HOME/state/turn-handoff/<session_key>.json holding the last user
message id + text, the assistant's in-progress text, every tool call
issued this turn with its result truncated to 500 chars (and an explicit
flag on the call that never completed), and the open todo items. The
conversation loop's terminal provider-failure return writes it and
appends a 3-line notice; the turn prologue consumes it into the next
turn via the existing plugin/gateway user-context channel. Handoffs are
consume-once and expire after 24h; corrupt and expired files are deleted
rather than retried forever. Every entry point swallows its own failures
— a dying turn must not die twice.
/resume-handoff is registered in the command registry (alias
resume_handoff) and dispatched in gateway/run.py, so the notice names a
command the dispatcher actually knows. It is listed in
_SLACK_VIA_HERMES_ONLY: Slack's 50-slash cap would otherwise have
evicted /usage, which tests/hermes_cli/test_commands.py caught.
(A, follow-up) _coerce_epoch parsed only numeric reset values. The
published registry uses ISO-8601 Z strings, so against the LIVE payload
the gate pruned NOTHING — a vacuous pass that the unit tests (which used
epoch floats) could not see. Found by measuring, not by testing.
MEASURED against the live registry snapshot, 2026-09-21 ~02:10 PT:
before: 21 providers attempted, 21 "switching to fallback provider..."
after: 4 providers attempted, 1 line ("skipping 17 exhausted subs")
live config.yaml chain (5 entries): 2 pruned, 3 survive
Per-sub verdicts spot-checked against the raw usage.json windows.
Verified: 151 tests green under scripts/run_tests.sh —
test_quota_registry_gate (27), test_quota_gate_fallback_wiring (9),
test_turn_handoff (28), test_turn_handoff_wiring (9),
test_resume_handoff_command (10), plus the neighbour suites
test_commands, test_turn_context, test_turn_context_overflow_warning,
test_conversation_loop_interpreter_shutdown, test_lazy_command_exports,
test_fallback_announce, test_quota_window_cooldown,
test_fallback_reason_threading.
Keep the configured fallback chain immutable, reset registry verdicts at each turn boundary, dedupe quota status messages across the full cascade, and append the soonest reset to terminal failures. Add explicit UTF-8 I/O for handoff state. Verified: 246 tests across 14 focused files; Windows footgun scan and Ruff clean. Regression tests proved RED when each behavior fix was removed.
Verified command dispatch previews the saved handoff, then the next model-turn seam consumes it exactly once. Focused turnover suite: 88 passed.
Capture the current visible stream, deduplicate materialized overlap, and persist user-only early cuts. Verified 125 focused turnover tests via scripts/run_tests.sh; Ruff and Windows footgun checks pass.
…expired handoffs The usage system publishes ONE snapshot, at the top-level Hermes root. The quota gate resolved it off HERMES_HOME, which is <root>/profiles/<name> for 8 of the 9 live gateways, so the gate loaded 0 accounts and degraded to the historical walk on every specialist. Measured before: root home 21 accounts, every profile home 0. After: all homes resolve the same file, and the aegis chain prunes identically (21 entries -> 16 skipped / 5 eligible) under both. Resolution now goes through hermes_constants.get_default_hermes_root(), which already implements the <root>/profiles/<p> -> <root> rule including the Docker /opt/data layout. Fail-open on a genuinely missing file is unchanged. The sibling join in turn_handoff.py (state/turn-handoff) is per-profile state and is deliberately left alone. Also closes the carried-over handoff leak: prune_expired_handoffs() had no production caller, so the 24h TTL only ran for a session that came back. The write path now sweeps, guarded so a failing sweep can never cost a handoff. Verified: 7 files / 177 tests green under scripts/run_tests.sh with a sandboxed HOME; ruff clean; windows-footgun scan clean.
addf4b6 to
bd89db0
Compare
|
🤖 merged-by: apollo · lane: boil-ocean-aged · gate: BYPASS: FR PAUSED by Ace ruling 2026-09-22 (state/fleetreview-pause-20260922.md); Apollo-reviewed lands via bypass · why: 0/6 commits on main by patch-id; rebased clean onto 7ed45d9 (was 206 behind), diff limited to PR's own 14 files; 97 passed own tests; gateway/cli command-adjacent suites show the same 11 local-env failures on main and PR (no new) |
FleetReviewFleetReview's daily member-call budget is spent (600/600 for 2026-09-26 UTC); review skipped. FleetReview · reviewKind: skipped-budget |
…s (t_9c9e757c) FleetReview retro-backfill 2026-09-27, class C4. Each finding re-verified at 5a9d284; one RED-on-base / GREEN test per confirmed instance. - #813 agent/quota_registry_gate.py: missing/unparseable observed_at now fails open (docstring contract), no longer skips a working fallback. - #1017 cron/lifecycle_guard.py: mask allowlist refuses module callables passed as values (json.dumps(p, default=subprocess.run), sorted(key=...)). - #1019 agent/lsp/manager.py: slot-dir OSError in _has_capacity -> run without LSP instead of raising out of enabled_for()/write_file. - #1026 cron/scheduler.py: owner-deadman exemption scoped to the host it owns; a note naming only another DOWN host is gated. - #1229 scripts/ci/live_comment.py: comment lookup/update network errors no longer kill the poller; a failed post is retried next poll. - #1230 plugins_cmd.py: pinned-plugin remedy shell-quotes the recorded source (CLI + dashboard). - #1238 tools/mixture_of_agents_tool.py: reference responses move from the aggregator system message to a tagged data block in the user message. Verified: test-gate narrow pytest; new tests 13 RED on base, all GREEN with fix; neighbouring suites (lifecycle_guard x4, host_down_gate, quota wiring, plugins_cmd, fallback_policy, fork toolsets) pass.
…fill Class fix for backfill section C5 (race-atomicity), 20 confirmed instances: - kanban_db: home-session ownership re-checked inside the mutator's first write txn (TOCTOU, #951); backfill_unhomed stamp+comment in one txn (#987) - turn_handoff: consume claims via atomic rename; prune/expired drop only deletes unchanged content (#813 prune race, lost handoff) - lcm lifecycle_state: prune DELETE conditioned on the judged session ids (#966) - checkout_admission: reject negative per-entry work counts (#1035) - provider_seam: materialize publish inputs before the retryable build; models: SeamCollision no longer latches discovery (#1072) - gateway/session: turn-marker publish gated on revision (#1043 x2), clear override keeps a newer concurrent set + its pin, off-lock prune detects an in-place heal; run.py stuck-loop suspend under the store lock; Telegram HWM tracker serialized across worker threads (#1043) Verified: 13 new tests red on base, green on head; neighbouring gateway and kanban suites green (1 pre-existing env-dependent failure, same on base). (cherry picked from commit 0ccb18e)
…s (t_9c9e757c) FleetReview retro-backfill 2026-09-27, class C4. Each finding re-verified at 5a9d284; one RED-on-base / GREEN test per confirmed instance. - #813 agent/quota_registry_gate.py: missing/unparseable observed_at now fails open (docstring contract), no longer skips a working fallback. - #1017 cron/lifecycle_guard.py: superseded by #1348 (6662bbd) on main; this slice's 5 callback cases pass against it, so the edit was dropped on rebase. - #1019 agent/lsp/manager.py: slot-dir OSError in _has_capacity -> run without LSP instead of raising out of enabled_for()/write_file. - #1026 cron/scheduler.py: owner-deadman exemption scoped to the host it owns; a note naming only another DOWN host is gated. - #1229 scripts/ci/live_comment.py: comment lookup/update network errors no longer kill the poller; a failed post is retried next poll. - #1230 plugins_cmd.py: pinned-plugin remedy shell-quotes the recorded source (CLI + dashboard). - #1238 tools/mixture_of_agents_tool.py: reference responses move from the aggregator system message to a tagged data block in the user message. Verified: test-gate narrow pytest; new tests 13 RED on base, all GREEN with fix; neighbouring suites (lifecycle_guard x4, host_down_gate, quota wiring, plugins_cmd, fallback_policy, fork toolsets) pass.
…fill Class fix for backfill section C5 (race-atomicity), 20 confirmed instances: - kanban_db: home-session ownership re-checked inside the mutator's first write txn (TOCTOU, #951); backfill_unhomed stamp+comment in one txn (#987) - turn_handoff: consume claims via atomic rename; prune/expired drop only deletes unchanged content (#813 prune race, lost handoff) - lcm lifecycle_state: prune DELETE conditioned on the judged session ids (#966) - checkout_admission: reject negative per-entry work counts (#1035) - provider_seam: materialize publish inputs before the retryable build; models: SeamCollision no longer latches discovery (#1072) - gateway/session: turn-marker publish gated on revision (#1043 x2), clear override keeps a newer concurrent set + its pin, off-lock prune detects an in-place heal; run.py stuck-loop suspend under the store lock; Telegram HWM tracker serialized across worker threads (#1043) Verified: 13 new tests red on base, green on head; neighbouring gateway and kanban suites green (1 pre-existing env-dependent failure, same on base). (cherry picked from commit 0ccb18e)
) * fix(security): Backfill C3 secret/PII exposure class (t_36b0277a) - tools/tool_wait_watchdog: redact tool args (force + URL creds) before the 240-char cut; long-wait log lines carried raw commands/tokens (#1012). - tools/openrouter_client: one client per OpenRouter credential, not per process; a multiplexed gateway sent later profiles' MoA prompts under the first profile's key (#1238). mixture_of_agents_tool uses the scope-aware key check. - plugins/kanban-home-cards: redact URL credentials in card text and redact the board slug, both injected into model context (#968). - agent/fallback_events: persisted err_head redacts URL credentials (#1211). - gateway/run.py: STT INFO line logs size+latency, not the user's words (#1064). - agent/turn_handoff: tool args/results redacted before /resume-handoff (#813). Verified: each new test fails on base and passes on head; test_turn_handoff (34), test_voice_inbound_accounting + test_gateway_foreground_deafness (39), test_kanban_home_cards + test_fallback_events_ledger (101), test_secret_scope_tier1_migration (23 passed, 2 skipped). * test: build fake URL credentials by concatenation (gitleaks generic-api-key) (t_36b0277a) --------- Co-authored-by: ang-fleet-workers[bot] <333956806+ang-fleet-workers[bot]@users.noreply.github.com>
…s (t_9c9e757c) (#1353) FleetReview retro-backfill 2026-09-27, class C4. Each finding re-verified at 5a9d284; one RED-on-base / GREEN test per confirmed instance. - #813 agent/quota_registry_gate.py: missing/unparseable observed_at now fails open (docstring contract), no longer skips a working fallback. - #1017 cron/lifecycle_guard.py: superseded by #1348 (6662bbd) on main; this slice's 5 callback cases pass against it, so the edit was dropped on rebase. - #1019 agent/lsp/manager.py: slot-dir OSError in _has_capacity -> run without LSP instead of raising out of enabled_for()/write_file. - #1026 cron/scheduler.py: owner-deadman exemption scoped to the host it owns; a note naming only another DOWN host is gated. - #1229 scripts/ci/live_comment.py: comment lookup/update network errors no longer kill the poller; a failed post is retried next poll. - #1230 plugins_cmd.py: pinned-plugin remedy shell-quotes the recorded source (CLI + dashboard). - #1238 tools/mixture_of_agents_tool.py: reference responses move from the aggregator system message to a tagged data block in the user message. Verified: test-gate narrow pytest; new tests 13 RED on base, all GREEN with fix; neighbouring suites (lifecycle_guard x4, host_down_gate, quota wiring, plugins_cmd, fallback_policy, fork toolsets) pass. Co-authored-by: ang-fleet-workers[bot] <333956806+ang-fleet-workers[bot]@users.noreply.github.com>
…fill Class fix for backfill section C5 (race-atomicity), 20 confirmed instances: - kanban_db: home-session ownership re-checked inside the mutator's first write txn (TOCTOU, #951); backfill_unhomed stamp+comment in one txn (#987) - turn_handoff: consume claims via atomic rename; prune/expired drop only deletes unchanged content (#813 prune race, lost handoff) - lcm lifecycle_state: prune DELETE conditioned on the judged session ids (#966) - checkout_admission: reject negative per-entry work counts (#1035) - provider_seam: materialize publish inputs before the retryable build; models: SeamCollision no longer latches discovery (#1072) - gateway/session: turn-marker publish gated on revision (#1043 x2), clear override keeps a newer concurrent set + its pin, off-lock prune detects an in-place heal; run.py stuck-loop suspend under the store lock; Telegram HWM tracker serialized across worker threads (#1043) Verified: 13 new tests red on base, green on head; neighbouring gateway and kanban suites green (1 pre-existing env-dependent failure, same on base). (cherry picked from commit 0ccb18e)
…fill Class fix for backfill section C5 (race-atomicity), 20 confirmed instances: - kanban_db: home-session ownership re-checked inside the mutator's first write txn (TOCTOU, #951); backfill_unhomed stamp+comment in one txn (#987) - turn_handoff: consume claims via atomic rename; prune/expired drop only deletes unchanged content (#813 prune race, lost handoff) - lcm lifecycle_state: prune DELETE conditioned on the judged session ids (#966) - checkout_admission: reject negative per-entry work counts (#1035) - provider_seam: materialize publish inputs before the retryable build; models: SeamCollision no longer latches discovery (#1072) - gateway/session: turn-marker publish gated on revision (#1043 x2), clear override keeps a newer concurrent set + its pin, off-lock prune detects an in-place heal; run.py stuck-loop suspend under the store lock; Telegram HWM tracker serialized across worker threads (#1043) Verified: 13 new tests red on base, green on head; neighbouring gateway and kanban suites green (1 pre-existing env-dependent failure, same on base). (cherry picked from commit 0ccb18e)
…fill Class fix for backfill section C5 (race-atomicity), 20 confirmed instances: - kanban_db: home-session ownership re-checked inside the mutator's first write txn (TOCTOU, #951); backfill_unhomed stamp+comment in one txn (#987) - turn_handoff: consume claims via atomic rename; prune/expired drop only deletes unchanged content (#813 prune race, lost handoff) - lcm lifecycle_state: prune DELETE conditioned on the judged session ids (#966) - checkout_admission: reject negative per-entry work counts (#1035) - provider_seam: materialize publish inputs before the retryable build; models: SeamCollision no longer latches discovery (#1072) - gateway/session: turn-marker publish gated on revision (#1043 x2), clear override keeps a newer concurrent set + its pin, off-lock prune detects an in-place heal; run.py stuck-loop suspend under the store lock; Telegram HWM tracker serialized across worker threads (#1043) Verified: 13 new tests red on base, green on head; neighbouring gateway and kanban suites green (1 pre-existing env-dependent failure, same on base). (cherry picked from commit 0ccb18e)
Why
The 2026-09-21 #apollo pattern, three times in one night: the primary sub hits its 5h/7d cap mid-turn, the gateway walks the fallback chain (
claude-bpx-17→apx-1→ … →apx-15, most also exhausted), spends ~60 s emitting "Rate limited — switching to fallback provider..." ×10, then dies. The next session reconstructs state from Discord scrollback — 15-25 min per turnover — and the work in flight at the cut is silently lost.Two independent defects, one PR each half.
(A) The walker tries subs the usage registry already knows are dead
agent/quota_registry_gate.pyreads the published usage snapshot (var/usage-portal/site/usage.json) and prunes, in one pass before any client is constructed, every chain entry whose 5h or 7d window is measurablyrejectedwith a reset beyond a 5-minute horizon. The loop's quota-failover site emits oneskipping N exhausted subsline instead of N. When the whole tail is pruned it fails fast naming the soonest reset.Conservative by construction — missing file, corrupt JSON, unknown provider, stale observation (>6 h), a rejection with no reset time, and scoped per-model (Fable) allowances all fail OPEN to the historical walk. Only a quota reason (
rate_limit/billing/upstream_rate_limit) triggers the gate; a transport timeout says nothing about quota and walks as before.Measured against the live registry, 2026-09-21 ~02:10 PT
Per-sub verdicts spot-checked against the raw
usage.jsonwindows (e.g.apx-10 five_hour 102.0 rejected 2.1h→ SKIP;apx-14 five_hour 90.0 allowed_warning→ ELIGIBLE).That measurement found a real bug the unit tests could not see.
_coerce_epochparsed only numeric reset values; the registry publishes ISO-8601 Z strings, so against the live payload the first implementation pruned nothing — a vacuous pass, because the tests used epoch floats. Fixed, with ISO tests added.(B) No durable handoff at the cut
agent/turn_handoff.pypersists the in-flight turn state to$HERMES_HOME/state/turn-handoff/<session_key>.json: last user message id + text, the assistant's in-progress text, every tool call issued this turn with its result truncated to 500 chars and an explicit flag on the one that never completed (the most valuable single fact), and the open todo items./resume-handoffis registered in the command registry (aliasresume_handoff) and dispatched ingateway/run.py, so the notice names a command the dispatcher actually knows.Notes for the reviewer
/resume-handoffis listed in_SLACK_VIA_HERMES_ONLY. Slack's 50-slash cap would otherwise have evicted/usage— caught bytests/hermes_cli/test_commands.py::TestSlackNativeSlashes::test_telegram_parity, and confirmed as mine by a clean-base control run (green on base, red with my registry entry).Verification
scripts/run_tests.sh, sandboxedHOME, 216 passed / 0 failed, rc=0:New:
test_quota_registry_gate(27),test_quota_gate_fallback_wiring(9),test_turn_handoff(28),test_turn_handoff_wiring(9),test_resume_handoff_command(10). Neighbours re-run:test_commands,test_turn_context,test_turn_context_overflow_warning,test_conversation_loop_interpreter_shutdown,test_lazy_command_exports,test_fallback_announce,test_quota_window_cooldown,test_fallback_reason_threading.Every new module was RED first:
ModuleNotFoundError/ImportErrorbefore implementation, and the ISO-8601 pair failed for the right reason before the parser fix.