Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion harnessx/tools/spawn_subagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})


Expand Down
67 changes: 67 additions & 0 deletions tests/unit/test_spawn_subagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading