Files
roboco/tests/unit/runtime/test_stale_claim_reaper.py
T

567 lines
18 KiB
Python
Raw Normal View History

"""Reaper releases tasks whose last_heartbeat_at exceeds the TTL.
The orchestrator dispatcher periodically calls `_reap_stale_claims` to
release tasks whose holder has gone silent past the heartbeat TTL. The
schema-level column `last_heartbeat_at` (DateTime(timezone=True)) has
existed since migration 006; this test covers the runtime decision that
turns a stale heartbeat into a freed claim.
Datetimes used here are timezone-aware UTC because the underlying column
is tz-aware — comparing naive vs aware would raise TypeError in production
even though it would silently work against an in-memory mock.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
2026-06-29 05:38:21 +02:00
from roboco.models.runtime import AgentInstance, WaitingRecord
2026-06-05 16:35:22 +02:00
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
from roboco.seeds.initial_data import AGENT_UUIDS
@pytest.mark.asyncio
2026-06-23 07:26:41 +02:00
async def test_reap_stale_claims_releases_dead_holders(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A task past TTL is unclaimed; a fresh one is left alone."""
stale_id = uuid4()
fresh_id = uuid4()
now = datetime.now(UTC)
stale_task = type(
"T",
(),
{"id": stale_id, "last_heartbeat_at": now - timedelta(seconds=600)},
)()
fresh_task = type(
"T",
(),
{"id": fresh_id, "last_heartbeat_at": now - timedelta(seconds=10)},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator) # bypass __init__
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [stale_task, fresh_task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
svc.unclaim_for_reaper.assert_awaited_once_with(stale_id)
@pytest.mark.asyncio
2026-06-23 07:26:41 +02:00
async def test_reap_stale_claims_releases_holders_with_null_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A claimed task that never heartbeated (NULL column) is treated as stale."""
null_id = uuid4()
null_task = type("T", (), {"id": null_id, "last_heartbeat_at": None})()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [null_task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
svc.unclaim_for_reaper.assert_awaited_once_with(null_id)
@pytest.mark.asyncio
2026-06-23 07:26:41 +02:00
async def test_reap_stale_claims_swallows_unclaim_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unclaim_for_reaper failure must not abort the reap loop."""
stale_a = uuid4()
stale_b = uuid4()
now = datetime.now(UTC)
task_a = type(
"T", (), {"id": stale_a, "last_heartbeat_at": now - timedelta(seconds=600)}
)()
task_b = type(
"T", (), {"id": stale_b, "last_heartbeat_at": now - timedelta(seconds=900)}
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [task_a, task_b]
2026-05-03 17:51:33 +02:00
svc.unclaim_for_reaper = AsyncMock(side_effect=[RuntimeError("transient"), None])
await orch._reap_with_service(svc)
# Both stale tasks attempted; second succeeded despite first raising.
expected_attempts = 2
assert svc.unclaim_for_reaper.await_count == expected_attempts
2026-06-05 16:35:22 +02:00
@pytest.mark.asyncio
2026-06-23 07:26:41 +02:00
async def test_reap_spares_claims_whose_assignee_container_is_alive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
2026-06-05 16:35:22 +02:00
"""A stale-heartbeat task is NOT reaped while its assignee container lives.
A developer deep in a long edit/test cycle outruns the heartbeat TTL; the
running container is the ground truth, so the claim survives rather than
being churned out from under live work. A peer task whose assignee has no
live instance is still reaped.
"""
now = datetime.now(UTC)
live_id = uuid4()
dead_id = uuid4()
live_task = type(
"T",
(),
{
"id": live_id,
"last_heartbeat_at": now - timedelta(seconds=600),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
dead_task = type(
"T",
(),
{
"id": dead_id,
"last_heartbeat_at": now - timedelta(seconds=600),
"assigned_to": AGENT_UUIDS["be-dev-2"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
2026-06-05 16:35:22 +02:00
orch._claim_heartbeat_ttl = 300
orch._instances = {
"be-dev-1": AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE)
}
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [live_task, dead_task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
# The live-assignee task is spared; only the dead one is reaped.
svc.unclaim_for_reaper.assert_awaited_once_with(dead_id)
def _grok_instance() -> AgentInstance:
cfg = type("C", (), {"provider_type": "grok"})()
return AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE, config=cfg)
@pytest.mark.asyncio
async def test_reaper_kills_and_releases_wedged_grok_container(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A GROK container idle past the kill TTL is killed, evicted, and released.
Unlike a Claude agent, a wedged grok container is ACTIVE yet fires no
verb, so the live-instance skip would shield it forever. Past the longer
grok-idle TTL the watchdog removes the container and drops it from
`_instances`, so the same reap pass then unclaims the task.
"""
now = datetime.now(UTC)
task_id = uuid4()
wedged = type(
"T",
(),
{
"id": task_id,
"last_heartbeat_at": now - timedelta(seconds=1200),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
orch._grok_idle_kill_ttl = 900
orch._instances = {"be-dev-1": _grok_instance()}
remove_mock = AsyncMock()
monkeypatch.setattr(orch, "_remove_container", remove_mock)
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [wedged]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
remove_mock.assert_awaited_once_with(
"roboco-agent-be-dev-1", stop_reason="reaper_wedged_grok"
)
assert "be-dev-1" not in orch._instances # evicted
svc.unclaim_for_reaper.assert_awaited_once_with(task_id) # released
@pytest.mark.asyncio
async def test_reaper_spares_grok_container_within_kill_ttl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A GROK container stale past the claim TTL but within the kill TTL lives.
Only a truly-dead run (idle past the longer grok-idle TTL) is killed — a
slow-but-working agent is left alone.
"""
now = datetime.now(UTC)
recent = type(
"T",
(),
{
"id": uuid4(),
"last_heartbeat_at": now - timedelta(seconds=600),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
orch._grok_idle_kill_ttl = 900
orch._instances = {"be-dev-1": _grok_instance()}
remove_mock = AsyncMock()
monkeypatch.setattr(orch, "_remove_container", remove_mock)
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [recent]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
remove_mock.assert_not_awaited()
assert "be-dev-1" in orch._instances
svc.unclaim_for_reaper.assert_not_awaited()
@pytest.mark.asyncio
2026-06-30 08:08:35 +02:00
async def test_reaper_spares_claude_container_within_stuck_ttl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
2026-06-30 08:08:35 +02:00
"""A non-GROK (Claude) container silent 1200s is still spared — it is within
the much longer claude-stuck TTL, so a slow-but-working agent keeps the
heartbeat-skip protection. Only a truly-stuck run (silent past the stuck
TTL) is killed (see test_reaper_kills_stuck_claude_past_stuck_ttl)."""
now = datetime.now(UTC)
claude_task = type(
"T",
(),
{
"id": uuid4(),
"last_heartbeat_at": now - timedelta(seconds=1200),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
claude_cfg = type("C", (), {"provider_type": "anthropic"})()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
orch._grok_idle_kill_ttl = 900
2026-06-30 08:08:35 +02:00
orch._claude_stuck_kill_ttl = 3600
orch._instances = {
"be-dev-1": AgentInstance(
agent_id="be-dev-1", state=AgentState.ACTIVE, config=claude_cfg
)
}
remove_mock = AsyncMock()
monkeypatch.setattr(orch, "_remove_container", remove_mock)
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [claude_task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
remove_mock.assert_not_awaited()
assert "be-dev-1" in orch._instances
svc.unclaim_for_reaper.assert_not_awaited()
2026-06-30 08:08:35 +02:00
@pytest.mark.asyncio
async def test_reaper_kills_stuck_claude_past_stuck_ttl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""#73: a non-GROK container stuck in a non-verb loop (alive, no heartbeat
advance past the claude-stuck TTL) is killed, evicted, and released — not
shielded by the live-container skip forever."""
now = datetime.now(UTC)
stuck = type(
"T",
(),
{
"id": uuid4(),
"last_heartbeat_at": now - timedelta(seconds=4000),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
claude_cfg = type("C", (), {"provider_type": "anthropic"})()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
orch._grok_idle_kill_ttl = 900
orch._claude_stuck_kill_ttl = 3600
orch._instances = {
"be-dev-1": AgentInstance(
agent_id="be-dev-1", state=AgentState.ACTIVE, config=claude_cfg
)
}
remove_mock = AsyncMock()
monkeypatch.setattr(orch, "_remove_container", remove_mock)
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [stuck]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
remove_mock.assert_awaited_once_with(
"roboco-agent-be-dev-1", stop_reason="reaper_stuck_claude"
)
2026-06-30 08:08:35 +02:00
assert "be-dev-1" not in orch._instances # evicted
svc.unclaim_for_reaper.assert_awaited_once_with(stuck.id) # released
def test_stuck_claude_slug_excludes_grok_and_non_active() -> None:
"""#73: the stuck-slug predicate fires only for a non-GROK ACTIVE instance
past the TTL — a GROK provider (its own kill path), a non-ACTIVE instance, a
recent heartbeat, and no owner all yield None."""
now = datetime.now(UTC)
past = now - timedelta(seconds=4000)
task = type(
"T",
(),
{
"id": uuid4(),
"last_heartbeat_at": past,
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._claude_stuck_kill_ttl = 3600
claude_cfg = type("C", (), {"provider_type": "anthropic"})()
grok_cfg = type("C", (), {"provider_type": "grok"})()
# Non-GROK ACTIVE past TTL → slug returned.
orch._instances = {
"be-dev-1": AgentInstance(
agent_id="be-dev-1", state=AgentState.ACTIVE, config=claude_cfg
)
}
assert orch._stuck_claude_slug(task, past) == "be-dev-1"
# GROK provider → None (the wedged-grok path owns it).
orch._instances = {
"be-dev-1": AgentInstance(
agent_id="be-dev-1", state=AgentState.ACTIVE, config=grok_cfg
)
}
assert orch._stuck_claude_slug(task, past) is None
# Non-ACTIVE → None.
orch._instances = {
"be-dev-1": AgentInstance(
agent_id="be-dev-1", state=AgentState.OFFLINE, config=claude_cfg
)
}
assert orch._stuck_claude_slug(task, past) is None
# Recent heartbeat → None.
orch._instances = {
"be-dev-1": AgentInstance(
agent_id="be-dev-1", state=AgentState.ACTIVE, config=claude_cfg
)
}
assert orch._stuck_claude_slug(task, now) is None
@pytest.mark.asyncio
async def test_reap_spares_live_container_on_registry_miss(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Registry amnesia: the orchestrator forgot the instance (empty `_instances`,
e.g. after a restart) but the container is still running per Docker — the
task must NOT be reaped. This closes the be-dev-1 over-reap: a live agent the
orchestrator merely lost track of is no longer churned out from under work.
"""
now = datetime.now(UTC)
task_id = uuid4()
task = type(
"T",
(),
{
"id": task_id,
"last_heartbeat_at": now - timedelta(seconds=600),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
orch._grok_idle_kill_ttl = 900
orch._instances = {} # registry lost; container still up
monkeypatch.setattr(
orch, "_inspect_container_state", AsyncMock(return_value=(True, None))
)
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
svc.unclaim_for_reaper.assert_not_awaited() # spared — Docker says it's alive
@pytest.mark.asyncio
async def test_reap_releases_on_registry_miss_when_container_gone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Registry miss AND Docker says the container is gone → genuinely dead → reaped."""
now = datetime.now(UTC)
task_id = uuid4()
task = type(
"T",
(),
{
"id": task_id,
"last_heartbeat_at": now - timedelta(seconds=600),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
orch._grok_idle_kill_ttl = 900
orch._instances = {}
monkeypatch.setattr(
orch, "_inspect_container_state", AsyncMock(return_value=(False, 0))
)
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
svc.unclaim_for_reaper.assert_awaited_once_with(task_id)
2026-06-29 05:38:21 +02:00
@pytest.mark.asyncio
async def test_reap_spares_provider_parked_agent_for_probe_resume(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A provider-parked agent (dead container, OFFLINE, with a
``rate_limit_lifted`` WaitingRecord) must NOT be reaped by the stale-claim
reaper — reaping would release the claim to pending and probe-success would
respawn the agent on a task it no longer owns.
"""
now = datetime.now(UTC)
task_id = uuid4()
task = type(
"T",
(),
{
"id": task_id,
"last_heartbeat_at": now - timedelta(seconds=1200),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
orch._grok_idle_kill_ttl = 900
orch._instances = {} # parked agent is OFFLINE / not in the registry
orch._waiting_records = {
"be-dev-1": WaitingRecord(
agent_id="be-dev-1",
task_id=str(task_id),
waiting_for="rate_limit_lifted",
waiting_since=now,
context={"provider": "anthropic"},
)
}
monkeypatch.setattr(
orch, "_inspect_container_state", AsyncMock(return_value=(False, 0))
)
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
svc.unclaim_for_reaper.assert_not_awaited() # spared for the probe loop
@pytest.mark.asyncio
2026-06-23 07:26:41 +02:00
async def test_registry_uninitialised_skips_docker_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With `_instances` never initialised (None — the __new__ unit harness), the
Docker fallback is skipped and the stale task reaps as before; no accidental
Docker probing where there's no registry to be amnesiac about.
"""
now = datetime.now(UTC)
task_id = uuid4()
task = type(
"T",
(),
{
"id": task_id,
"last_heartbeat_at": now - timedelta(seconds=600),
"assigned_to": AGENT_UUIDS["be-dev-1"],
"claimed_by": None,
},
)()
orch = AgentOrchestrator.__new__(AgentOrchestrator)
2026-06-23 07:26:41 +02:00
monkeypatch.setattr(
orch, "_maybe_recover_broken_gateway", AsyncMock(return_value=False)
)
orch._claim_heartbeat_ttl = 300
# _instances intentionally NOT set -> getattr yields None -> no fallback.
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_with_service(svc)
svc.unclaim_for_reaper.assert_awaited_once_with(task_id)