diff --git a/.env.example b/.env.example index 91524d3..c6c4914 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcdf5e..d46685f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`. 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.** diff --git a/README.md b/README.md index 0bfeefb..bb706e7 100644 --- a/README.md +++ b/README.md @@ -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 `: @@ -475,6 +481,12 @@ Wired into each agent as `python -m handler.hooks `: 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:`. 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/-.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 diff --git a/app/src/screens/SchedulesScreen.tsx b/app/src/screens/SchedulesScreen.tsx index 9a540b5..240eee4 100644 --- a/app/src/screens/SchedulesScreen.tsx +++ b/app/src/screens/SchedulesScreen.tsx @@ -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" }, diff --git a/docs/PLAN.md b/docs/PLAN.md index 46f2bae..c6fce88 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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:`, + 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 diff --git a/frontend/components/sections/AgentsSection.tsx b/frontend/components/sections/AgentsSection.tsx index 960e689..86ae996 100644 --- a/frontend/components/sections/AgentsSection.tsx +++ b/frontend/components/sections/AgentsSection.tsx @@ -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" }, diff --git a/frontend/components/sections/SchedulesSection.tsx b/frontend/components/sections/SchedulesSection.tsx index 4f06cad..e2c3058 100644 --- a/frontend/components/sections/SchedulesSection.tsx +++ b/frontend/components/sections/SchedulesSection.tsx @@ -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" }, diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py index 4493df5..de12b04 100644 --- a/src/handler/api/schemas.py +++ b/src/handler/api/schemas.py @@ -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). diff --git a/src/handler/builtin_skills.py b/src/handler/builtin_skills.py index 916ee50..49d574a 100644 --- a/src/handler/builtin_skills.py +++ b/src/handler/builtin_skills.py @@ -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:`). 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/-.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/.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. """, ), ] diff --git a/src/handler/config.py b/src/handler/config.py index 69aa09f..6882211 100644 --- a/src/handler/config.py +++ b/src/handler/config.py @@ -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 diff --git a/src/handler/control/pi_bridge.ts b/src/handler/control/pi_bridge.ts index 79241ec..1dea14e 100644 --- a/src/handler/control/pi_bridge.ts +++ b/src/handler/control/pi_bridge.ts @@ -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 ` — 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 ` — 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 { +// 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 { 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) { - 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) { + 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 }> = [ { diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index 262571b..5344e71 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -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: diff --git a/src/handler/db/repository.py b/src/handler/db/repository.py index 1876bab..e9d7047 100644 --- a/src/handler/db/repository.py +++ b/src/handler/db/repository.py @@ -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:'``, 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, diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py index e83a464..abb0574 100644 --- a/src/handler/db/tables.py +++ b/src/handler/db/tables.py @@ -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 — diff --git a/src/handler/hooks/checkpoint.py b/src/handler/hooks/checkpoint.py index 7e09620..ef4b22b 100644 --- a/src/handler/hooks/checkpoint.py +++ b/src/handler/hooks/checkpoint.py @@ -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) ) diff --git a/src/handler/hooks/context.py b/src/handler/hooks/context.py index aaa5bc5..2986ca0 100644 --- a/src/handler/hooks/context.py +++ b/src/handler/hooks/context.py @@ -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 diff --git a/src/handler/mcpserver/__init__.py b/src/handler/mcpserver/__init__.py index bf29891..77b205d 100644 --- a/src/handler/mcpserver/__init__.py +++ b/src/handler/mcpserver/__init__.py @@ -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: diff --git a/src/handler/mcpserver/__main__.py b/src/handler/mcpserver/__main__.py index f3834be..eb4ef35 100644 --- a/src/handler/mcpserver/__main__.py +++ b/src/handler/mcpserver/__main__.py @@ -1,6 +1,6 @@ """``python -m handler.mcpserver`` — run the bundled handler-memory MCP server. -``--call `` runs one memory tool directly instead: JSON arguments on stdin, JSON +``--call `` 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 diff --git a/src/handler/migrations/versions/0017_gate_skipped.py b/src/handler/migrations/versions/0017_gate_skipped.py new file mode 100644 index 0000000..ec51113 --- /dev/null +++ b/src/handler/migrations/versions/0017_gate_skipped.py @@ -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) diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py new file mode 100644 index 0000000..18b99fb --- /dev/null +++ b/tests/test_dispatch.py @@ -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 diff --git a/tests/test_hook_checkpoint.py b/tests/test_hook_checkpoint.py index 41e2ac0..b7d9319 100644 --- a/tests/test_hook_checkpoint.py +++ b/tests/test_hook_checkpoint.py @@ -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")) diff --git a/tests/test_memory.py b/tests/test_memory.py index 50c4c72..73a366c 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -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", } diff --git a/tests/test_pi_harness.py b/tests/test_pi_harness.py index 99a96e8..36f7785 100644 --- a/tests/test_pi_harness.py +++ b/tests/test_pi_harness.py @@ -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()