diff --git a/harnessx/tools/spawn_subagent.py b/harnessx/tools/spawn_subagent.py index 21673f5..6394984 100644 --- a/harnessx/tools/spawn_subagent.py +++ b/harnessx/tools/spawn_subagent.py @@ -29,6 +29,11 @@ _MAX_SPAWN_DEPTH = 3 +# Strong refs to fire-and-forget async subagent tasks. asyncio only holds +# weak references to tasks, so without this set a background subagent could +# be garbage-collected before completing and silently drop its result. +_BACKGROUND_TASKS: "set[asyncio.Task]" = set() + # --------------------------------------------------------------------------- # Module-level tool function — reads parent configs from RunLoop context @@ -224,7 +229,9 @@ async def _run_child() -> None: ) parent_state.pending_subagents.pop(effective_label, None) - asyncio.create_task(_run_child()) + _bg_task = asyncio.create_task(_run_child(), name=f"spawn_subagent:{effective_label}") + _BACKGROUND_TASKS.add(_bg_task) + _bg_task.add_done_callback(_BACKGROUND_TASKS.discard) return json.dumps({"status": "accepted", "label": effective_label}) diff --git a/tests/unit/test_spawn_subagent.py b/tests/unit/test_spawn_subagent.py index 8ad5bd4..9877c71 100644 --- a/tests/unit/test_spawn_subagent.py +++ b/tests/unit/test_spawn_subagent.py @@ -222,6 +222,73 @@ async def _fake_run(self, subtask, parent_run_id=None, run_id=None): user_msgs = [m for m in parent_state.messages if m.role == "user"] assert any("async result" in m.content for m in user_msgs) + @pytest.mark.asyncio + async def test_spawn_async_task_is_referenced_until_done(self, monkeypatch): + """Fire-and-forget subagent tasks must be strong-referenced so the + event loop cannot garbage-collect them before completion. + + Regression: asyncio only holds weak refs to tasks. Without a module-level + strong ref, the returned Task from asyncio.create_task() could be + collected between the tool return and the await point in the child + harness, silently dropping the subagent's result. + """ + import gc + from harnessx.core.harness import HarnessConfig + from harnessx.core.state import State + from harnessx.tools.inmemory import InMemoryToolRegistry + from harnessx.tools.spawn_subagent import _BACKGROUND_TASKS + + model_config = ModelConfig(main=MagicMock()) + config = HarnessConfig(tool_registry=InMemoryToolRegistry()) + + child_started = asyncio.Event() + release_child = asyncio.Event() + + class _FakeResult: + final_output = "held result" + run_id = "held-run" + + async def _fake_run(self, subtask, parent_run_id=None, run_id=None): + child_started.set() + await release_child.wait() + return _FakeResult() + + import harnessx.core.harness as harness_mod + + monkeypatch.setattr(harness_mod.Harness, "run", _fake_run) + + parent_state = State(run_id="parent-ref") + token = _spawn_ctx.set( + { + "run_id": "parent-ref", + "step_id": 0, + "spawn_depth": 0, + "state": parent_state, + "tracer": None, + "model_config": model_config, + "harness_config": config, + } + ) + try: + await spawn_subagent(task="held work", wait=False, label="held") + finally: + _spawn_ctx.reset(token) + + # Task must be registered while still running. + await child_started.wait() + held_tasks = [t for t in _BACKGROUND_TASKS if t.get_name() == "spawn_subagent:held"] + assert len(held_tasks) == 1, "background task not tracked in _BACKGROUND_TASKS" + assert not held_tasks[0].done() + + # Even after aggressive GC the task remains reachable via the strong-ref set. + gc.collect() + assert any(t.get_name() == "spawn_subagent:held" for t in _BACKGROUND_TASKS) + + # Release the child and confirm the done-callback discards it. + release_child.set() + await held_tasks[0] + assert not any(t.get_name() == "spawn_subagent:held" for t in _BACKGROUND_TASKS) + # --------------------------------------------------------------------------- # Tool schema # ---------------------------------------------------------------------------