fix(delegate): grandchildren spawned after their orchestrator was stopped now die with it
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.
This commit is contained in:
62
tests/tools/test_delegate_late_child_inherits_stop.py
Normal file
62
tests/tools/test_delegate_late_child_inherits_stop.py
Normal file
@@ -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()
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user