Files
handler/tests/test_dispatch.py
T
Claude 8541b4b7c0 Agents can hand work to agents: dispatch_agent
A schedule is a time trigger, and only the first step of a pipeline is really
waiting on time — every later step waits on the previous step's result. Modeling
"watch a source -> write a spec -> implement it" as three schedules made each fire
blind: on a quiet day the coding agent still spawned, paid a full model run to find
there was nothing to do, and left an empty run in Activity.

So an agent can start the next step itself. `dispatch_agent` (a tool on the bundled
MCP server, and on the pi bridge through the same --call seam) enqueues an ordinary
spawn command in the agent's own project, tagged requested_by=agent:<id> — so a
handoff is visible in Activity with no new surface to build.

- Project-scoped by construction: project_id is read from the spawn environment and
  never from the tool arguments.
- Bounded rather than gated: MAX_DISPATCH_PER_RUN counts the command rows the agent
  already wrote; MAX_DISPATCH_DEPTH rides in the spawn payload and is recovered by
  spawn._dispatch_depth, so a chain keeps its place across a resume and a cycle
  terminates instead of fanning out.
- New scout and planner roles, with built-in skills (handler-scout, handler-planner,
  handler-dispatch) carrying the judgment code can't: dedupe against a memory-note
  watermark, treat "nothing new" as a complete run, and write a task the receiving
  cold-start agent can act on.
- A scout ending on a clean tree skips the test gate and records the new
  tests_status='skipped' (migration 0017, additive CHECK widening). The gate promises
  `done` means tests passed for the work that shipped; nothing shipped.

Rejected a `condition` field on schedules: "is this paper new and does it matter
here?" is a semantic judgment, so it belongs to a model, not a scheduler column. The
scout is the condition; dispatch is how it reports true — one mechanism that covers
future pipelines too.

426 tests (14 new for dispatch, 3 for the gate exemption).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcbDevyMcJWE6qPA56C7mZ
2026-08-19 22:46:06 +00:00

236 lines
8.4 KiB
Python

"""Agent-initiated dispatch: the ``dispatch_agent`` MCP tool, its guardrails, and the
depth that spawn recovers for the agents it launches.
A dispatch is an ordinary queued ``spawn`` command attributed to the agent that asked
for it, so these tests assert on the command queue — the same rows Activity renders.
"""
from __future__ import annotations
import json
import pytest
from handler.db import repository as repo
from handler.db.engine import get_engine
@pytest.fixture
def dispatcher(env):
"""A project with one agent that has a live run (the per-run budget's boundary)."""
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
repo.create_project(conn, "other", "/tmp/other")
agent = repo.create_agent(conn, "proj", "scout-1", "/tmp/proj", "working")
run = repo.create_run(conn, agent["id"], "s1", "w1", "spawn")
return {"agent": agent, "run": run}
_DEFAULT = object()
def _server(dispatcher, *, depth=0, project_id="proj", agent_id=_DEFAULT):
from handler.mcpserver import MemoryServer
return MemoryServer(
agent_id=dispatcher["agent"]["id"] if agent_id is _DEFAULT else agent_id,
project_id=project_id,
dispatch_depth=depth,
)
def _call(server, name, args):
from handler.mcpserver import handle_message
resp = handle_message(
server,
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": name, "arguments": args}},
)
result = resp["result"]
text = result["content"][0]["text"]
return result["isError"], (text if result["isError"] else json.loads(text))
_TASK = {"name_prefix": "planner", "task": "Read @specs/x.md and build it", "reason": "new paper"}
# --- the happy path ----------------------------------------------------------------------
def test_dispatch_enqueues_an_attributed_spawn(dispatcher):
err, out = _call(_server(dispatcher), "dispatch_agent", {**_TASK, "role": "planner"})
assert not err, out
assert out["dispatched"] is True
assert out["agent_name"].startswith("planner-")
with get_engine().begin() as conn:
command = repo.get_command(conn, out["command_id"])
assert command["type"] == "spawn"
assert command["status"] == "queued"
assert command["project_id"] == "proj"
assert command["agent_name"] == out["agent_name"]
# Attribution is what makes a dispatch legible in Activity — and what the per-run
# budget counts.
assert command["requested_by"] == f"agent:{dispatcher['agent']['id']}"
payload = command["payload"]
assert payload["task"] == _TASK["task"]
assert payload["reason"] == _TASK["reason"]
assert payload["role"] == "planner"
assert payload["dispatch_depth"] == 1
assert payload["parent_agent_id"] == dispatcher["agent"]["id"]
def test_dispatch_is_listed_and_optional_fields_pass_through(dispatcher):
from handler.mcpserver import TOOLS
assert "dispatch_agent" in {t["name"] for t in TOOLS}
err, out = _call(
_server(dispatcher),
"dispatch_agent",
{**_TASK, "model_id": 7, "worktree": "feature/x", "subdir": "svc"},
)
assert not err, out
with get_engine().begin() as conn:
payload = repo.get_command(conn, out["command_id"])["payload"]
assert payload["model_id"] == 7
assert payload["worktree"] == "feature/x"
assert payload["subdir"] == "svc"
# Untouched optional fields stay absent rather than arriving as nulls the worker
# would have to special-case.
assert "role" not in payload
def test_repeat_dispatches_get_distinct_names(dispatcher):
"""Names are timestamped to the second; a burst must not collide."""
server = _server(dispatcher)
names = set()
for _ in range(3):
err, out = _call(server, "dispatch_agent", _TASK)
assert not err, out
names.add(out["agent_name"])
assert len(names) == 3
# --- guardrails --------------------------------------------------------------------------
def test_project_comes_from_the_environment_not_the_arguments(dispatcher):
"""Project isolation holds by construction: a foreign project_id is simply ignored."""
err, out = _call(_server(dispatcher), "dispatch_agent", {**_TASK, "project_id": "other"})
assert not err, out
with get_engine().begin() as conn:
assert repo.get_command(conn, out["command_id"])["project_id"] == "proj"
def test_per_run_budget_refuses_the_next_dispatch(dispatcher, monkeypatch):
monkeypatch.setenv("MAX_DISPATCH_PER_RUN", "2")
from handler import config
config.get_settings.cache_clear()
server = _server(dispatcher)
for _ in range(2):
err, out = _call(server, "dispatch_agent", _TASK)
assert not err, out
err, msg = _call(server, "dispatch_agent", _TASK)
assert err
assert "dispatch refused" in msg and "limit 2" in msg
with get_engine().begin() as conn:
queued = [c for c in repo.list_commands(conn, project_id="proj") if c["type"] == "spawn"]
assert len(queued) == 2
config.get_settings.cache_clear()
def test_budget_counts_only_the_current_run(dispatcher, monkeypatch):
"""A dispatch from an earlier run doesn't spend this run's allowance."""
monkeypatch.setenv("MAX_DISPATCH_PER_RUN", "1")
from handler import config
config.get_settings.cache_clear()
server = _server(dispatcher)
err, _ = _call(server, "dispatch_agent", _TASK)
assert not err
err, _ = _call(server, "dispatch_agent", _TASK)
assert err # budget spent for this run
# A new run resets the window.
with get_engine().begin() as conn:
repo.finish_run(conn, dispatcher["run"]["id"], "completed", exit_code=0)
repo.create_run(conn, dispatcher["agent"]["id"], "s2", "w1", "resume")
err, out = _call(server, "dispatch_agent", _TASK)
assert not err, out
config.get_settings.cache_clear()
def test_depth_cap_refuses_a_long_chain(dispatcher, monkeypatch):
monkeypatch.setenv("MAX_DISPATCH_DEPTH", "3")
from handler import config
config.get_settings.cache_clear()
# Depth 2 may still hand off (the child lands at 3, the cap itself).
err, out = _call(_server(dispatcher, depth=2), "dispatch_agent", _TASK)
assert not err, out
assert out["dispatch_depth"] == 3
# Depth 3 is the end of the chain: a cycle terminates here instead of fanning out.
err, msg = _call(_server(dispatcher, depth=3), "dispatch_agent", _TASK)
assert err
assert "dispatch refused" in msg and "deep" in msg
config.get_settings.cache_clear()
@pytest.mark.parametrize(
"args",
[
{**_TASK, "name_prefix": " "},
{**_TASK, "task": ""},
{**_TASK, "reason": ""},
{**_TASK, "role": "architect"},
],
)
def test_bad_arguments_are_refused(dispatcher, args):
err, msg = _call(_server(dispatcher), "dispatch_agent", args)
assert err
assert "error:" in msg
def test_dispatch_needs_an_identity(dispatcher):
"""A process with no agent identity (a stray CLI call) cannot enqueue work."""
err, msg = _call(_server(dispatcher, agent_id=None), "dispatch_agent", _TASK)
assert err
assert "identity" in msg
# --- what the dispatched agent inherits --------------------------------------------------
def test_spawn_recovers_dispatch_depth_for_the_child(dispatcher):
"""The child's depth is read back off the command that created it — so it survives
a resume, which carries no payload of its own."""
from handler.control import spawn
err, out = _call(_server(dispatcher, depth=1), "dispatch_agent", _TASK)
assert not err, out
assert spawn._dispatch_depth("proj", out["agent_name"]) == 2
# An operator- or schedule-started agent is at the root of its own chain.
assert spawn._dispatch_depth("proj", "scout-1") == 0
def test_dispatch_reaches_the_call_seam(dispatcher, monkeypatch, capsys):
"""``--call`` is how pi-harness agents dispatch; it shares the tool implementation."""
import io
from handler.mcpserver.__main__ import call_tool
monkeypatch.setenv("HANDLER_AGENT_ID", str(dispatcher["agent"]["id"]))
monkeypatch.setenv("HANDLER_PROJECT_ID", "proj")
monkeypatch.setenv("HANDLER_DISPATCH_DEPTH", "1")
out = io.StringIO()
rc = call_tool("dispatch_agent", stdin=io.StringIO(json.dumps(_TASK)), stdout=out)
assert rc == 0
payload = json.loads(out.getvalue())
assert payload["dispatched"] is True
# The env-carried depth is what the seam uses, same as the MCP path.
assert payload["dispatch_depth"] == 2