fix(orchestrator): consolidate stale-heartbeat config + drop dead _task_svc slot

I1: claim_heartbeat_ttl_seconds (300s) overlapped semantically with the
pre-existing claim_stale_seconds (180s). Between 180-300s of silence,
trigger_filter queued duplicate spawns while the reaper hadn't yet
released the claim — exactly the dispatcher churn the reaper was
supposed to close. Collapse to one field (claim_stale_seconds, 180s);
reaper now consumes the same setting trigger_filter uses, so both
agree on 'stale' on the same tick and the reaper runs first.

I2: _task_svc injection slot on AgentOrchestrator.__init__ was
production-dead (always None) and only used by __new__-based test
instances. Drop the __init__ slot + the production branch in
_reap_stale_claims that read it. Tests still pre-bind on __new__
instances; the attribute exists per-instance, not per-class.
This commit is contained in:
Renn F
2026-05-03 04:57:25 +02:00
parent b301020398
commit c12aad3005
3 changed files with 37 additions and 38 deletions
+15 -15
View File
@@ -40,13 +40,13 @@ async def test_reap_stale_claims_releases_dead_holders() -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator) # bypass __init__
orch._claim_heartbeat_ttl = 300
orch._task_svc = AsyncMock()
orch._task_svc.list_in_progress_or_claimed.return_value = [stale_task, fresh_task]
orch._task_svc.unclaim_for_reaper = AsyncMock()
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [stale_task, fresh_task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_stale_claims()
await orch._reap_with_service(svc)
orch._task_svc.unclaim_for_reaper.assert_awaited_once_with(stale_id)
svc.unclaim_for_reaper.assert_awaited_once_with(stale_id)
@pytest.mark.asyncio
@@ -57,13 +57,13 @@ async def test_reap_stale_claims_releases_holders_with_null_heartbeat() -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._claim_heartbeat_ttl = 300
orch._task_svc = AsyncMock()
orch._task_svc.list_in_progress_or_claimed.return_value = [null_task]
orch._task_svc.unclaim_for_reaper = AsyncMock()
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [null_task]
svc.unclaim_for_reaper = AsyncMock()
await orch._reap_stale_claims()
await orch._reap_with_service(svc)
orch._task_svc.unclaim_for_reaper.assert_awaited_once_with(null_id)
svc.unclaim_for_reaper.assert_awaited_once_with(null_id)
@pytest.mark.asyncio
@@ -81,14 +81,14 @@ async def test_reap_stale_claims_swallows_unclaim_errors() -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._claim_heartbeat_ttl = 300
orch._task_svc = AsyncMock()
orch._task_svc.list_in_progress_or_claimed.return_value = [task_a, task_b]
orch._task_svc.unclaim_for_reaper = AsyncMock(
svc = AsyncMock()
svc.list_in_progress_or_claimed.return_value = [task_a, task_b]
svc.unclaim_for_reaper = AsyncMock(
side_effect=[RuntimeError("transient"), None]
)
await orch._reap_stale_claims()
await orch._reap_with_service(svc)
# Both stale tasks attempted; second succeeded despite first raising.
expected_attempts = 2
assert orch._task_svc.unclaim_for_reaper.await_count == expected_attempts
assert svc.unclaim_for_reaper.await_count == expected_attempts