diff --git a/strix/core/execution.py b/strix/core/execution.py index 8966ebf4..eccc3419 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -780,17 +780,21 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 await coordinator.set_status(agent_id, "failed", error=str(exc)) await notify_parent_on_terminal(coordinator, agent_id, "failed") return None - if not interactive: - raise if isinstance(exc, MaxTurnsExceeded): status: Status = "stopped" elif isinstance(exc, UserError | AgentsException | APIError): status = "failed" else: status = "crashed" - logger.exception("agent run failed for %s; parking as %s", agent_id, status) + logger.exception("agent run failed for %s; marking %s", agent_id, status) + # Settle the status and wake the parent before the exception unwinds a + # non-interactive agent's task: a child that dies still owes its parent a + # report, and the parent would otherwise wait out its timeout on a message + # the dead child can no longer send. await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__) await notify_parent_on_terminal(coordinator, agent_id, status) + if not interactive: + raise return None else: return cast("RunResultBase | None", stream) diff --git a/tests/test_execution.py b/tests/test_execution.py index da6f4169..6364f801 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -855,6 +855,39 @@ async def test_structured_provider_refusal_fails_noninteractive_child( session.close() +@pytest.mark.asyncio +async def test_crashing_noninteractive_child_settles_and_wakes_its_parent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The exception ends the child's task, so its status and the parent's wake-up + # have to be settled on the way out or the parent waits on a dead child. + def _boom(*_args: Any, **_kwargs: Any) -> Any: + raise RuntimeError("sandbox died mid-turn") + + monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _boom) + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + await coordinator.register("child", "recon", parent_id="root") + + with pytest.raises(RuntimeError, match="sandbox died mid-turn"): + await execution._run_cycle( + MagicMock(), + coordinator, + "child", + input_data="task", + run_config=MagicMock(), + context={"parent_id": "root"}, + max_turns=5, + session=None, + interactive=False, + event_sink=None, + hooks=None, + ) + + assert coordinator.statuses["child"] == "crashed" + assert coordinator.pending_counts.get("root", 0) > 0 + + @pytest.mark.asyncio async def test_run_agent_loop_seeds_identity_before_first_cycle( tmp_path: Any, monkeypatch: pytest.MonkeyPatch