fix(orchestrator): don't auto-restart on graceful exit; tighten role-status

Smoke-8 surfaced a tight respawn loop: QA failed a PR cleanly, container
exited 0, then _check_health bumped error_count and respawned QA with
the same task_id. But by then the task was in needs_revision (dev's
state), so QA's claim_review was rejected — and the cycle repeated on
the next health tick. Token-burning loop.

Two layers:

1. _check_health now reads docker's exit code. exit_code == 0 →
   graceful (intentional handoff via i_am_idle / clean shutdown) →
   reset error_count, do NOT auto-restart. Non-zero → keep the
   existing crash-retry behavior. Refactored into
   _inspect_container_state + _handle_stopped_container to keep
   xenon's complexity check happy.

2. _readiness_check_role_for_status now includes the dev-owned
   states (needs_revision, verifying) so a misrouted spawn for QA /
   PM / board on these statuses fails the readiness gate before the
   gateway has to reject it. Defense in depth — the right path is
   #1 (don't respawn on clean exit at all), but if some other code
   path tries to spawn QA on needs_revision the gate now catches it.

Tests: 12 new (5 for _check_health graceful/crash matrix + 7 for the
expanded role-status table). Pre-gateway names (none of which were
needed here) untouched.
This commit is contained in:
Renn F
2026-05-15 04:36:08 +02:00
parent 87b18bc64f
commit cfefe85f87
3 changed files with 329 additions and 53 deletions
+101 -53
View File
@@ -2067,12 +2067,24 @@ class AgentOrchestrator:
def _readiness_check_role_for_status(
agent_id: str, role: str, status: str
) -> str | None:
"""Verify agent role matches the role expected for the task status."""
"""Verify agent role matches the role expected for the task status.
Handoff states are role-specific. Dev-owned states (in_progress,
verifying, needs_revision, paused, blocked) are restricted to
developer/documenter to defang the smoke-8 bug where QA got
respawned on a `needs_revision` task via the crash-restart path
and immediately hit ``role 'qa' may not claim from status
'needs_revision'`` at the gateway.
"""
role_mismatch: dict[str, str | set[str]] = {
"awaiting_qa": "qa",
"awaiting_documentation": "documenter",
"awaiting_pm_review": {"cell_pm", "main_pm"},
"awaiting_ceo_approval": "ceo",
# Dev-owned states — only developer/documenter may claim or
# resume work here. PMs / QA spawning on these is a misroute.
"needs_revision": {"developer", "documenter"},
"verifying": {"developer", "documenter"},
}
required = role_mismatch.get(status)
if required is None:
@@ -2863,67 +2875,103 @@ Start by:
error=str(e),
)
@staticmethod
async def _inspect_container_state(
container_name: str,
) -> tuple[bool, int | None]:
"""Return (is_running, exit_code) from `docker inspect`.
exit_code is None when the output is missing or unparseable; the
caller treats None as a crash for safety (smoke-8 fix).
"""
proc = await asyncio.create_subprocess_exec(
"docker",
"inspect",
"-f",
"{{.State.Running}} {{.State.ExitCode}}",
container_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
parts = stdout.decode().strip().split()
is_running = bool(parts) and parts[0] == "true"
try:
exit_code = int(parts[1]) if len(parts) > 1 and parts[1] else None
except ValueError:
exit_code = None
return is_running, exit_code
async def _handle_stopped_container(
self, agent_id: str, instance: Any, exit_code: int | None
) -> None:
"""Update state + auto-restart only when the exit was non-zero.
Smoke-8 evidence: graceful exits (exit 0 — agent called i_am_idle)
were treated as crashes by the old logic. The health check bumped
error_count and respawned the agent with the prior task_id even if
the task had since moved into a state the role can't claim from
(e.g. QA → needs_revision). Now: clean exits reset error_count and
do nothing; non-zero exits keep the existing crash-retry behaviour.
"""
cid = instance.container_id[:12] if instance.container_id else None
graceful = exit_code == 0
if graceful:
logger.info(
"Agent container exited gracefully",
agent_id=agent_id,
container_id=cid,
exit_code=exit_code,
)
else:
logger.warning(
"Agent container stopped unexpectedly",
agent_id=agent_id,
container_id=cid,
exit_code=exit_code,
)
instance.state = AgentState.OFFLINE
instance.container_id = None
if graceful:
instance.error_count = 0
return
instance.error_count += 1
max_retries = 3
if instance.error_count < max_retries:
logger.info("Auto-restarting crashed agent", agent_id=agent_id)
await self.spawn_agent(
agent_id=agent_id,
task_id=instance.current_task_id,
git_context=(instance.config.git_context if instance.config else None),
)
elif instance.error_count == max_retries:
# Exactly at the threshold — escalate once to humans so a
# stranded agent doesn't die silently. Subsequent crashes
# stay quiet to avoid notification spam.
logger.error(
"Agent exceeded max restart attempts; escalating",
agent_id=agent_id,
error_count=instance.error_count,
task_id=instance.current_task_id,
)
await self._notify_agent_stranded(
agent_id=agent_id,
error_count=instance.error_count,
task_id=instance.current_task_id,
)
async def _check_health(self) -> None:
"""Check health of all running agents."""
for agent_id, instance in list(self._instances.items()):
if instance.state not in (AgentState.ACTIVE, AgentState.WAITING_SHORT):
continue
if instance.container_id is None:
continue
# Check if container is still running
container_name = f"roboco-agent-{agent_id}"
proc = await asyncio.create_subprocess_exec(
"docker",
"inspect",
"-f",
"{{.State.Running}}",
container_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
is_running, exit_code = await self._inspect_container_state(
f"roboco-agent-{agent_id}"
)
stdout, _ = await proc.communicate()
is_running = stdout.decode().strip() == "true"
if not is_running:
cid = instance.container_id[:12] if instance.container_id else None
logger.warning(
"Agent container stopped",
agent_id=agent_id,
container_id=cid,
)
instance.state = AgentState.OFFLINE
instance.error_count += 1
instance.container_id = None
# Auto-restart if not too many errors
max_retries = 3
if instance.error_count < max_retries:
logger.info("Auto-restarting agent", agent_id=agent_id)
await self.spawn_agent(
agent_id=agent_id,
task_id=instance.current_task_id,
git_context=(
instance.config.git_context if instance.config else None
),
)
elif instance.error_count == max_retries:
# Exactly at the threshold — escalate once to humans so a
# stranded agent doesn't die silently. Subsequent crashes
# stay quiet to avoid notification spam.
logger.error(
"Agent exceeded max restart attempts; escalating",
agent_id=agent_id,
error_count=instance.error_count,
task_id=instance.current_task_id,
)
await self._notify_agent_stranded(
agent_id=agent_id,
error_count=instance.error_count,
task_id=instance.current_task_id,
)
await self._handle_stopped_container(agent_id, instance, exit_code)
async def _notify_agent_stranded(
self,
@@ -0,0 +1,153 @@
"""Smoke-8: _check_health distinguishes graceful (exit 0) from crash exits.
Original bug: every container stop bumped error_count and triggered
spawn_agent(agent_id, task_id=instance.current_task_id). After QA failed a
PR and cleanly idled, the health check respawned QA on the (now
needs_revision) task the gateway rejected claim_review every time, and
QA respawned again on the next health tick. Token-burning tight loop.
Fix: read exit code via `docker inspect`. exit_code == 0 graceful;
reset error_count and DO NOT auto-restart. Non-zero crash; keep
existing retry behavior.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
def _make_orchestrator() -> AgentOrchestrator:
with patch.object(AgentOrchestrator, "__init__", return_value=None):
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._instances = {}
orch._lock = MagicMock()
return orch
def _instance(task_id: str | None) -> MagicMock:
inst = MagicMock()
inst.state = AgentState.ACTIVE
inst.container_id = "deadbeef1234"
inst.current_task_id = task_id
inst.error_count = 0
inst.config = MagicMock(git_context=None)
return inst
async def _docker_inspect_returning(*, running: bool, exit_code: int) -> bytes:
return f"{'true' if running else 'false'} {exit_code}\n".encode()
@pytest.mark.asyncio
async def test_graceful_exit_does_not_respawn() -> None:
"""Container exit_code=0 means clean shutdown. No auto-restart."""
orch = _make_orchestrator()
inst = _instance(task_id=str(uuid4()))
orch._instances["be-qa"] = inst
proc = MagicMock()
proc.communicate = AsyncMock(return_value=(b"false 0\n", b""))
spawn = AsyncMock()
orch.spawn_agent = spawn
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
await orch._check_health()
spawn.assert_not_awaited()
assert inst.state == AgentState.OFFLINE
assert inst.error_count == 0, (
"Graceful exit must reset error_count, not bump it. Otherwise a "
"long-running agent that idles clean every time eventually trips "
"max_retries and gets flagged as stranded."
)
@pytest.mark.asyncio
async def test_crash_exit_triggers_restart() -> None:
"""Container exit_code != 0 means crash. Auto-restart (existing behavior)."""
orch = _make_orchestrator()
task_id = str(uuid4())
inst = _instance(task_id=task_id)
orch._instances["be-dev-1"] = inst
proc = MagicMock()
proc.communicate = AsyncMock(return_value=(b"false 137\n", b""))
spawn = AsyncMock()
orch.spawn_agent = spawn
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
await orch._check_health()
spawn.assert_awaited_once()
args = spawn.await_args.kwargs
assert args["agent_id"] == "be-dev-1"
assert args["task_id"] == task_id
assert inst.error_count == 1
@pytest.mark.asyncio
async def test_still_running_no_action() -> None:
"""If the container is still running, no state change."""
orch = _make_orchestrator()
inst = _instance(task_id=str(uuid4()))
orch._instances["be-dev-1"] = inst
proc = MagicMock()
proc.communicate = AsyncMock(return_value=(b"true 0\n", b""))
spawn = AsyncMock()
orch.spawn_agent = spawn
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
await orch._check_health()
spawn.assert_not_awaited()
assert inst.state == AgentState.ACTIVE
assert inst.error_count == 0
assert inst.container_id == "deadbeef1234"
@pytest.mark.asyncio
async def test_crash_max_retries_does_not_restart() -> None:
"""Hit max_retries → don't restart (existing behavior preserved)."""
orch = _make_orchestrator()
inst = _instance(task_id=str(uuid4()))
starting_error_count = 3
inst.error_count = starting_error_count
orch._instances["be-dev-1"] = inst
proc = MagicMock()
proc.communicate = AsyncMock(return_value=(b"false 1\n", b""))
spawn = AsyncMock()
orch.spawn_agent = spawn
orch._notify_agent_stranded = AsyncMock()
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
await orch._check_health()
spawn.assert_not_awaited()
assert inst.error_count == starting_error_count + 1
@pytest.mark.asyncio
async def test_malformed_inspect_treated_as_crash() -> None:
"""If `docker inspect` returns malformed output, default to crash path."""
orch = _make_orchestrator()
inst = _instance(task_id=str(uuid4()))
orch._instances["be-dev-1"] = inst
proc = MagicMock()
# No exit code field at all.
proc.communicate = AsyncMock(return_value=(b"false\n", b""))
spawn = AsyncMock()
orch.spawn_agent = spawn
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
await orch._check_health()
# exit_code is None → not graceful → counts as crash.
spawn.assert_awaited_once()
assert inst.error_count == 1
@@ -0,0 +1,75 @@
"""Smoke-8: _readiness_check_role_for_status covers dev-owned states.
Original gap: the role-mismatch table only mapped handoff states
(awaiting_qa, awaiting_documentation, awaiting_pm_review,
awaiting_ceo_approval) to required roles. needs_revision/verifying had
no entry, so a QA spawn for a needs_revision task passed the readiness
check and the gateway rejected claim_review afterwards. Defense in depth
layered behind the _check_health fix.
"""
from __future__ import annotations
from roboco.runtime.orchestrator import AgentOrchestrator
def test_qa_on_needs_revision_blocked() -> None:
"""QA cannot be spawned for a needs_revision task."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-qa", role="qa", status="needs_revision"
)
assert reason is not None
assert "needs_revision" in reason
assert "qa" in reason
def test_pm_on_needs_revision_blocked() -> None:
"""PM cannot be spawned for a needs_revision task either."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-pm", role="cell_pm", status="needs_revision"
)
assert reason is not None
def test_developer_on_needs_revision_allowed() -> None:
"""Developer (and documenter) ARE the right roles for needs_revision."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-dev-1", role="developer", status="needs_revision"
)
assert reason is None
def test_documenter_on_needs_revision_allowed() -> None:
"""Documenter can rework — same dev/doc-owned set."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-doc", role="documenter", status="needs_revision"
)
assert reason is None
def test_qa_on_verifying_blocked() -> None:
"""Verifying belongs to the dev/doc roles, not QA."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-qa", role="qa", status="verifying"
)
assert reason is not None
def test_qa_on_awaiting_qa_allowed() -> None:
"""The original handoff case still works — QA on awaiting_qa is fine."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-qa", role="qa", status="awaiting_qa"
)
assert reason is None
def test_unmapped_status_allows_any_role() -> None:
"""Statuses with no role lock (pending, paused, blocked, etc.) pass."""
for status in ("pending", "claimed", "in_progress", "paused", "blocked"):
for role in ("developer", "qa", "documenter", "cell_pm"):
assert (
AgentOrchestrator._readiness_check_role_for_status(
agent_id="x", role=role, status=status
)
is None
), f"role={role} status={status} should not be rejected by this gate"