2026-05-03 08:04:28 +02:00
|
|
|
"""PM respawn loop guard counts no-progress only, not rule-following retries.
|
|
|
|
|
|
|
|
|
|
Background
|
|
|
|
|
----------
|
|
|
|
|
``_pm_respawn_should_gate`` is the orchestrator's circuit breaker against
|
|
|
|
|
respawning the same PM on the same task forever. Before Task 13 it
|
|
|
|
|
counted any spawn whose task status didn't advance as a "strike", and
|
|
|
|
|
killed respawns after three of them.
|
|
|
|
|
|
|
|
|
|
That works for an agent that's hung — but with the gateway claim-time
|
|
|
|
|
gates installed in Phase 3, a rule-following PM that hits
|
|
|
|
|
``PARENT_NOT_CLAIMED`` (a ``tracing_gap`` envelope) and is told by the
|
|
|
|
|
prompt to call ``i_will_plan`` first will keep returning to the same
|
|
|
|
|
status. Each retry would trip a strike even though the agent did
|
|
|
|
|
exactly what the gateway told it to do.
|
|
|
|
|
|
|
|
|
|
The fix: when audit_log shows the agent emitted a ``gateway.rejected``
|
|
|
|
|
envelope with ``reason == "tracing_gap"`` since the last spawn, treat
|
|
|
|
|
that as forward progress (rule-following) and reset the strike count
|
|
|
|
|
instead of incrementing.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, datetime
|
2026-05-03 18:04:26 +02:00
|
|
|
from typing import Any, cast
|
2026-05-03 08:04:28 +02:00
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
|
|
|
|
from roboco.seeds.initial_data import AGENT_UUIDS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _new_orchestrator() -> AgentOrchestrator:
|
|
|
|
|
"""Bypass __init__ so tests don't need a full DI graph."""
|
|
|
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
2026-05-03 18:04:26 +02:00
|
|
|
cast("Any", orch)._pm_respawn_tracker = {}
|
2026-05-03 08:04:28 +02:00
|
|
|
return orch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_three_tracing_gap_responses_do_not_trip_kill() -> None:
|
|
|
|
|
"""A PM rule-followed three times must NOT be killed.
|
|
|
|
|
|
|
|
|
|
Each spawn the audit_log shows a fresh ``gateway.rejected`` row with
|
|
|
|
|
``reason == "tracing_gap"`` since the last check. That's the
|
|
|
|
|
rule-following retry pattern — counter must reset every time.
|
|
|
|
|
"""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
task = {"id": task_id, "status": "pending"}
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=True)
|
|
|
|
|
|
|
|
|
|
spawn_attempts = 5
|
|
|
|
|
with patch("roboco.services.audit.get_audit_service", return_value=fake_audit):
|
|
|
|
|
for _ in range(spawn_attempts):
|
|
|
|
|
should_gate = await orch._pm_respawn_should_gate("be-pm", task)
|
|
|
|
|
assert should_gate is False
|
|
|
|
|
|
|
|
|
|
# Audit was consulted on every call past the first record-creation.
|
|
|
|
|
expected_audit_calls = spawn_attempts - 1
|
|
|
|
|
assert fake_audit.has_recent_tracing_gap.await_count >= expected_audit_calls
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 23:18:58 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_unending_tracing_gap_is_bounded_and_eventually_trips() -> None:
|
|
|
|
|
"""A task whose EVERY respawn trips the same tracing_gap must still die.
|
|
|
|
|
|
|
|
|
|
The rule-following reset is bounded by ``pm_respawn_max_tracing_resets``.
|
|
|
|
|
Before this bound a permanently-wedged task (e.g. a cold-respawned PM that
|
|
|
|
|
can never satisfy the unblock journal-decision gate) emitted a tracing_gap
|
|
|
|
|
on every spawn, reset the strike counter every time, and respawned
|
|
|
|
|
forever — the production bleed. With the cap, resets are exhausted and
|
|
|
|
|
strikes accrue until the gate fires.
|
|
|
|
|
"""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
task = {"id": task_id, "status": "blocked"}
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=True)
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.services.audit.get_audit_service", return_value=fake_audit),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.notification.NotificationService",
|
|
|
|
|
return_value=AsyncMock(),
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
results = [
|
|
|
|
|
await orch._pm_respawn_should_gate("main-pm", task) for _ in range(12)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# The bug was that this list would be all-False forever. The gate must
|
|
|
|
|
# trip at least once now that the reset budget is finite.
|
|
|
|
|
assert any(results), "tracing_gap loop must eventually be gated"
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 08:04:28 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_three_no_progress_spawns_still_trip_kill() -> None:
|
|
|
|
|
"""When there is NO tracing_gap envelope, the strike logic still bites.
|
|
|
|
|
|
|
|
|
|
The classic stuck-loop case must still be detected: agent silently
|
|
|
|
|
hung, no envelopes emitted, status doesn't change. Strike count
|
|
|
|
|
increments each spawn and the kill fires after the threshold.
|
|
|
|
|
"""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
task = {"id": task_id, "status": "pending"}
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=False)
|
|
|
|
|
|
2026-06-08 04:42:15 +02:00
|
|
|
with (
|
|
|
|
|
patch("roboco.services.audit.get_audit_service", return_value=fake_audit),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.notification.NotificationService",
|
|
|
|
|
return_value=AsyncMock(),
|
|
|
|
|
),
|
|
|
|
|
):
|
2026-05-03 08:04:28 +02:00
|
|
|
# Spawns 1, 2, 3 — under threshold, all allowed.
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
assert await orch._pm_respawn_should_gate("be-pm", task) is False
|
|
|
|
|
# Spawn 4 — count now exceeds _PM_RESPAWN_MAX_UNPRODUCTIVE = 3.
|
|
|
|
|
assert await orch._pm_respawn_should_gate("be-pm", task) is True
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 04:42:15 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_stuck_loop_alerts_overseer_once() -> None:
|
|
|
|
|
"""When the loop guard bites, the CEO is alerted exactly once.
|
|
|
|
|
|
|
|
|
|
The guard pauses further spawns but can't advance the task. Without an
|
|
|
|
|
alert a wedged agent is silently stranded. The alert must fire on the
|
|
|
|
|
first kill and NOT repeat on every subsequent skipped spawn.
|
|
|
|
|
"""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
task = {"id": task_id, "status": "pending"}
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=False)
|
|
|
|
|
notifier = AsyncMock()
|
|
|
|
|
notifier.send_stuck_agent_notification = AsyncMock()
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.services.audit.get_audit_service", return_value=fake_audit),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.notification.NotificationService",
|
|
|
|
|
return_value=notifier,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
# Under threshold — no kill, no alert.
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
assert await orch._pm_respawn_should_gate("be-pm", task) is False
|
|
|
|
|
notifier.send_stuck_agent_notification.assert_not_awaited()
|
|
|
|
|
# Two over-threshold spawns — both killed, but only ONE alert.
|
|
|
|
|
assert await orch._pm_respawn_should_gate("be-pm", task) is True
|
|
|
|
|
assert await orch._pm_respawn_should_gate("be-pm", task) is True
|
|
|
|
|
|
|
|
|
|
notifier.send_stuck_agent_notification.assert_awaited_once()
|
|
|
|
|
kwargs = notifier.send_stuck_agent_notification.await_args.kwargs
|
|
|
|
|
assert kwargs["to_agent"] == "ceo"
|
|
|
|
|
assert kwargs["task_id"] == task_id
|
|
|
|
|
assert kwargs["agent_slug"] == "be-pm"
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 08:04:28 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_status_change_resets_strike_count() -> None:
|
|
|
|
|
"""Pre-existing reset path on real status change must keep working."""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
with patch("roboco.services.audit.get_audit_service", return_value=fake_audit):
|
|
|
|
|
# Two strikes on pending.
|
|
|
|
|
await orch._pm_respawn_should_gate(
|
|
|
|
|
"be-pm", {"id": task_id, "status": "pending"}
|
|
|
|
|
)
|
|
|
|
|
await orch._pm_respawn_should_gate(
|
|
|
|
|
"be-pm", {"id": task_id, "status": "pending"}
|
|
|
|
|
)
|
|
|
|
|
# Status advances — counter must drop back to 1.
|
|
|
|
|
await orch._pm_respawn_should_gate(
|
|
|
|
|
"be-pm", {"id": task_id, "status": "in_progress"}
|
|
|
|
|
)
|
|
|
|
|
record = orch._pm_respawn_tracker[("be-pm", task_id)]
|
|
|
|
|
assert record["count"] == 1
|
|
|
|
|
assert record["last_status"] == "in_progress"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_audit_query_uses_correct_agent_uuid_and_task_id() -> None:
|
|
|
|
|
"""The audit query must scope to (agent UUID, task UUID, since)."""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
task = {"id": task_id, "status": "pending"}
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=True)
|
|
|
|
|
|
|
|
|
|
with patch("roboco.services.audit.get_audit_service", return_value=fake_audit):
|
|
|
|
|
# First call seeds the record (no audit query yet).
|
|
|
|
|
await orch._pm_respawn_should_gate("be-pm", task)
|
|
|
|
|
# Second call should consult audit with be-pm's UUID + task UUID + since.
|
|
|
|
|
await orch._pm_respawn_should_gate("be-pm", task)
|
|
|
|
|
|
|
|
|
|
fake_audit.has_recent_tracing_gap.assert_awaited()
|
|
|
|
|
kwargs = fake_audit.has_recent_tracing_gap.call_args.kwargs
|
|
|
|
|
assert str(kwargs["agent_id"]) == AGENT_UUIDS["be-pm"]
|
|
|
|
|
assert str(kwargs["task_id"]) == task_id
|
|
|
|
|
assert isinstance(kwargs["since"], datetime)
|
|
|
|
|
assert kwargs["since"].tzinfo is UTC
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_unknown_slug_falls_back_to_status_only() -> None:
|
|
|
|
|
"""A slug not in AGENT_UUIDS must NOT crash; just skip the audit query.
|
|
|
|
|
|
|
|
|
|
Defensive: if the slug map drifts, the kill loop guard still works,
|
|
|
|
|
only it won't get the tracing_gap reset.
|
|
|
|
|
"""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
task = {"id": task_id, "status": "pending"}
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
with patch("roboco.services.audit.get_audit_service", return_value=fake_audit):
|
|
|
|
|
# Slug not present in AGENT_UUIDS — should not raise.
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
assert await orch._pm_respawn_should_gate("not-a-real-slug", task) is False
|
|
|
|
|
# Threshold trip path still reachable.
|
|
|
|
|
assert await orch._pm_respawn_should_gate("not-a-real-slug", task) is True
|
|
|
|
|
# Audit is never consulted because slug didn't resolve.
|
|
|
|
|
fake_audit.has_recent_tracing_gap.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_audit_query_failure_does_not_crash_gate() -> None:
|
|
|
|
|
"""If audit lookup raises, fall back to the legacy strike behavior.
|
|
|
|
|
|
|
|
|
|
Audit is observability — it must never block orchestrator decisions.
|
|
|
|
|
"""
|
|
|
|
|
orch = _new_orchestrator()
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
task = {"id": task_id, "status": "pending"}
|
|
|
|
|
|
|
|
|
|
fake_audit = AsyncMock()
|
|
|
|
|
fake_audit.has_recent_tracing_gap = AsyncMock(side_effect=RuntimeError("db down"))
|
|
|
|
|
|
|
|
|
|
with patch("roboco.services.audit.get_audit_service", return_value=fake_audit):
|
|
|
|
|
# Strikes 1-3: allowed. 4th: gated. Same as the no-tracing-gap case.
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
assert await orch._pm_respawn_should_gate("be-pm", task) is False
|
|
|
|
|
assert await orch._pm_respawn_should_gate("be-pm", task) is True
|