mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
417 lines
14 KiB
Python
417 lines
14 KiB
Python
"""Per-verb circuit breaker — agent_sdk denies after N retries in 60s.
|
|||
|
|
|
||
|
|
Phase 3 Task 14. The 2026-05-10 smoke run showed `i_am_done` retried 5+
|
||
|
|
times in 2 minutes (each rejection was a `tracing_gap`; the agent kept
|
||
|
|
calling). This module verifies the runtime tracker added to
|
||
|
|
`agent_sdk.server` enforces the per-verb cap from
|
||
|
|
`foundation.policy.agent_loop.retry_limit_for`.
|
||
|
|
|
||
|
|
The tracker is keyed on `(verb, task_id)` and operates over a 60s sliding
|
||
|
|
window. Tests poke time.monotonic via patch to drive window decay
|
||
|
|
deterministically — wallclock sleeps would slow the suite.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections import deque
|
||
|
|
from typing import TYPE_CHECKING
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import roboco.agent_sdk.server as srv
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from roboco.foundation.policy.agent_loop import retry_limit_for
|
||
|
|
from roboco.services.gateway.envelope import Envelope
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from collections.abc import Iterator
|
||
|
|
|
||
|
|
|
||
|
|
# Named constants — ruff PLR2004 forbids magic comparisons. Mirror the
|
||
|
|
# foundation cap for i_am_done so the tests remain readable.
|
||
|
|
_OK = 200
|
||
|
|
_I_AM_DONE_CAP = 3 # foundation.VERB_RETRY_LIMITS["i_am_done"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def _reset_state() -> Iterator[None]:
|
||
|
|
"""Wipe the SDK session state before every test.
|
||
|
|
|
||
|
|
Helpers share a module-level `_state` singleton, so leakage between
|
||
|
|
tests would silently corrupt counts.
|
||
|
|
"""
|
||
|
|
srv._state.reset()
|
||
|
|
yield
|
||
|
|
srv._state.reset()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Envelope.circuit_open
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_envelope_circuit_open_kind() -> None:
|
||
|
|
"""Envelope.circuit_open is a distinct error kind."""
|
||
|
|
env = Envelope.circuit_open(
|
||
|
|
verb="i_am_done",
|
||
|
|
attempts=4,
|
||
|
|
window_seconds=60,
|
||
|
|
remediate="call i_am_blocked",
|
||
|
|
)
|
||
|
|
body = env.as_dict()
|
||
|
|
assert body["error"] == "circuit_open"
|
||
|
|
assert body["remediate"] == "call i_am_blocked"
|
||
|
|
assert body["message"] is not None
|
||
|
|
assert "i_am_done" in body["message"]
|
||
|
|
assert "60" in body["message"]
|
||
|
|
assert "4" in body["message"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_envelope_circuit_open_carries_briefing() -> None:
|
||
|
|
"""Optional context_briefing is preserved when caller supplies it."""
|
||
|
|
env = Envelope.circuit_open(
|
||
|
|
verb="i_am_done",
|
||
|
|
attempts=4,
|
||
|
|
window_seconds=60,
|
||
|
|
remediate="x",
|
||
|
|
context_briefing={"hint": "blocked"},
|
||
|
|
)
|
||
|
|
body = env.as_dict()
|
||
|
|
assert body["context_briefing"] == {"hint": "blocked"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_envelope_circuit_open_default_briefing_empty() -> None:
|
||
|
|
"""Omitted context_briefing defaults to an empty dict (not None)."""
|
||
|
|
env = Envelope.circuit_open(
|
||
|
|
verb="i_am_done", attempts=3, window_seconds=60, remediate="x"
|
||
|
|
)
|
||
|
|
body = env.as_dict()
|
||
|
|
assert body["context_briefing"] == {}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tracker — record + count
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_record_attempt_increments_count_for_same_key() -> None:
|
||
|
|
"""Three records for the same (verb, task_id) yield count == 3."""
|
||
|
|
expected = 3
|
||
|
|
for _ in range(expected):
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == expected
|
||
|
|
|
||
|
|
|
||
|
|
def test_tracker_keys_per_verb_task_pair() -> None:
|
||
|
|
"""The tracker keys on (verb, task_id), not just verb.
|
||
|
|
|
||
|
|
Records for task-A are independent of records for task-B even when the
|
||
|
|
verb is identical. Without this guarantee, an agent juggling two tasks
|
||
|
|
would trip the breaker on the wrong one.
|
||
|
|
"""
|
||
|
|
a_count = 3
|
||
|
|
b_count = 1
|
||
|
|
for _ in range(a_count):
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
for _ in range(b_count):
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-B")
|
||
|
|
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == a_count
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-B") == b_count
|
||
|
|
|
||
|
|
|
||
|
|
def test_tracker_keys_per_verb_independent_of_task() -> None:
|
||
|
|
"""Different verbs on the same task are tracked independently."""
|
||
|
|
done_count = 2
|
||
|
|
submit_count = 1
|
||
|
|
for _ in range(done_count):
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
for _ in range(submit_count):
|
||
|
|
srv._record_verb_attempt("submit_up", "task-A")
|
||
|
|
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == done_count
|
||
|
|
assert srv._verb_attempt_count("submit_up", "task-A") == submit_count
|
||
|
|
|
||
|
|
|
||
|
|
def test_tracker_handles_none_task_id() -> None:
|
||
|
|
"""Verbs without a task_id collapse to (verb, None) — still tracked."""
|
||
|
|
expected = 2
|
||
|
|
for _ in range(expected):
|
||
|
|
srv._record_verb_attempt("triage", None)
|
||
|
|
assert srv._verb_attempt_count("triage", None) == expected
|
||
|
|
|
||
|
|
|
||
|
|
def test_count_is_zero_for_unknown_key() -> None:
|
||
|
|
"""Querying a never-recorded key returns 0 (no KeyError)."""
|
||
|
|
assert srv._verb_attempt_count("never_called", "task-X") == 0
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tracker — window decay
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_window_drops_entries_older_than_60s() -> None:
|
||
|
|
"""Attempts older than 60s are pruned from the window on next access.
|
||
|
|
|
||
|
|
Drives time.monotonic via patch so the test runs in <1ms instead of
|
||
|
|
waiting on wall-clock.
|
||
|
|
"""
|
||
|
|
base = 1000.0
|
||
|
|
initial = 3
|
||
|
|
after_jump = 1
|
||
|
|
with patch("roboco.agent_sdk.server.time.monotonic") as mock_time:
|
||
|
|
mock_time.return_value = base
|
||
|
|
for _ in range(initial):
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == initial
|
||
|
|
|
||
|
|
# Jump past the 60s window — old attempts must be dropped.
|
||
|
|
mock_time.return_value = base + 61.0
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
# The 3 old entries are pruned; only the new one remains.
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == after_jump
|
||
|
|
|
||
|
|
|
||
|
|
def test_window_keeps_entries_within_60s() -> None:
|
||
|
|
"""Attempts within the window survive — only ones past 60s drop."""
|
||
|
|
base = 1000.0
|
||
|
|
expected = 2
|
||
|
|
with patch("roboco.agent_sdk.server.time.monotonic") as mock_time:
|
||
|
|
mock_time.return_value = base
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
|
||
|
|
mock_time.return_value = base + 30.0
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
|
||
|
|
mock_time.return_value = base + 59.0
|
||
|
|
# Both entries within the 60s window starting at base.
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == expected
|
||
|
|
|
||
|
|
|
||
|
|
def test_count_prunes_on_read_without_recording() -> None:
|
||
|
|
"""_verb_attempt_count prunes the window even when not recording.
|
||
|
|
|
||
|
|
A read-only check should still see a fresh count after time advances —
|
||
|
|
callers (e.g. _check_verb_circuit) rely on this.
|
||
|
|
"""
|
||
|
|
base = 1000.0
|
||
|
|
with patch("roboco.agent_sdk.server.time.monotonic") as mock_time:
|
||
|
|
mock_time.return_value = base
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
|
||
|
|
mock_time.return_value = base + 61.0
|
||
|
|
# Count must drop without us recording anything new.
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == 0
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _check_verb_circuit — gating logic
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_check_returns_none_when_under_limit() -> None:
|
||
|
|
"""Below the cap, _check_verb_circuit returns None — call may proceed."""
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
# i_am_done cap is 3; we recorded 2.
|
||
|
|
assert srv._check_verb_circuit("i_am_done", "task-A") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_check_returns_envelope_at_or_above_limit() -> None:
|
||
|
|
"""At the cap, _check_verb_circuit returns a circuit_open envelope dict."""
|
||
|
|
limit = retry_limit_for("i_am_done")
|
||
|
|
assert limit is not None
|
||
|
|
for _ in range(limit):
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
|
||
|
|
result = srv._check_verb_circuit("i_am_done", "task-A")
|
||
|
|
assert result is not None
|
||
|
|
assert result["error"] == "circuit_open"
|
||
|
|
assert "i_am_done" in result["message"]
|
||
|
|
assert result["remediate"] is not None
|
||
|
|
assert "i_am_blocked" in result["remediate"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_check_returns_none_for_unlimited_retry_verbs() -> None:
|
||
|
|
"""give_me_work / triage / evidence never trip the breaker."""
|
||
|
|
assert retry_limit_for("give_me_work") is None
|
||
|
|
# Even after many recorded attempts, no envelope is returned.
|
||
|
|
for _ in range(20):
|
||
|
|
srv._record_verb_attempt("give_me_work", None)
|
||
|
|
assert srv._check_verb_circuit("give_me_work", None) is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_check_uses_default_cap_for_unknown_verb() -> None:
|
||
|
|
"""Unknown verbs fall back to BudgetPolicy.verb_retry_max_per_minute."""
|
||
|
|
limit = retry_limit_for("not_a_real_verb")
|
||
|
|
assert limit is not None # default cap applies
|
||
|
|
for _ in range(limit):
|
||
|
|
srv._record_verb_attempt("not_a_real_verb", "task-A")
|
||
|
|
assert srv._check_verb_circuit("not_a_real_verb", "task-A") is not None
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# /verb/attempted endpoint
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_verb_attempted_endpoint_records_rejection() -> None:
|
||
|
|
"""POST /verb/attempted with a counted rejection_kind increments the window."""
|
||
|
|
client = TestClient(srv.app)
|
||
|
|
resp = client.post(
|
||
|
|
"/verb/attempted",
|
||
|
|
json={
|
||
|
|
"verb": "i_am_done",
|
||
|
|
"task_id": "task-A",
|
||
|
|
"rejection_kind": "tracing_gap",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
body = resp.json()
|
||
|
|
assert body["verb"] == "i_am_done"
|
||
|
|
assert body["task_id"] == "task-A"
|
||
|
|
assert body["attempts"] == 1
|
||
|
|
assert body["limit"] == retry_limit_for("i_am_done")
|
||
|
|
assert body["open"] is False
|
||
|
|
assert body["circuit_envelope"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_verb_attempted_endpoint_ignores_uncounted_kinds() -> None:
|
||
|
|
"""A non-rejection (e.g. ok-related) kind doesn't move the counter."""
|
||
|
|
client = TestClient(srv.app)
|
||
|
|
resp = client.post(
|
||
|
|
"/verb/attempted",
|
||
|
|
json={
|
||
|
|
"verb": "i_am_done",
|
||
|
|
"task_id": "task-A",
|
||
|
|
"rejection_kind": "ok", # not in the counted set
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
body = resp.json()
|
||
|
|
assert body["attempts"] == 0
|
||
|
|
assert body["open"] is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_verb_attempted_endpoint_opens_circuit_at_threshold() -> None:
|
||
|
|
"""After `limit` rejections in 60s the endpoint reports open=True with envelope."""
|
||
|
|
client = TestClient(srv.app)
|
||
|
|
limit = retry_limit_for("i_am_done")
|
||
|
|
assert limit is not None
|
||
|
|
|
||
|
|
# Hit the cap.
|
||
|
|
last_body: dict[str, object] | None = None
|
||
|
|
for _ in range(limit):
|
||
|
|
resp = client.post(
|
||
|
|
"/verb/attempted",
|
||
|
|
json={
|
||
|
|
"verb": "i_am_done",
|
||
|
|
"task_id": "task-A",
|
||
|
|
"rejection_kind": "tracing_gap",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
last_body = resp.json()
|
||
|
|
|
||
|
|
assert last_body is not None
|
||
|
|
assert last_body["attempts"] == limit
|
||
|
|
assert last_body["open"] is True
|
||
|
|
env = last_body["circuit_envelope"]
|
||
|
|
assert isinstance(env, dict)
|
||
|
|
assert env["error"] == "circuit_open"
|
||
|
|
assert "i_am_done" in env["message"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_verb_attempted_endpoint_unlimited_verb_never_opens() -> None:
|
||
|
|
"""give_me_work bypasses the breaker even after many rejections."""
|
||
|
|
client = TestClient(srv.app)
|
||
|
|
for _ in range(10):
|
||
|
|
resp = client.post(
|
||
|
|
"/verb/attempted",
|
||
|
|
json={
|
||
|
|
"verb": "give_me_work",
|
||
|
|
"task_id": None,
|
||
|
|
"rejection_kind": "tracing_gap",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
|
||
|
|
body = resp.json()
|
||
|
|
assert body["limit"] is None
|
||
|
|
assert body["open"] is False
|
||
|
|
assert body["circuit_envelope"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_verb_circuit_status_endpoint_does_not_record() -> None:
|
||
|
|
"""GET /verb/circuit_status reads state without incrementing."""
|
||
|
|
client = TestClient(srv.app)
|
||
|
|
# Record one rejection via the POST endpoint.
|
||
|
|
client.post(
|
||
|
|
"/verb/attempted",
|
||
|
|
json={
|
||
|
|
"verb": "i_am_done",
|
||
|
|
"task_id": "task-A",
|
||
|
|
"rejection_kind": "tracing_gap",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
# Now poll status repeatedly — count must stay at 1.
|
||
|
|
for _ in range(5):
|
||
|
|
resp = client.get(
|
||
|
|
"/verb/circuit_status",
|
||
|
|
params={"verb": "i_am_done", "task_id": "task-A"},
|
||
|
|
)
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
assert resp.json()["attempts"] == 1
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Reset semantics
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_state_reset_clears_verb_attempts() -> None:
|
||
|
|
"""_state.reset() wipes the verb tracker (orchestrator calls this on spawn)."""
|
||
|
|
expected = 2
|
||
|
|
for _ in range(expected):
|
||
|
|
srv._record_verb_attempt("i_am_done", "task-A")
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == expected
|
||
|
|
|
||
|
|
srv._state.reset()
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_state_reset_via_endpoint_clears_verb_attempts() -> None:
|
||
|
|
"""POST /budget/reset (called by orchestrator on spawn) clears the tracker too."""
|
||
|
|
client = TestClient(srv.app)
|
||
|
|
client.post(
|
||
|
|
"/verb/attempted",
|
||
|
|
json={
|
||
|
|
"verb": "i_am_done",
|
||
|
|
"task_id": "task-A",
|
||
|
|
"rejection_kind": "tracing_gap",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == 1
|
||
|
|
|
||
|
|
resp = client.post("/budget/reset")
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
assert srv._verb_attempt_count("i_am_done", "task-A") == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_verb_attempts_default_is_empty_deque() -> None:
|
||
|
|
"""defaultdict yields an empty deque for unseen keys — sanity check."""
|
||
|
|
fresh = srv._SessionState()
|
||
|
|
assert isinstance(fresh.verb_attempts[("never_seen", None)], deque)
|
||
|
|
assert len(fresh.verb_attempts[("never_seen", None)]) == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_i_am_done_cap_matches_foundation() -> None:
|
||
|
|
"""The local _I_AM_DONE_CAP constant tracks foundation.
|
||
|
|
|
||
|
|
If foundation changes the cap, this test fails so the test constants
|
||
|
|
are updated alongside.
|
||
|
|
"""
|
||
|
|
assert retry_limit_for("i_am_done") == _I_AM_DONE_CAP
|