// dispatch-pairing.ts — the DECISION-time guard against agent/task-kind // mismatches (KEY: AUTOPILOT-92). pi-subagents' completion guard refuses a // read-only agent handed an implementation task AT SPAWN, naming neither the // item nor the lane: observed on the AUTO-REVIEW dispatch for ACTUATOR-51, // refused because reviewTask() embedded the item's implementation-shaped scope // verbatim (`...must apply...` reads as implementation intent to the runtime's // classifier). A failed workflow with NO partial work then looked like // infrastructure noise, and the item silently never ran. // // This module applies the SAME judgement at the DECISION, before any spawn: // the item key, the lane, the agent and the task kind are all named, and // EXACTLY ONE countable `dispatch.agent-task-mismatch` telemetry line is // emitted per mismatch. The check keys on TASK KIND — never a blanket // "reviewers may not implement" heuristic that would misfire on a legitimate // review of implementation-shaped scope text. // // The two rules (AUTOPILOT-92): // 1. worker work requires a NON-read-only agent — an implementation task // must never be paired with a read-only agent (the runtime refuses it). // 2. a review requires a review task — a review lane's task must be // recognisable as a review (reviewTask declares itself one). // // The agent comes from the registry, resolved by name (agents/install.ts) — // NOT re-derived here and NOT a second agent-resolution path. import { appendTelemetry } from "../config.ts"; import type { ResolvedAgent } from "../agents/install.ts"; /** The lanes the framework DECIDES its dispatches in (the harness automations * plus the recon pass). Each lane names itself so a mismatch is attributable * in telemetry rather than a single opaque failed run. */ export type DispatchLane = "auto-dispatch" | "auto-redispatch" | "spec-pass" | "auto-review"; /** What a task is FOR. Read off the task's own explicit declaration — the * framework's task builders are the only producers at these decision points. * A task that does not declare itself read-only is implementation work, which * is the runtime's own fail-closed default. */ export type TaskKind = "implementation" | "review"; // The exact wording the framework's task builders emit for read-only work: // reviewTask() says "review only" (the phrase the runtime's own classifier // keys on), specPassTask() says "NOT an implementation task". Narrow on // purpose — a review of implementation-shaped scope text must not be // misread as implementation work here. const REVIEW_TASK_MARKER = /\breview only\b|\bnot an implementation task\b/i; /** Classify a dispatch task by the wording its builder emits. */ export function classifyTaskKind(task: string): TaskKind { return REVIEW_TASK_MARKER.test(task) ? "review" : "implementation"; } export interface PairingCheck { stateDir: string; lane: DispatchLane; key: string; /** The registry-resolved agent the lane is about to spawn. */ agent: ResolvedAgent; task: string; /** The lane's intent: a "review" lane requires a review task; a "worker" * lane requires a mutable agent for implementation work. `spec-pass` is a * worker-role lane whose task declares itself read-only, so it passes on a * mutable agent without a mismatch. */ role: "worker" | "review"; } export type PairingResult = | { ok: true } | { ok: false; reason: string; taskKind: TaskKind; agent: string; readOnly: boolean }; /** * The pairing decision. Returns `{ok:true}` when the agent and task may be * spawned together. On a mismatch it emits EXACTLY ONE * `dispatch.agent-task-mismatch {key, lane, agent, taskKind}` telemetry line * and returns the refusal (naming the item key and the reason) for the caller * to act on. Never throws — a telemetry failure must not become a new bug. */ export function validateDispatchPairing(c: PairingCheck): PairingResult { const taskKind = classifyTaskKind(c.task); // RULE 1 — worker work requires a non-read-only agent. if (taskKind === "implementation" && c.agent.readOnly) { return refuse( c, taskKind, `agent '${c.agent.name}' is read-only in the registry but the task is implementation work — a worker task requires a non-read-only agent`, ); } // RULE 2 — a review requires a review task. if (c.role === "review" && taskKind !== "review") { return refuse( c, taskKind, `the '${c.lane}' lane built a '${taskKind}' task — a review requires a task that is recognisable as a review`, ); } return { ok: true }; } function refuse(c: PairingCheck, taskKind: TaskKind, reason: string): PairingResult { appendTelemetry( c.stateDir, JSON.stringify({ t: new Date().toISOString(), type: "dispatch.agent-task-mismatch", key: c.key, lane: c.lane, agent: c.agent.name, taskKind, reason, }), ); return { ok: false, reason: `refused dispatch of '${c.key}' (${c.lane}): ${reason}`, taskKind, agent: c.agent.name, readOnly: c.agent.readOnly, }; }