From dd497c3d5925323ae3e60613befe0f4509b2d0d8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:05:29 -0700 Subject: [PATCH] fix(delegate): grandchildren spawned after their orchestrator was stopped now die with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIAgent.interrupt() fans the stop out to a snapshot of _active_children. A child that is attached after that snapshot — an orchestrator subagent still building its fan-out siblings, or one that has not yet hit its next iteration check — started with no signal and ran to completion as an orphan while its parent had already reported `interrupted`. Live repro (mid orchestrator, stop delivered between grandchild A and B builds): grandchild B kept its `sleep 20` alive and stayed in the subagent registry after the mid returned. _attach_child now mirrors a pending parent stop onto the newcomer, so the whole spawn tree dies with the node that was stopped. Tests: unit (late attach gets the stop, normal attach does not) + the real _build_child_agent path with a stopped orchestrator. --- .../test_delegate_late_child_inherits_stop.py | 62 +++++++++++++++++++ tools/delegate_tool_child_run.py | 9 ++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_delegate_late_child_inherits_stop.py diff --git a/tests/tools/test_delegate_late_child_inherits_stop.py b/tests/tools/test_delegate_late_child_inherits_stop.py new file mode 100644 index 0000000000..c7e2692217 --- /dev/null +++ b/tests/tools/test_delegate_late_child_inherits_stop.py @@ -0,0 +1,62 @@ +"""A stop that lands on an orchestrator subagent mid-fan-out reaches every grandchild it builds afterwards. + +``AIAgent.interrupt()`` fans out to a snapshot of ``_active_children``; a child attached after that +snapshot (the orchestrator is still building siblings, or has not reached its next iteration check) +used to start with no signal and run to completion as an orphan. ``_attach_child`` now mirrors a +pending stop onto the newcomer. +""" +from types import SimpleNamespace + +from tools.delegate_tool_child_run import _attach_child + + +class _Child: + def __init__(self): + self.stops = [] + + def hard_interrupt(self, message=None, *, tool_reason=None): + self.stops.append(message) + + +def test_child_attached_after_parent_stop_is_stopped_too(): + parent = SimpleNamespace(_active_children=[], _interrupt_requested=True, _interrupt_message="stop") + late = _Child() + _attach_child(parent, late) + assert late in parent._active_children + assert late.stops == ["stop"] + + +def test_child_attached_to_running_parent_is_left_alone(): + parent = SimpleNamespace(_active_children=[], _interrupt_requested=False) + child = _Child() + _attach_child(parent, child) + assert child in parent._active_children and child.stops == [] + + +def test_real_spawn_path_child_starts_interrupted(tmp_path, monkeypatch): + """Through ``_build_child_agent`` with real AIAgents: the orchestrator is already stopped, so the + grandchild it builds carries the interrupt before its conversation ever starts.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from run_agent import AIAgent + from tools import delegate_tool as dt + import tools.delegate_tool_config as dtc + cfg = {"max_spawn_depth": 3} + monkeypatch.setattr(dt, "_load_config", lambda: cfg) + monkeypatch.setattr(dtc, "_load_config", lambda: cfg) + kw = dict(api_key="k", base_url="https://openrouter.ai/api/v1", provider="openrouter", + api_mode="chat_completions", model="anthropic/claude-sonnet-4.6", platform="cli", quiet_mode=True, + skip_context_files=True, skip_memory=True, save_trajectories=False, enabled_toolsets=["file"]) + parent = AIAgent(session_id="p", **kw) + mid = dt._build_child_agent(task_index=0, goal="mid", context=None, toolsets=["file"], model=None, + max_iterations=4, task_count=1, parent_agent=parent) + try: + mid.hard_interrupt("stop") + grandchild = dt._build_child_agent(task_index=0, goal="gc", context=None, toolsets=["file"], model=None, + max_iterations=4, task_count=1, parent_agent=mid) + try: + assert grandchild._interrupt_requested is True + finally: + grandchild.close() + finally: + mid.close() + parent.close() diff --git a/tools/delegate_tool_child_run.py b/tools/delegate_tool_child_run.py index 7d49038a8d..4a38995cf5 100644 --- a/tools/delegate_tool_child_run.py +++ b/tools/delegate_tool_child_run.py @@ -60,9 +60,16 @@ def _with_children_lock(parent_agent: Any, op: str, child: Any) -> None: getattr(parent_agent._active_children, op)(child) def _attach_child(parent_agent: Any, child: Any) -> None: - """Register the child for parent interrupt propagation.""" + """Register the child for parent interrupt propagation. + + ``interrupt()`` fans out to a SNAPSHOT of ``_active_children``; a child attached after the stop + landed (a fan-out still building its siblings, a turn that has not reached its iteration check yet) + would otherwise start with no signal and run to completion as an orphan. Mirror a pending stop here + so the whole spawn tree dies with its parent.""" if hasattr(parent_agent, "_active_children"): _with_children_lock(parent_agent, "append", child) + if getattr(parent_agent, "_interrupt_requested", False) is True: + _signal_child_stop(child, getattr(parent_agent, "_interrupt_message", None) or "parent agent interrupted") def _detach_child(parent_agent: Any, child: Any) -> None: """Remove the child from parent interrupt propagation (no-op if absent)."""