fix(core): wake the parent when a child ends without a completion report

This commit is contained in:
Ahmed Allam
2026-08-02 15:43:43 +03:00
committed by Ahmed Allam
parent 797b37467e
commit 002712284a
4 changed files with 121 additions and 15 deletions
+17
View File
@@ -55,6 +55,7 @@ class AgentCoordinator:
self.idle_resume_counts: dict[str, int] = {}
self.wait_kinds: dict[str, WaitKind] = {}
self.runtimes: dict[str, AgentRuntime] = {}
self._parent_notified: set[str] = set()
self._lock = asyncio.Lock()
self._snapshot_path: Path | None = None
self.is_shutting_down = False
@@ -191,6 +192,7 @@ class AgentCoordinator:
self.errors.pop(agent_id, None)
self.wait_kinds.pop(agent_id, None)
self.runtimes.setdefault(agent_id, AgentRuntime()).user_wake_required = False
self._parent_notified.discard(agent_id)
await self._maybe_snapshot()
async def park_waiting(self, agent_id: str, *, wait_kind: WaitKind) -> None:
@@ -252,12 +254,27 @@ class AgentCoordinator:
self.errors[agent_id] = error
elif status == "running":
self.errors.pop(agent_id, None)
if status == "running":
# Running again means a fresh stint that owes its parent its own notice.
self._parent_notified.discard(agent_id)
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
runtime.user_wake_required = status in {"failed", "crashed"}
runtime.wake.set()
logger.info("agent.status %s=%s", agent_id, status)
await self._maybe_snapshot()
async def claim_parent_notice(self, agent_id: str) -> bool:
"""Reserve the one notice a child owes its parent when it stops running.
A completion report and a terminal notice carry the same information, so
whichever comes first claims the slot and the other is skipped.
"""
async with self._lock:
if agent_id in self._parent_notified:
return False
self._parent_notified.add(agent_id)
return True
async def send(
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
) -> bool:
+30 -5
View File
@@ -539,7 +539,7 @@ async def _exhausted_recovery(
"""
if not interactive:
await coordinator.set_status(agent_id, "crashed")
await _notify_parent_on_terminal(coordinator, agent_id, "crashed")
await notify_parent_on_terminal(coordinator, agent_id, "crashed")
raise MaxTurnsExceeded(
"Agent exhausted recovery attempts without calling finish_scan or agent_finish."
)
@@ -622,7 +622,7 @@ async def _run_cycle_parked(
except Exception as exc:
logger.exception("error escaped the run cycle for %s; parking as failed", agent_id)
await coordinator.set_status(agent_id, "failed", error=str(exc) or type(exc).__name__)
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
await notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
@@ -778,7 +778,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
if isinstance(exc, ProviderRefusalError):
logger.warning("agent %s refused by the model provider: %s", agent_id, exc)
await coordinator.set_status(agent_id, "failed", error=str(exc))
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
await notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
if not interactive:
raise
@@ -790,7 +790,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
status = "crashed"
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
await _notify_parent_on_terminal(coordinator, agent_id, status)
await notify_parent_on_terminal(coordinator, agent_id, status)
return None
else:
return cast("RunResultBase | None", stream)
@@ -851,6 +851,11 @@ async def _append_tool_required_message(
_TERMINAL_NOTICE = {
"completed": (
"[Agent completed] {name} ({agent_id}) finished and is no longer running, but it "
"sent no completion report. Stop waiting on this child; ask it directly if you "
"need its results."
),
"crashed": (
"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
"Stop waiting on this child unless you want to message it again."
@@ -898,7 +903,7 @@ async def _notify_parent_on_stall(
)
async def _notify_parent_on_terminal(
async def notify_parent_on_terminal(
coordinator: AgentCoordinator,
agent_id: str,
status: str,
@@ -911,6 +916,8 @@ async def _notify_parent_on_terminal(
name = coordinator.names.get(agent_id, agent_id)
if parent is None:
return
if not await coordinator.claim_parent_notice(agent_id):
return
await coordinator.send(
parent,
{
@@ -945,6 +952,21 @@ async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
await coordinator.send(root, _reserve_notice())
async def _notify_parent_on_exit(
coordinator: AgentCoordinator,
agent_id: str,
) -> None:
"""Backstop for a child whose loop ended without telling its parent.
Every terminal state counts, including ``completed``: a child that skips its
completion report leaves the parent waiting on a message nobody will send.
"""
status = await _agent_status(coordinator, agent_id)
if status is None:
return
await notify_parent_on_terminal(coordinator, agent_id, status)
async def _start_child_runner(
*,
parent_ctx: dict[str, Any],
@@ -998,6 +1020,9 @@ async def _start_child_runner(
logger.info("child %s stopped after reaching the scan budget limit", child_id)
except SubagentBudgetReservedError:
logger.info("child %s stopped at the sub-agent budget reserve", child_id)
finally:
if not coordinator.is_shutting_down:
await _notify_parent_on_exit(coordinator, child_id)
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
await coordinator.attach_runtime(child_id, task=task_handle)
+7 -2
View File
@@ -13,6 +13,7 @@ from typing import Any, Literal, get_args
from agents import RunContextWrapper, function_tool
from strix.core.agents import Status, coordinator_from_context
from strix.core.execution import notify_parent_on_terminal
from strix.skills import validate_requested_skills
@@ -558,7 +559,7 @@ async def agent_finish(
)
parent_notified = False
if report_to_parent:
if report_to_parent and await coordinator.claim_parent_notice(me):
async with coordinator._lock:
agent_name = coordinator.names.get(me, me)
report = _render_completion_report(
@@ -582,6 +583,11 @@ async def agent_finish(
)
parent_notified = True
await coordinator.set_status(me, "completed")
if not parent_notified:
# Silence here would leave a parent waiting on a report that is never coming.
await notify_parent_on_terminal(coordinator, me, "completed")
logger.info(
"agent_finish: %s success=%s findings=%d parent_notified=%s",
me,
@@ -589,7 +595,6 @@ async def agent_finish(
len(findings or []),
parent_notified,
)
await coordinator.set_status(me, "completed")
return json.dumps(
{