From 8f1bb64d167fefb4eab561fe01f1c42cb143c7c3 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 1 Aug 2026 21:30:44 +0000 Subject: [PATCH] fix(core): tell the parent when an interactive subagent parks Parking is self-service only for the root, which the user is watching. A parked child owes its parent a report it can no longer send, so the parent would wait out its full timeout for nothing. --- strix/core/execution.py | 34 +++++++++++++++++++++++++++++ tests/test_execution.py | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/strix/core/execution.py b/strix/core/execution.py index 5862adb2..97fc1c94 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -537,6 +537,10 @@ async def _exhausted_recovery( agent_id, ) await coordinator.set_status(agent_id, "waiting") + # The user talks to the root, so parking is only self-service there. A parked + # subagent owes its parent a report it can no longer send, and the parent would + # otherwise wait out its full timeout for a message that is never coming. + await _notify_parent_on_stall(coordinator, agent_id) return result @@ -839,6 +843,36 @@ _TERMINAL_NOTICE = { } +_STALL_NOTICE = ( + "[Agent stalled] {name} ({agent_id}) kept ending turns without a tool call and is " + "parked until it receives a message. It will not send a completion report on its " + "own: either message it with a concrete next step to unblock it, or stop waiting on " + "it and account for its unfinished subtask." +) + + +async def _notify_parent_on_stall( + coordinator: AgentCoordinator, + agent_id: str, +) -> None: + """Tell the parent that a child parked mid-task, so it stops waiting blindly.""" + async with coordinator._lock: + parent = coordinator.parent_of.get(agent_id) + name = coordinator.names.get(agent_id, agent_id) + if parent is None: + return + await coordinator.send( + parent, + { + "from": agent_id, + "type": "stalled", + "priority": "high", + "content": _STALL_NOTICE.format(name=name, agent_id=agent_id), + }, + interrupt=False, + ) + + async def _notify_parent_on_terminal( coordinator: AgentCoordinator, agent_id: str, diff --git a/tests/test_execution.py b/tests/test_execution.py index 3b2df11c..8adcaa0f 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -891,6 +891,54 @@ async def test_interactive_recovery_exhaustion_parks_instead_of_crashing( assert coordinator.statuses["root"] == "waiting" +@pytest.mark.asyncio +async def test_interactive_subagent_exhaustion_tells_its_parent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The user only talks to the root, so a parked child must report up. + + Otherwise a parent blocked in wait_for_message burns its whole timeout + waiting for a completion report the child can no longer send. + """ + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + await coordinator.register("child", "recon", parent_id="root") + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "child", ["running"], calls), + ) + + await _drive(coordinator, "child", interactive=True) + + assert coordinator.statuses["child"] == "waiting" + pending, items = await coordinator.consume_pending("root", include_items=True) + assert pending == 1 + notice = str(items[0]) + assert "child" in notice + assert "parked" in notice + + +@pytest.mark.asyncio +async def test_interactive_root_exhaustion_notifies_nobody( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A parked root is self-service: the user is already watching it.""" + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "root", ["running"], []), + ) + + await _drive(coordinator, "root", interactive=True) + + pending, _ = await coordinator.consume_pending("root") + assert pending == 0 + + @pytest.mark.asyncio async def test_noninteractive_recovery_exhaustion_crashes( monkeypatch: pytest.MonkeyPatch,