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
This commit is contained in:
Claude
2026-08-19 22:46:06 +00:00
parent 52167db085
commit 8541b4b7c0
23 changed files with 945 additions and 33 deletions
+6
View File
@@ -77,6 +77,12 @@ PROJECTS_ROOT=/var/lib/handler/projects
# Closes the "merge locally, push to main" path around the forge-merge approval gate.
# PROTECTED_BRANCHES=main,master
# Agent-initiated dispatch (the `dispatch_agent` tool): an agent handing work to a fresh
# agent in its own project, so a pipeline advances on a result instead of on a timer.
# Both caps bound a confused or looping agent, not a healthy one — a handoff is one call.
# MAX_DISPATCH_PER_RUN=3
# MAX_DISPATCH_DEPTH=3
# Phase 3 (web UI). Serve the bundled UI from "/" and "/static". Set false for a
# headless, API-only deployment. Applied at process start (restart to change).
# UI_ENABLED=true
+37
View File
@@ -6,6 +6,43 @@ the image workflows publish (plus `latest` from every push to `main`).
## [Unreleased]
### Added — dynamic workflows: agents hand work to agents
Until now the only recurring primitive was a schedule, which fires an unconditional
spawn on a timer. A pipeline (watch a source → write a spec → implement it) had to be
N independent schedules, each firing blind: on a quiet day the coding agent still
spawned, paid a full model run to discover there was no work, and left an empty run in
Activity. Time is the wrong trigger for the later steps — the previous step's *result*
is.
- **`dispatch_agent`**, a new tool on the bundled `handler-memory` MCP server (and on
the pi bridge, via the same `--call` seam). An agent hands work to a fresh agent in
**its own project**`project_id` comes from the spawn environment, never from the
arguments — by enqueuing an ordinary `spawn` command tagged
`requested_by = agent:<id>`. It shows up in Activity like any other command, so
nothing new had to be built to observe it.
- **Guardrails, not an approval queue.** `MAX_DISPATCH_PER_RUN` (default 3) bounds one
run's handoffs, counted off the command rows it already wrote; `MAX_DISPATCH_DEPTH`
(default 3) bounds how far a chain reaches, so a cycle terminates instead of fanning
out. Depth is recovered from the spawn command that created an agent, so it survives
a resume. A refused dispatch tells the agent what to do instead.
- **Two roles — `scout` and `planner`** — with built-in skills (`handler-scout`,
`handler-planner`, `handler-dispatch`) carrying the judgment the code can't: dedupe
against a memory watermark, treat "nothing new" as a complete run, write a task the
receiving agent can act on cold.
- **A quiet scout run is cheap.** A `scout` ending on a clean tree skips `mise run test`
and records the new `tests_status = 'skipped'` (migration `0017_gate_skipped`) — the
gate's promise is that `done` means tests passed *for the work that shipped*, and
nothing shipped. A dirty tree, or any other role, keeps the full gate.
The net effect: one cheap schedule fires the scout; on a quiet day the pipeline costs
one small-model call and stops. Only a scout that actually found something spends a
coding-model run, and the spec it produced travels with the dispatch.
**Rollout:** run migrations (`0017_gate_skipped`, additive — it only widens a CHECK).
The two new settings have working defaults. The three new skills seed themselves on the
next API start, idempotently by name.
### Fixed
- **The project-root checkout is now a ref store, never a working tree Handler moves.**
+58
View File
@@ -139,6 +139,7 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example)
| `CLAUDE_BIN` / `PI_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `pi` / `mise` / `tmux` / `forge` / `git` |
| `FORGE_VERSION` | Pinned forge version verified at spawn (Phase 2) | unset → skip check |
| `PROTECTED_BRANCHES` | Branches a direct push needs an approval to reach (Phase 2) | `main,master` |
| `MAX_DISPATCH_PER_RUN` / `MAX_DISPATCH_DEPTH` | Caps on agent-initiated dispatch: handoffs per run, and how far a chain reaches | `3` / `3` |
## Run
@@ -452,6 +453,11 @@ recent notes in scope, so knowledge from earlier runs arrives without being aske
Notes live only in the database — like everything else, they survive disposable
workers by construction — and deleting an agent never deletes what it learned.
The same MCP server carries **`dispatch_agent`** (see
[Dynamic workflows](#dynamic-workflows-agents-handing-work-to-agents)), so an agent's
two ways of reaching past its own run — what it *knows* and what it *starts* — arrive
over one transport with one identity contract.
## Hooks
Wired into each agent as `python -m handler.hooks <event>`:
@@ -475,6 +481,12 @@ Wired into each agent as `python -m handler.hooks <event>`:
agent's scope (its project + global) as additional context, plus a pointer at the
handler-memory MCP tools. Best-effort; never blocks the session.
One narrow exemption to the completion gate: a **`scout`** ending with a clean tree
skips the test run and records `tests_status = 'skipped'`. Scouts look and hand
findings on, so "`done` means a test run passed" is vacuous when nothing shipped — and
a scheduled watch is mostly quiet runs. A dirty tree, or any other role, keeps the
full gate.
Hook identity travels via environment variables injected at spawn (`HANDLER_AGENT_ID`,
`HANDLER_PROJECT_ID`, `HANDLER_AGENT_NAME`, `HANDLER_AGENT_ROLE`, `DATABASE_URL`), since
hook stdin doesn't carry it; the wiring itself lives in the generated `settings.json`.
@@ -547,6 +559,52 @@ Missed intervals (worker down) collapse into a single catch-up run. Manage sched
the dashboard's **Schedules** pane or via `GET/POST /projects/:p/schedules`,
`PATCH`/`DELETE /schedules/:id`.
### Dynamic workflows: agents handing work to agents
A schedule is a *time* trigger, which is the wrong trigger for every step after the
first. Scheduling "write a spec" and "implement the spec" on their own timers means
they fire blind: on a quiet day the coding agent still spawns, pays a full model run to
discover there is nothing to do, and leaves an empty run behind. What the later steps
actually wait on is the **result** of the earlier one.
So an agent can start one: **`dispatch_agent`** (a tool on the bundled MCP server, and
on the pi bridge) enqueues an ordinary `spawn` command in the agent's own project,
tagged `requested_by = agent:<id>`. It appears in Activity like any other command, and
the worker claims it the same way.
```
schedule (cheap model, every 6h)
└─ scout ── nothing new? update the watermark, end. ← one small call, done
something new? dispatch_agent(role="planner") ─┐
planner ── writes + commits specs/<date>-<slug>.md
dispatch_agent(role="junior") ─┐
junior → senior → deploy
```
Only the scout is on a timer. Nothing downstream costs a token until the scout says
there is work — which is the point: the condition ("is this paper new, and does it
matter here?") is a judgment, so it belongs to a model rather than to a scheduler
field.
- **Project-scoped by construction.** The target project comes from the spawn
environment, never from the tool arguments, so a dispatch can't cross a project
boundary any more than an agent can.
- **Bounded, not gated.** `MAX_DISPATCH_PER_RUN` (default 3) caps one run's handoffs;
`MAX_DISPATCH_DEPTH` (default 3) caps how far a chain reaches, so a cycle terminates
instead of fanning out. Depth is recovered from the command that created an agent, so
a resume keeps its place in the chain. Dispatches run immediately — the caps and the
Activity trail are the cost control, not an approval queue.
- **Roles carry the judgment.** `scout` and `planner` join the forge roles, with
built-in skills (`handler-scout`, `handler-planner`, `handler-dispatch`) that teach
the parts code can't enforce: dedupe new findings against a memory-note watermark,
treat "nothing new" as a *complete* run, and write a task the receiving agent — which
starts cold, with no memory of the session that dispatched it — can actually act on.
- **Cheap where it should be.** Point the scout's schedule at a small backend with
`model_id` (a `claude_models` row, `harness='pi'` for a local model); the expensive
Claude run only happens on the day there is something to build.
## Development
```bash
+2
View File
@@ -22,6 +22,8 @@ import type { Schedule } from "../api/client";
const ROLE_OPTIONS = [
{ value: "", label: "Role — none" },
{ value: "scout", label: "scout" },
{ value: "planner", label: "planner" },
{ value: "junior", label: "junior" },
{ value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" },
+38
View File
@@ -244,6 +244,44 @@ The static shell is served **unauthenticated** (it holds no data); the browser p
**Out of scope (additive follow-ups):** an aggregate `GET /projects/{project}/overview` (agents + latest checkmark in one call) to show every agent's checkmark at once; spawning agents / registering projects from the UI (still CLI-driven); shared-context **writes** from the UI (would need the shared-write token — MVP is read-only).
### Dynamic workflows — agent-initiated dispatch
Design decision (operator, 2026-08-19): a recurring **pipeline** is not N schedules. A
schedule is a time trigger, and only the first step of a pipeline is genuinely waiting
on time — every later step is waiting on the previous step's *result*. Scheduling them
independently means each fires blind, and the expensive steps pay a full model run on
quiet days just to discover there is nothing to do.
Rejected: a `condition` field on `schedules`. The conditions that matter here ("is this
paper new, and does it bear on this project?") are semantic judgments, so they belong
to a model, not to a scheduler column. The scout **is** the condition; `dispatch_agent`
is how it reports true. One mechanism then covers every future pipeline rather than
this one.
- [x] `dispatch_agent` on the bundled MCP server (+ pi bridge via the same `--call`
seam): enqueues an ordinary `spawn` command tagged `requested_by = agent:<id>`,
so a handoff is visible in Activity with no new surface. `project_id` is read
from the spawn environment and never from the arguments — project isolation holds
by construction rather than by validation.
- [x] Bounded rather than gated (operator decision: dispatches run immediately, no
approval queue): `max_dispatch_per_run` counted off the command rows the agent
already wrote, and `max_dispatch_depth` carried in the spawn payload and
recovered by `spawn._dispatch_depth`, so a chain keeps its place across a resume
and a cycle terminates. Any agent may dispatch — one general primitive, so a
later chain (a junior splitting off a follow-up) needs no further change.
- [x] `scout` + `planner` roles with built-in skills carrying the judgment code can't:
dedupe against a memory-note watermark (better than a repo file — no checkout, no
commit, and `SessionStart` recall delivers it for free), treat "nothing new" as a
complete run, and write a task the receiving cold-start agent can act on.
- [x] A `scout` on a clean tree skips the test gate (`tests_status = 'skipped'`,
migration `0017_gate_skipped`). The gate promises `done` means tests passed *for
the work that shipped*; nothing shipped. The clean-tree condition is what keeps
that honest.
**Definition of done:** one cheap schedule fires a scout; a quiet run costs one
small-model call and enqueues nothing; a run that finds something dispatches a planner,
which commits a spec and dispatches a junior into the existing forge workflow. 426 tests.
### Phase 4 — Observability (moved back, now optional)
- [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate)
- [ ] Grafana/Loki wiring documented as an optional add-on for self-hosters who already run that stack — not a dependency for anyone else
@@ -9,6 +9,8 @@ import { fmtFull, timeAgo } from "@/lib/format";
const ROLE_OPTS = [
{ value: "", label: "Role — none" },
{ value: "scout", label: "scout" },
{ value: "planner", label: "planner" },
{ value: "junior", label: "junior" },
{ value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" },
@@ -10,6 +10,8 @@ import { fmtFull } from "@/lib/format";
const ROLE_OPTS = [
{ value: "", label: "Role — none" },
{ value: "scout", label: "scout" },
{ value: "planner", label: "planner" },
{ value: "junior", label: "junior" },
{ value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" },
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Roles + forge families mirrored from db.tables; Literal gives clean 422s on bad input.
Role = Literal["junior", "senior", "deploy"]
Role = Literal["scout", "planner", "junior", "senior", "deploy"]
ForgeType = Literal["github", "gitlab", "gitea", "forgejo", "bitbucket"]
# Agent harness a model backend launches (claude_models.harness).
+123
View File
@@ -240,6 +240,129 @@ whatever the operator configured). They are for tools to use, not for output.
- Committed a secret anyway? Do not just delete it in a follow-up commit (history
keeps it). Stop, leave an open question naming the credential so the operator can
rotate it, and say exactly which commit is affected.
""",
),
(
"handler-dispatch",
"How to hand work to a new agent with dispatch_agent: when a handoff is "
"warranted, and how to write a task the receiving agent can act on cold. "
"Use whenever you are considering dispatching.",
"""# Handing work to another agent
`dispatch_agent` queues a **new agent** in your project, starting as soon as a worker
is free. It exists so a pipeline advances on a *result* instead of on a timer: the
step that knows whether there is work is the step that starts the next one.
## Dispatch when — and only when — there is real work
- One dispatch per thing you actually found. A handoff, not a fan-out.
- **Finding nothing is a complete, successful run.** Say so in your final message and
end your turn. Do not dispatch "just to check", do not dispatch a placeholder, and
do not dispatch work you could finish yourself in this run.
- Don't dispatch to dodge a blocked gate. A failing test is yours to fix.
## Write the task for someone with no context
The new agent starts cold: it never saw your session, your search results, or your
reasoning. Its whole world is the `task` string you write. So:
- State the outcome, not the backstory: "Implement @specs/2026-08-19-foo.md" beats
"continue what I was looking at".
- Name every file, path, URL, or identifier it needs. If a fact only exists in your
transcript, it is lost — put it in the task, or save it to memory and say which note.
- Say what *done* looks like, and name any constraint you already know about.
- `reason` is for the operator reading Activity, not for the new agent: one line on
why this handoff was warranted.
## Limits, and what they mean
Dispatch is capped per run and per chain depth; a refusal is not a bug to route
around. Hitting the per-run cap means you are fanning out — fold the rest into one
handoff. Hitting the depth cap means the chain has gone far enough without a human:
finish what you can and leave the rest in your checkpoint for the operator.
Your dispatch shows up in Activity as a normal queued spawn attributed to you, so the
operator can always see which agent started what.
""",
),
(
"handler-scout",
"Role: scout — watch a source for genuinely new material, dedupe against a "
"memory watermark, and hand findings to a planner. Use when your role is "
"scout or your task is a recurring watch.",
"""# Role: scout
You watch a source and decide whether anything new is worth acting on. You do **not**
write code, write specs, or change the repo — a scout run should leave a clean tree.
Most of your runs will find nothing. That is the job working correctly, and it is what
makes the whole pipeline cheap: nothing downstream runs until you say there is work.
## Every run, in order
1. **Recall the watermark.** `memory_search` for your watch note (the one your task
names, e.g. `watch:<topic>`). It lists the identifiers you have already handled —
DOIs, arXiv ids, URLs, release tags, whatever your source uses. A `SessionStart`
recall usually puts it in front of you before you ask.
2. **Query the source** your task names, over a window comfortably wider than your
schedule's interval — overlap is free, a gap loses an item forever.
3. **Filter to genuinely new AND relevant.** Drop anything whose id is already in the
watermark. Then drop anything that doesn't actually bear on the project's subject:
a keyword match is not relevance, and passing junk downstream costs a full coding
run. When you are unsure, prefer to skip and note why.
4. **Update the watermark** with `memory_save(note_id=...)` on the *same* note — every
id you examined this run, whether or not you passed it on, so the next run doesn't
re-examine it. Keep it a compact list, newest first; trim ids far older than could
ever resurface.
5. **Report.**
- *Nothing new:* end your turn with a one-line final message saying what you
searched and that nothing qualified. No dispatch. Don't pad the run.
- *Something new:* one `dispatch_agent` call with `role="planner"`, carrying the
full citations (title, id/DOI, link, authors, date) and — in your own words — why
it matters to this project and what it might change. Then end.
## Keep the run cheap
You are deliberately run on a small, cheap model on a short leash: read abstracts and
metadata first, and only fetch a full text when relevance genuinely turns on it. Don't
clone, don't build, don't run tests. Your entire output is a memory note and, on the
rare interesting day, one dispatch.
""",
),
(
"handler-planner",
"Role: planner — turn source material into a committed spec, then dispatch "
"an implementer. Use when your role is planner.",
"""# Role: planner
You turn raw material (a paper, a report, an operator brief) into a spec someone else
can implement without reading the source. You do **not** implement it yourself.
## The run
1. **Read the sources named in your task** — properly, not just the abstract. If a
source is unreachable, say so in your checkpoint rather than guessing at it.
2. **Check what already exists.** `memory_search` for prior decisions on this topic,
and look at the repo: the change may already be present, already rejected, or
already specced. Saying "no change needed, here's why" is a valid outcome and a
cheap one.
3. **Write `specs/<YYYY-MM-DD>-<slug>.md`** (create `specs/` if missing):
- *Source* — citation and link, so the provenance survives you.
- *Why* — what this changes about how the project should work.
- *What to build* — concrete, in this codebase's terms: the files and functions to
touch, the behavior to add, the interfaces involved.
- *How to verify* — what test proves it, and what the expected result is.
- *Out of scope* — what an implementer should explicitly not do here.
Write for someone who never read the source. If the source doesn't support a
concrete change, write that conclusion in the spec instead of inventing scope.
4. **Commit and push the spec** — it must exist in the repo before anyone can act on
it, and the completion gate will hold you until it's pushed anyway.
5. **Dispatch the implementer:** one `dispatch_agent` with `role="junior"` and a task
naming the spec path (`Implement @specs/<file>.md`) plus a one-line summary of the
goal. From there the normal junior -> senior -> deploy workflow takes over.
If step 3 concluded no change is warranted, commit that spec anyway as the record —
and do **not** dispatch. A written "we looked and decided not to" is worth keeping.
""",
),
]
+10
View File
@@ -107,6 +107,16 @@ class Settings(BaseSettings):
# Comma-separated permission allow rules added to generated settings for headless runs.
headless_allowed_tools: str = "Bash(git *),Bash(mise *)"
# ---- Agent-initiated dispatch (the ``dispatch_agent`` MCP tool): an agent handing
# work to a fresh agent in its own project, so a pipeline advances on a result rather
# than on a timer. Both caps bound a confused (or looping) agent, not a healthy one:
# a handoff is one call, and chains are two or three links deep.
# Dispatches one agent may enqueue within a single run.
max_dispatch_per_run: int = 3
# How far a dispatch chain may reach from the agent that started it. A dispatch at
# this depth is refused, so A -> B -> C -> A terminates instead of fanning out.
max_dispatch_depth: int = 3
# Wall-clock budget for the install-from-prompt one-off claude run (Claude page,
# Skills tab). Kept under worker_stale_after by default: the run blocks the worker's
# drain loop synchronously, and outliving the heartbeat window would get its live
+36 -4
View File
@@ -16,8 +16,9 @@
* Stop-hook re-invoke; stop_hook_active guards the loop)
* - session_shutdown → hooks session_end
*
* It also registers the memory tools (memory_search/get/save/link) that claude agents
* reach over MCP, by shelling to `python -m handler.mcpserver --call <tool>` — pi has
* It also registers the memory tools (memory_search/get/save/link) and dispatch_agent
* that claude agents reach over MCP, by shelling to
* `python -m handler.mcpserver --call <tool>` — pi has
* no MCP by design, and a subprocess inheriting the agent env is the same trust model
* the MCP server used anyway — plus web_search/web_fetch (`python -m handler.webtool`),
* because pi ships no web tools and claude's live server-side at Anthropic.
@@ -71,7 +72,9 @@ function callPython(moduleArgs: string[], toolName: string, args: Record<string,
return (res.stdout || "").trim() || "{}";
}
function callMemory(tool: string, args: Record<string, unknown>): string {
// The bundled MCP server's one-shot seam: same tool implementations claude reaches
// over MCP (memory + dispatch), same identity-from-environment contract.
function callMcpTool(tool: string, args: Record<string, unknown>): string {
return callPython(["-m", "handler.mcpserver", "--call", tool], tool, args);
}
@@ -258,12 +261,41 @@ export default function (pi: ExtensionAPI) {
pi.registerTool({
...tool,
async execute(_toolCallId: string, params: Record<string, unknown>) {
const text = callMemory(tool.name, params ?? {});
const text = callMcpTool(tool.name, params ?? {});
return { content: [{ type: "text", text }], details: {} };
},
});
}
// ---- dispatch (same MCP seam): hand work to a new agent in this project ------------
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch agent",
description:
"Hand work to a NEW agent in this project, which starts as soon as a worker is " +
"free. Use when your run produced something concrete for a different role to act " +
"on. This is a handoff, not a fan-out: dispatch once per thing you found, and only " +
"when there is real work — finding nothing is a complete run, so say so and end " +
"your turn instead. The new agent has NO memory of this session, so 'task' must " +
"stand on its own: what to do, which files or sources, and anything it would " +
"otherwise have to rediscover.",
parameters: Type.Object({
name_prefix: Type.String({ description: "Short slug; a timestamp is appended" }),
task: Type.String({ description: "The new agent's whole prompt — self-contained" }),
reason: Type.String({ description: "Why this handoff is warranted (for the operator)" }),
role: Type.Optional(
Type.String({ description: "scout | planner | junior | senior | deploy" }),
),
model_id: Type.Optional(Type.Integer({ description: "Model backend for the new agent" })),
worktree: Type.Optional(Type.String({ description: "Branch for a per-run worktree" })),
subdir: Type.Optional(Type.String({ description: "Subdir under the project root" })),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const text = callMcpTool("dispatch_agent", params ?? {});
return { content: [{ type: "text", text }], details: {} };
},
});
// ---- web tools (handler.webtool — pi ships none, claude's are Anthropic-server-side)
const webTools: Array<{ name: string; label: string; description: string; parameters: any }> = [
{
+21
View File
@@ -249,6 +249,12 @@ def _agent_env(
role = role or agent.get("role")
if role:
env["HANDLER_AGENT_ROLE"] = role
depth = _dispatch_depth(project["id"], agent["name"])
if depth:
# Read by the dispatch tool: how many handoffs deep this agent already is, so a
# chain (and any cycle in it) runs into ``max_dispatch_depth``. Recovered from
# the spawn command rather than the agent row, so a resume keeps the same depth.
env["HANDLER_DISPATCH_DEPTH"] = str(depth)
if mise_init:
# Read by the Stop / git-push hooks to enforce the bootstrap contract.
env["HANDLER_MISE_INIT"] = "1"
@@ -282,6 +288,21 @@ def _agent_env(
return env, harness
def _dispatch_depth(project_id: str, agent_name: str) -> int:
"""How many dispatch handoffs led to this agent (0 for operator/schedule spawns).
Best-effort: a missing command row or a hand-rolled payload just means depth 0,
which is the same position an operator-started agent is in.
"""
with connection() as conn:
command = repo.get_spawn_command(conn, project_id, agent_name)
payload = (command or {}).get("payload") or {}
try:
return max(0, int(payload.get("dispatch_depth") or 0))
except (TypeError, ValueError):
return 0
def _check_forge_version(working_dir: str) -> str | None:
pin = get_settings().forge_version
if not pin:
+40
View File
@@ -522,6 +522,46 @@ def get_command(conn: Connection, command_id: int) -> dict | None:
return _row_to_dict(row)
def get_spawn_command(conn: Connection, project_id: str, agent_name: str) -> dict | None:
"""The most recent ``spawn`` command that created this agent, if any.
Lets a launch recover what it was spawned *with* (currently the dispatch depth)
without copying those fields onto the agent row and works identically on the
resume path, which has no payload of its own.
"""
row = conn.execute(
select(commands)
.where(
commands.c.type == "spawn",
commands.c.project_id == project_id,
commands.c.agent_name == agent_name,
)
.order_by(commands.c.id.desc())
.limit(1)
).first()
return _row_to_dict(row)
def count_agent_dispatches(conn: Connection, agent_id: int, since: datetime | None) -> int:
"""How many commands this agent has enqueued itself (optionally since a timestamp).
Dispatches are ordinary ``commands`` rows tagged ``requested_by = 'agent:<id>'``, so
the per-run budget needs no extra table it counts the audit trail it already wrote.
``since`` is the current run's ``started_at``; ``None`` counts the agent's whole
history, which is the conservative reading when no run row exists yet.
"""
from sqlalchemy import func as sqlfunc
stmt = (
select(sqlfunc.count())
.select_from(commands)
.where(commands.c.requested_by == f"agent:{agent_id}")
)
if since is not None:
stmt = stmt.where(commands.c.created_at >= since)
return int(conn.execute(stmt).scalar_one())
def list_commands(
conn: Connection,
project_id: str | None = None,
+7 -3
View File
@@ -31,7 +31,9 @@ metadata = MetaData()
# ``crashed`` is reserved for the reaper: it marks an agent whose owning worker went
# silent mid-run — never a normal exit, which reconciles to done/blocked instead.
AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done", "crashed")
GATE_STATUSES = ("pass", "fail", "unknown")
# "skipped" = the gate deliberately did not run because there was nothing to verify
# (a scout ending on a clean tree), which is neither "unknown" nor a "pass".
GATE_STATUSES = ("pass", "fail", "unknown", "skipped")
CI_STATUSES = ("not_applicable", "pending", "pass", "fail")
VISIBILITIES = ("project", "global")
APPROVAL_STATUSES = ("approved", "rejected")
@@ -142,8 +144,10 @@ agents = Table(
Column("name", String, nullable=False), # unique within a project, not globally
Column("working_dir", String, nullable=False),
Column("status", String, nullable=False),
# Optional workflow role (junior | senior | deploy) — informational, drives which
# forge skill an agent follows; the approval gate keys on identity, not role.
# Optional workflow role (scout | planner | junior | senior | deploy) —
# informational, drives which role skill an agent follows; the approval gate keys on
# identity, not role. ``scout`` additionally relaxes the Stop test gate on a clean
# tree (a run that shipped nothing has nothing to verify).
Column("role", String),
# Which model backend (claude_models row) this agent runs on; null = the worker's
# logged-in Claude subscription. Recorded at spawn so resumes — a brand-new process —
+33 -3
View File
@@ -9,6 +9,12 @@ captured from the session transcript onto the checkmark, so the dashboard always
real checkpoint to show regardless of whether the agent thought to leave one.
``SessionEnd`` cannot be blocked, so it just records a final checkpoint with the end
reason.
One narrow exemption: a ``scout`` that ends with a clean tree skips the test run and
records ``tests_status = 'skipped'``. Scouts exist to look and hand findings on, so the
gate's promise — ``done`` means a test run passed for the work that shipped — is vacuous
when nothing shipped, and a scheduled watch is mostly quiet runs. A dirty tree, or any
other role, keeps the full gate.
"""
from __future__ import annotations
@@ -112,6 +118,19 @@ def _completion_blockers(working_dir: str) -> list[str]:
return blockers
def _tests_are_moot(ident: Identity, working_dir: str) -> bool:
"""True when this stop needs no test run: a ``scout`` leaving a clean tree.
Scouts read the world and hand findings on; the expensive half of the gate exists to
stop unverified *changes* reaching ``done``. Running the suite on every quiet watch
run is pure overhead, and on a scheduled watch there are a lot of quiet runs. Any
other role, or any uncommitted change, keeps the full gate.
"""
if ident.role != "scout":
return False
return gitops.head_sha(working_dir) is None or gitops.is_clean(working_dir)
def _final_assistant_text(transcript_path: str | None) -> str | None:
"""The agent's last assistant message from the session transcript.
@@ -155,15 +174,26 @@ def handle_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dic
if ident.mise_init:
return handle_mise_init_stop(conn, ident, hook_input)
working_dir = ident.working_dir or hook_input.cwd or "."
tests_ok, output = verify.run_test(working_dir)
if _tests_are_moot(ident, working_dir):
# A scout that touched nothing has nothing to verify: the gate's promise is
# "``done`` means a test run passed for the work that shipped", and no work
# shipped. The clean-tree condition is what keeps that honest — a scout that
# *did* edit files falls through to the normal gate like anyone else.
tests_ok, output, tests_status = True, "", "skipped"
else:
tests_ok, output = verify.run_test(working_dir)
tests_status = "pass" if tests_ok else "fail"
blockers = [] if tests_ok else ["the test suite is failing (`mise run test`)"]
blockers += _completion_blockers(working_dir)
now = datetime.now(UTC)
status = "done" if not blockers else "blocked"
tests_status = "pass" if tests_ok else "fail"
summary = (
"checkpoint: tests passed, work committed and pushed"
(
"checkpoint: nothing to ship, tests not applicable"
if tests_status == "skipped"
else "checkpoint: tests passed, work committed and pushed"
)
if not blockers
else "checkpoint blocked: " + "; ".join(blockers)
)
+19 -2
View File
@@ -77,6 +77,9 @@ class Identity:
# git-push hooks enforce the "write .mise.toml, commit, push" contract rather than the
# normal test gate.
mise_init: bool = False
# Workflow role (env ``HANDLER_AGENT_ROLE``, falling back to the agent row). The
# Stop gate reads it: a ``scout`` that changed nothing has no work to verify.
role: str | None = None
extra: dict = field(default_factory=dict)
@@ -93,10 +96,18 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None
agent_name = os.environ.get("HANDLER_AGENT_NAME")
mise_init = bool(os.environ.get("HANDLER_MISE_INIT"))
role = os.environ.get("HANDLER_AGENT_ROLE") or None
if agent_id and project_id and agent_name:
row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first()
working_dir = row._mapping["working_dir"] if row else None
return Identity(int(agent_id), project_id, agent_name, working_dir, mise_init=mise_init)
return Identity(
int(agent_id),
project_id,
agent_name,
working_dir,
mise_init=mise_init,
role=role or (row._mapping["role"] if row else None),
)
# Fallback: match by working_dir == cwd.
if hook_input.cwd:
@@ -105,7 +116,13 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None
).first()
if row is not None:
m = row._mapping
return Identity(m["id"], m["project_id"], m["name"], m["working_dir"])
return Identity(
m["id"],
m["project_id"],
m["name"],
m["working_dir"],
role=role or m["role"],
)
return None
+170 -8
View File
@@ -9,14 +9,16 @@ It talks straight to the database — memory is rows, workers stay stateless.
Tools: ``memory_search`` (substring search over the agent's project + global notes;
empty query = most recent), ``memory_get`` (one note with its links), ``memory_save``
(create, or update with ``note_id``), ``memory_link`` (connect two notes, idempotent).
(create, or update with ``note_id``), ``memory_link`` (connect two notes, idempotent),
``dispatch_agent`` (hand work to a fresh agent in this project the seam that lets a
pipeline advance on a *result* instead of on a timer).
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
PROTOCOL_VERSION = "2025-06-18"
@@ -24,6 +26,10 @@ SERVER_INFO = {"name": "handler-memory", "version": "0.1.0"}
_NOTE_KINDS = ["fact", "decision", "gotcha", "runbook"]
# Roles a dispatch may target. Mirrors ``api.schemas.Role``; duplicated as plain data
# because this module is deliberately import-light (it runs as its own subprocess).
_ROLES = ["scout", "planner", "junior", "senior", "deploy"]
TOOLS: list[dict] = [
{
"name": "memory_search",
@@ -94,6 +100,49 @@ TOOLS: list[dict] = [
"required": ["src_note_id", "dst_note_id"],
},
},
{
"name": "dispatch_agent",
"description": (
"Hand work to a NEW agent in this project, which starts as soon as a worker "
"is free. Use this when your run produced something concrete for a different "
"role to act on — findings that deserve a spec, a spec ready to implement. "
"This is a handoff, not a fan-out: dispatch once per thing you found, and "
"only when there is real work. Finding nothing is a complete, successful "
"run — say so and end your turn instead of dispatching. The new agent starts "
"with NO memory of this session, so 'task' must stand on its own: state what "
"to do, name the files or sources, and include anything it would otherwise "
"have to rediscover."
),
"inputSchema": {
"type": "object",
"properties": {
"name_prefix": {
"type": "string",
"description": "Short slug for the new agent; a timestamp is appended",
},
"task": {
"type": "string",
"description": "The new agent's whole prompt — self-contained, no context",
},
"reason": {
"type": "string",
"description": "Why this handoff is warranted; recorded for the operator",
},
"role": {
"type": "string",
"enum": _ROLES,
"description": "Role skill the new agent runs under",
},
"model_id": {
"type": "integer",
"description": "Model backend to pin the new agent to",
},
"worktree": {"type": "string", "description": "Branch for a per-run worktree"},
"subdir": {"type": "string", "description": "Subdir under the project root"},
},
"required": ["name_prefix", "task", "reason"],
},
},
]
@@ -112,9 +161,14 @@ class MemoryServer:
"""Tool dispatch against the handler database. One short connection per call —
the process lives as long as the agent's session, but holds nothing in memory."""
def __init__(self, agent_id: int | None, project_id: str | None):
def __init__(
self, agent_id: int | None, project_id: str | None, dispatch_depth: int = 0
):
self.agent_id = agent_id
self.project_id = project_id
# How many dispatches deep this agent already is (0 = started by the operator or
# a schedule). Set from ``HANDLER_DISPATCH_DEPTH``, which spawn injects.
self.dispatch_depth = dispatch_depth
def _connection(self):
from ..db.engine import connection
@@ -219,12 +273,105 @@ class MemoryServer:
)
return {"link": {k: _iso(v) for k, v in link.items()}}
# ---- dispatch ----
def _name_taken(self, conn, name: str) -> bool:
"""True if an agent (or an unfinished spawn) already claims this name.
Agent names are unique per project and the timestamp suffix has one-second
resolution, so two dispatches in the same second would otherwise collide the
second one only failing later, asynchronously, as a failed command.
"""
from ..db import repository as repo
if repo.get_agent_by_name(conn, self.project_id, name) is not None:
return True
recent = repo.list_commands(conn, project_id=self.project_id, limit=200)
return any(
c.get("type") == "spawn"
and c.get("agent_name") == name
and c.get("status") in ("queued", "running")
for c in recent
)
def dispatch_agent(self, args: dict) -> dict:
from ..config import get_settings
from ..db import repository as repo
if self.agent_id is None or not self.project_id:
raise ValueError(
"dispatch_agent needs an agent identity, and this process has none"
)
prefix = (args.get("name_prefix") or "").strip().strip("-")
task = (args.get("task") or "").strip()
reason = (args.get("reason") or "").strip()
if not prefix or not task or not reason:
raise ValueError("name_prefix, task and reason are all required")
role = args.get("role")
if role is not None and role not in _ROLES:
raise ValueError(f"role must be one of {_ROLES}")
settings = get_settings()
# Depth is counted from the agent that started the chain, so a cycle
# (A dispatches B dispatches C dispatches A) runs out of budget instead of
# fanning out forever.
depth = self.dispatch_depth + 1
if depth > settings.max_dispatch_depth:
raise ValueError(
f"dispatch refused: this chain is already {self.dispatch_depth} handoffs "
f"deep (limit {settings.max_dispatch_depth}). Finish the work in this "
"run, or leave it for the operator."
)
with self._connection() as conn:
run = repo.get_latest_run(conn, self.agent_id)
spent = repo.count_agent_dispatches(
conn, self.agent_id, run["started_at"] if run else None
)
if spent >= settings.max_dispatch_per_run:
raise ValueError(
f"dispatch refused: {spent} already dispatched in this run (limit "
f"{settings.max_dispatch_per_run}). Fold the rest into one handoff."
)
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
candidates = [f"{prefix}-{stamp}"] + [f"{prefix}-{stamp}-{n}" for n in range(2, 12)]
name = next((c for c in candidates if not self._name_taken(conn, c)), None)
if name is None:
raise ValueError(f"no free agent name for prefix '{prefix}' — try another")
payload: dict = {
"task": task,
"reason": reason,
"dispatch_depth": depth,
"parent_agent_id": self.agent_id,
}
for key in ("role", "model_id", "worktree", "subdir"):
if args.get(key):
payload[key] = args[key]
# project_id comes from the environment, never from the arguments: an agent
# can only ever dispatch inside its own project.
command = repo.enqueue_command(
conn,
"spawn",
project_id=self.project_id,
agent_name=name,
payload=payload,
requested_by=f"agent:{self.agent_id}",
)
return {
"dispatched": True,
"command_id": command["id"],
"agent_name": name,
"project_id": self.project_id,
"dispatch_depth": depth,
}
def call_tool(self, name: str, args: dict) -> dict:
handlers = {
"memory_search": self.memory_search,
"memory_get": self.memory_get,
"memory_save": self.memory_save,
"memory_link": self.memory_link,
"dispatch_agent": self.dispatch_agent,
}
if name not in handlers:
raise ValueError(f"unknown tool '{name}'")
@@ -277,17 +424,32 @@ def _error(msg_id: Any, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
def serve(stdin=None, stdout=None) -> int:
"""The stdio loop: one JSON-RPC message per line, responses flushed immediately."""
def server_from_env() -> MemoryServer:
"""Build the server from the spawn environment — the one identity contract.
Shared by the stdio loop and the ``--call`` seam (the pi bridge) so both agree on
who the agent is, which project it may touch, and how deep its dispatch chain runs.
"""
import os
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
agent_id_raw = os.environ.get("HANDLER_AGENT_ID")
server = MemoryServer(
depth_raw = os.environ.get("HANDLER_DISPATCH_DEPTH") or "0"
try:
depth = max(0, int(depth_raw))
except ValueError:
depth = 0
return MemoryServer(
agent_id=int(agent_id_raw) if agent_id_raw else None,
project_id=os.environ.get("HANDLER_PROJECT_ID") or None,
dispatch_depth=depth,
)
def serve(stdin=None, stdout=None) -> int:
"""The stdio loop: one JSON-RPC message per line, responses flushed immediately."""
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
server = server_from_env()
for line in stdin:
line = line.strip()
if not line:
+3 -8
View File
@@ -1,6 +1,6 @@
"""``python -m handler.mcpserver`` — run the bundled handler-memory MCP server.
``--call <tool>`` runs one memory tool directly instead: JSON arguments on stdin, JSON
``--call <tool>`` runs one tool directly instead: JSON arguments on stdin, JSON
result on stdout, exit 1 with the error on stderr for a failed call. This is the seam
the pi harness's bridge extension uses (pi has no MCP by design), sharing the exact
tool implementations and the same identity-from-environment contract the MCP
@@ -10,10 +10,9 @@ server dispatches to.
from __future__ import annotations
import json
import os
import sys
from . import MemoryServer, serve
from . import serve, server_from_env
def call_tool(name: str, stdin=None, stdout=None) -> int:
@@ -25,11 +24,7 @@ def call_tool(name: str, stdin=None, stdout=None) -> int:
except ValueError:
print("invalid JSON arguments on stdin", file=sys.stderr)
return 2
agent_id_raw = os.environ.get("HANDLER_AGENT_ID")
server = MemoryServer(
agent_id=int(agent_id_raw) if agent_id_raw else None,
project_id=os.environ.get("HANDLER_PROJECT_ID") or None,
)
server = server_from_env()
try:
payload = server.call_tool(name, args if isinstance(args, dict) else {})
except Exception as exc: # noqa: BLE001 - the caller renders this as a tool error
@@ -0,0 +1,52 @@
"""checkmark gates gain a 'skipped' verdict
Revision ID: 0017_gate_skipped
Revises: 0016_user_accounts
Create Date: 2026-08-19
The Stop gate can now decline to run the suite when there is provably nothing to
verify a ``scout`` role ending on a clean tree, whose whole job is to look and hand
findings on. That verdict is not ``unknown`` (we didn't look) and certainly not
``pass`` (nothing ran), so the checkmark needs a third word for it. Widening the CHECK
is additive: every existing row already holds one of the three old values.
Both gate columns move together they share one vocabulary, and a future gate that
can be moot for the same reason should not need another migration.
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0017_gate_skipped"
down_revision: str | None = "0016_user_accounts"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
OLD_GATE_STATUSES = "'pass', 'fail', 'unknown'"
NEW_GATE_STATUSES = OLD_GATE_STATUSES + ", 'skipped'"
# (constraint name, column) — batch_alter_table so SQLite recreates the table while
# Postgres alters in place, the same shape migration 0004 used to widen command types.
_GATES = (("ck_checkmarks_tests", "tests_status"), ("ck_checkmarks_build", "build_status"))
def _rewrite(values: str) -> None:
with op.batch_alter_table("checkmarks", schema=None) as batch_op:
for name, column in _GATES:
batch_op.drop_constraint(name, type_="check")
batch_op.create_check_constraint(name, f"{column} IN ({values})")
def upgrade() -> None:
_rewrite(NEW_GATE_STATUSES)
def downgrade() -> None:
# Any row parked on the new verdict has to land somewhere the old CHECK accepts;
# 'unknown' is the honest reading of "no verdict was recorded".
for _, column in _GATES:
op.execute(f"UPDATE checkmarks SET {column} = 'unknown' WHERE {column} = 'skipped'")
_rewrite(OLD_GATE_STATUSES)
+235
View File
@@ -0,0 +1,235 @@
"""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
+45 -3
View File
@@ -2,16 +2,18 @@
from __future__ import annotations
import pytest
from handler.control import gitops, mise
from handler.db import repository as repo
from handler.hooks import checkpoint, verify
from handler.hooks.context import HookInput, Identity
def _seed(conn, mise_init=False):
def _seed(conn, mise_init=False, role=None):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init)
a = repo.create_agent(conn, "p", "a", "/tmp/p/a", role=role)
return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init, role=role)
def _fake_mise_state(monkeypatch, *, has_test, clean, ahead):
@@ -118,6 +120,46 @@ def test_stop_allows_done_when_working_dir_is_not_a_repo(conn, monkeypatch):
assert repo.get_checkmark(conn, ident.agent_id)["status"] == "done"
def test_stop_skips_tests_for_a_scout_that_shipped_nothing(conn, monkeypatch):
"""A watch run that found nothing has no work to verify — and most runs are that."""
ident = _seed(conn, role="scout")
monkeypatch.setattr(
verify, "run_test", lambda cwd: pytest.fail("the suite must not run here")
)
_fake_git_state(monkeypatch, clean=True)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result == {}
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "skipped"
assert cm["status"] == "done"
def test_stop_still_gates_a_scout_that_changed_files(conn, monkeypatch):
"""The exemption is clean-tree-only: a scout that edited something is gated."""
ident = _seed(conn, role="scout")
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
_fake_git_state(monkeypatch, clean=False)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "test suite is failing" in result["reason"]
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
def test_stop_gates_other_roles_on_a_clean_tree(conn, monkeypatch):
"""Only scouts are exempt — a junior with nothing to commit still runs the suite."""
ident = _seed(conn, role="junior")
ran = []
monkeypatch.setattr(verify, "run_test", lambda cwd: (ran.append(cwd), (True, "ok"))[1])
_fake_git_state(monkeypatch, clean=True)
assert checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop")) == {}
assert ran, "the suite must run for a non-scout role"
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "pass"
def test_stop_captures_final_message_as_checkpoint(conn, monkeypatch, tmp_path):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
+3 -1
View File
@@ -181,8 +181,10 @@ def test_mcp_protocol_basics(seeded):
assert handle_message(server, {"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
assert _rpc(server, "resources/list")["error"]["code"] == -32601
tools = _rpc(server, "tools/list")["result"]["tools"]
# The bundled server's whole surface: the memory tools plus dispatch (tested in
# tests/test_dispatch.py), which shares this transport and identity contract.
assert {t["name"] for t in tools} == {
"memory_search", "memory_get", "memory_save", "memory_link",
"memory_search", "memory_get", "memory_save", "memory_link", "dispatch_agent",
}
+2
View File
@@ -111,6 +111,8 @@ def test_write_config_renders_provider_and_bridge(pi_env, tmp_path):
assert "setActiveTools" in text
assert "handler.webtool" in text
assert "web_search" in text and "web_fetch" in text
# Dispatch parity: a pi agent can hand work on through the same MCP seam.
assert "dispatch_agent" in text
assert (base / "APPEND_SYSTEM.md").read_text().strip()