// tools/queue-ops.ts — the SIX queue tools as host-agnostic operations. // Both hosts (the pi extension and the opencode plugin) register these tools; // the logic lives here ONCE (single authoritative implementation — no dead // copies between hosts). Hosts adapt the result shape: pi wraps // { text, details } into its tool-result envelope, opencode returns the text. // // These are deterministic store+backend operations; all LLM judgment stays in // the orchestrator (the tool CALLER), per the framework's trigger-only rule. import type { SubagentBackend } from "../backends/types.ts"; import type { Autopilot } from "../core.ts"; import type { LoadedAutopilotConfig } from "../config.ts"; import { loadStore, newStore, mutateStore, addItem, updateItem, queryItems, queueLengths, resolveSeries, recordSeries, isDispatchClass, isTerminalStatus, unmetDependencies, dependsOnHeader, type DispatchClass, type QueueStore } from "../queue-store.ts"; import { appendOverride, captureFinisherBaseline } from "../finisher-evidence.ts"; import { effectiveOccupiedSlots, isAutoDispatchable, priorAttemptContext } from "../framework/auto-dispatch.ts"; import { git } from "../framework/git.ts"; import { assessRequestedBudget, noBudgetNote, resolveDispatchBudget } from "../framework/run-budget.ts"; import { reviewerRunAlive } from "../framework/run-liveness.ts"; import { REVIEW_CONTRACT_TEXT } from "../review-contract.ts"; import { acceptanceEvidenceStatus } from "../acceptance-evidence.ts"; import { formatDurationMs } from "../duration.ts"; import { preserveRunWorktree, reviewPointersFor, commitResolvesIn } from "../framework/worktree-preservation.ts"; import { specCompleteness, specGapTag } from "../framework/spec-completeness.ts"; import { formatContractRefusal, validateTransitionContract, type ContractGap } from "../framework/item-contract.ts"; import { normalizeModel, normalizeTier, resolveSpawnModel, tierNote } from "../spawn-model.ts"; // Re-exported for backward compatibility — normalizeModel moved to // spawn-model.ts at AUTOPILOT-80 so config.ts can validate the config-wide // model default without importing the tools/ tree (cycle avoidance); this // module (and its existing callers/tests) keeps importing it from here. export { normalizeModel }; export interface ToolResult { text: string; details: Record; } /** Host adapter seam — the pi extension and opencode plugin both satisfy this. */ export interface QueueOpsCtx { stateDir: string; backend: SubagentBackend; storeOrNew(): QueueStore; autopilot(): Autopilot; cfg(): LoadedAutopilotConfig; /** Domain-event sink (orch:reviewer-dispatched, ...) — host-local bus. */ emit(events: Array<{ name: string; data?: Record }>): void; /** Fail-closed repo check for dispatch cwd (git toplevel + dirty tree). */ repoCheck(cwd: string): Promise<{ ok: boolean; reason?: string; files?: number }>; /** The host session's cwd (dispatch falls back to it when no explicit cwd). */ sessionCwd?: string; } function err(e: unknown, op: string): ToolResult { return { text: `${op} failed: ${e instanceof Error ? e.message : String(e)}`, details: {} }; } /** Validate a requested wall-clock budget: a positive finite number, else null. * null = "not requested" — the runtime default budget stays authoritative. */ export function normalizeTimeoutMs(v: unknown): number | null { return typeof v === "number" && Number.isFinite(v) && v >= 1 ? v : null; } /** DEPENDENCY GATE (KEY: AUTOPILOT-81) — `after` accepts an array of keys OR * a comma-separated string (opencode's ArgSpec has no array type), so both * hosts land the exact same shape here, once. undefined = "not supplied" * (leave the recorded value alone); [] clears it. The actual existence/ * self-reference/cycle validation happens at the store write (validateAfter, * queue-store.ts) — this only normalizes the SHAPE. */ export function normalizeAfter(v: unknown): string[] | undefined { if (v === undefined) return undefined; if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean); if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean); return []; } /** queue_list — read path (filter by status / since / sort, compact or notes). */ export async function queueList(ctx: QueueOpsCtx, params: Record): Promise { try { const store = ctx.storeOrNew(); const items = queryItems(store, { status: params.status as never, since: params.since as string | undefined, sort: params.sort as never, limit: params.limit as number | undefined, includeNotes: params.includeNotes as boolean | undefined, }); const counts = queueLengths(store); const backendFleet = await ctx.backend.fleetStatus(); // KEY: AUTOPILOT-61 — occupied is LIVE-computed (store active count union // the fleet's own count), never the engine's run ledger: that ledger leaks // (see effectiveOccupiedSlots) and once inflated never recovers, which // made this reading climb monotonically across a session even against a // verified-idle fleet. const fleet = { occupied: effectiveOccupiedSlots(ctx.stateDir, { backend: ctx.backend, zombieGraceMinutes: ctx.cfg().zombieGraceMinutes, fleetTotalActive: backendFleet?.totalActive ?? null, }), // RUNS, NOT ENTRIES (AUTOPILOT-95): one logical worker spawn is a workflow // WRAPPER + its nested CHILD — two fleet ROWS but ONE run. `runs` is the // run-level count (what the cap and `occupied` use); `entries` is the raw // fleet-row count (wrapper + child = two), kept visible so the two can // never be confused. TotalActive stays run-level for back-compat. runs: backendFleet?.runs ?? backendFleet?.totalActive ?? null, entries: backendFleet?.entries ?? null, totalActive: backendFleet?.totalActive ?? null, maxSlots: ctx.cfg().maxSlots, }; // DEPENDENCY GATE (KEY: AUTOPILOT-81): approved items WAITING on an unmet // `after` — named alongside fleet/counts so a full-looking approved buffer // that is actually stuck on a dependency is visible, not silently idle. const waiting = Object.values(store.items).filter((i) => i.status === "approved" && unmetDependencies(i, store).length > 0).length; return { text: JSON.stringify({ counts, fleet, waiting, items }, null, 2), details: {} }; } catch (e) { return err(e, "queue_list"); } } // The item-specification contract is NOT a bespoke presence check any more // (AUTOPILOT-128): the attributes the framework depends on, per transition, and // the closed vocabularies live in ONE table (framework/item-contract.ts). Every // approval surface (queue_add, queue_update, the decision panel) runs the SAME // validateTransitionContract(`… → approved`) and REFUSES with EVERY gap named. // Intake is deliberately NOT gated — a proposal may lack scope/cwd (spec §01). // See the item-contract module header for the full rationale, including why // value/urgency/evidence are now REQUIRED rather than advisory-only. /** ADVISORY spec-gap note appended to a queue_add/queue_update RESULT (never * persisted, never a gate) whenever the item the write just produced still * has a scope-completeness gap (KEY: AUTOPILOT-58 part A). * * THE GAP THIS CLOSES: specCompleteness's gap list (config.ts: * underSpecifiedProposals) was consumed ONLY by the panels — grep for * specCompleteness/specGapTag in this file, before this change, returned * NOTHING. The agent that just wrote the item — the one party able to fix * it immediately, with context still loaded — was never told. * * STAYS ADVISORY: nothing here changes admission (the item-contract table * decides admission — see item-contract.ts) — spec-completeness.ts's "must * never become a second approval gate" holds for scope content. This only * changes WHO is told and WHEN. * * LOUDER AT APPROVAL (decided: yes): an item that reaches `approved` already * has scope+cwd (the contract enforces both), so only CONTENT gaps * (thin-scope / no-artifact / no-acceptance) can still fire here — but * approval is the last cheap moment before a worker burns an hour on a thin * scope, so the note is phrased sharper than a fresh proposal's. * * RE-REPORTED ON EVERY WRITE (decided: yes): an item that already failed * once and is being re-approved/re-dispatched with the SAME thin scope gets * the note again — re-dispatching a thin scope is the expensive case (a * second wasted run), so silence here would be the wrong optimization. * "Once per write" (the nagging guard from the risk section) means once IN * THIS RESULT, never a persisted flag that suppresses a later write. * * Reuses specGapTag verbatim (closed set, report order) — no re-wording, so * every surface (panel, /autopilot status, this) describes a gap * identically. Points at SCOPE_SKELETON by NAME rather than inlining its six * lines on every gapped write: the caller is the orchestrator agent with * full repo access, and inlining a multi-line template on every write is * exactly the nagging Part A's own risk section warns against. */ function specGapNote(scope: string | null | undefined, cwd: string | null | undefined, status: string): string { const gaps = specCompleteness({ scope, cwd }); if (!gaps.length) return ""; const tag = specGapTag(gaps)!; const loud = status === "approved" ? " — APPROVED ANYWAY (advisory; this is the last cheap moment before a worker runs it)" : ""; return ` SPEC GAP: ${tag}${loud}. Fill via queue_update({key, scope: ...}); template = SCOPE_SKELETON (src/framework/spec-completeness.ts).`; } /** queue_add — new proposal (default) or approved item; free-form notes. */ /** Jira-style sequential allocation: the next free `PREFIX-N` for a series. * N = max existing N + 1. The number is the FIRST number after the prefix * (and an optional separator), so every real convention counts: * `B5-NAME` → 5, `B-4` → 4, `EVAL-EXPT-M9` → 9, `B20-REMAINING` → 20 — the * hand-allocated era produced duplicates ("multiple B-49 items") exactly * because suffixed keys were invisible to eyeball counting. Guaranteed free: * bumps until the key is unclaimed. */ export function nextKeyFor(store: QueueStore, prefix: string): string { const clean = prefix.replace(/[^A-Za-z0-9_-]/g, ""); const re = new RegExp(`^${clean}(?:-|_)?(.+)$`); let max = 0; for (const k of Object.keys(store.items)) { const m = re.exec(k); if (!m) continue; const num = /\d+/.exec(m[1]); if (num) max = Math.max(max, Number(num[0])); } let n = max + 1; while (store.items[`${clean}-${n}`]) n += 1; return `${clean}-${n}`; } export async function queueAdd(ctx: QueueOpsCtx, params: Record): Promise { try { // Key allocation: explicit key (unique, semantic suffixes welcome) OR a // series ("B") → Jira-style auto-allocated B-. Omitting both allocates // under the default series "Q" — keys are load-bearing identifiers // (telemetry, handoffs, steering), so the harness must hand them out // collision-free instead of trusting hand-numbering. let key = (params.key as string | undefined)?.trim(); // Series resolution: explicit `series` is a DELIBERATE choice (new // workstream / re-association). Otherwise the framework derives from the // item's cwd (registry → history → repo-name slug) — the agent's only job // is to not fight it. Without a cwd (proposal stage) there is nothing to // derive from: allocate PROVISIONALLY in the default Q series; the key is // renamed into the repo's real series at the approved transition (where // cwd becomes mandatory). const cwd = (params.cwd as string | null) ?? null; const explicitSeries = ((params.series as string | undefined) ?? "").replace(/[^A-Za-z0-9_-]/g, "").toUpperCase(); // Provisional = we had NEITHER an explicit series NOR a cwd to derive one // from — the Q handle is a placeholder that the approved transition renames. const provisional = !key && !explicitSeries && !cwd; const series = explicitSeries || (cwd ? resolveSeries(ctx.stateDir, cwd) : "Q"); const status: "approved" | "proposal" = params.status === "approved" ? "approved" : "proposal"; const scope = (params.scope as string) ?? ""; const evidence = (params.evidence as string) ?? ""; const value = (params.value as string) ?? ""; const urgency = (params.urgency as string) ?? ""; const risk = (params.risk as string) ?? ""; // THE ITEM-SPECIFICATION CONTRACT (AUTOPILOT-128): an item created ALREADY // approved is validated against the `proposal → approved` requirements from // the ONE table. A plain proposal is NOT gated — intake must be able to // propose before it is scoped (spec §01). EVERY gap is named in the refusal. if (status === "approved") { const gaps = validateTransitionContract({ scope, cwd, evidence, value, urgency, risk }, "proposal", "approved"); if (gaps.length) return { text: formatContractRefusal("queue_add", "proposal", "approved", gaps), details: { contractGaps: gaps } }; } // WORKER MODEL OVERRIDE (AUTOPILOT-79): validated when supplied; omitted = // no field on the item = every lane spawns exactly as before (inherit). let model: string | undefined; if (params.model !== undefined) { const m = normalizeModel(params.model); if (!m.ok) return { text: `queue_add: ${m.reason}`, details: {} }; model = m.model; } // CAPABILITY LEVEL (AUTOPILOT-110): a level NAME, shape-validated here and // resolved through the config's `models` map at DISPATCH time (the map may // not exist yet / may change; an unknown level fails loudly at resolution). let tier: string | undefined; if (params.tier !== undefined) { const t = normalizeTier(params.tier); if (!t.ok) return { text: `queue_add: ${t.reason}`, details: {} }; tier = t.tier; } // DEPENDENCY GATE (KEY: AUTOPILOT-81) — shape-normalized here (array or // comma-separated string); the real existence/self-ref/cycle validation // happens at the write (validateAfter). const afterParam = normalizeAfter(params.after); // ALLOCATE AND INSERT IN ONE MUTATION. nextKeyFor computes max+1 from the // store it can see, so allocating in a separate read-modify-write cycle is // how the same key gets handed out twice (observed live: AUTOPILOT-28). // Inside mutateStore the store it sees IS the store the insert lands in. const explicitKey = key; const outcome = mutateStore(ctx.stateDir, (store) => { const allocated = explicitKey || nextKeyFor(store, series); if (store.items[allocated]) return { taken: true, key: allocated }; addItem(store, { key: allocated, status, blocker: null, title: params.title as string, scope, cwd, evidence, value, urgency, risk, runId: null, reviewerRunId: null, specPassRunId: null, timeoutMs: normalizeTimeoutMs(params.timeoutMs), ...(model ? { model } : {}), ...(tier ? { tier } : {}), attempts: 0, notes: (params.notes as string) ?? "", ...(provisional ? { provisionalKey: true } : {}), // DEPENDENCY GATE (KEY: AUTOPILOT-81) — validated inside addItem // (validateAfter): unknown key / self-reference / cycle throw, and // the throw propagates out of mutateStore — nothing is added. ...(afterParam !== undefined ? { after: afterParam } : {}), }); return { taken: false, key: allocated }; }); if (outcome.taken) return { text: `queue_add: key '${outcome.key}' already exists — use queue_update, or omit key to auto-allocate the next number in a series`, details: {} }; key = outcome.key; if (cwd) recordSeries(ctx.stateDir, cwd, series); // NO FALSE SUCCESS: the receipt is only issued for an item that is ACTUALLY // in the persisted store. mutateStore already aborts loudly on a conflict; // this re-read is the belt on those braces, because "added ''" for an // item that never existed is what made the live loss invisible. if (!loadStore(ctx.stateDir)?.items[key]) { return { text: `queue_add: '${key}' did NOT survive the write (concurrent writer) — NOT added, retry`, details: {} }; } // Provisional keys are ALWAYS called out in the tool text: a repo-less // proposal gets a Q- handle by necessity (nothing to derive a series // from), but the caller must know it is temporary — approval with a cwd // RENAMES it into the repo's real series (registry → history → slug). const provisionalNote = provisional ? ` — NOTE: '${key}' is a PROVISIONAL handle (no cwd — repo-less proposal). At approval, where cwd becomes mandatory, the key is RENAMED into the repo's real series (registry → history → slug). Pass cwd (git rev-parse --show-toplevel of the target repo) to queue_add to get a stable real-series key now.` : ""; // BUDGET VISIBILITY (AUTOPILOT-53): only relevant for a dispatchable item // (proposal/blocked items are not spawned yet, so naming a default here // would be a promise about a dispatch that may never happen). An explicit // timeoutMs IS honoured end to end — there is nothing to warn about there. const addBudgetNote = status === "approved" ? noBudgetNote(assessRequestedBudget(normalizeTimeoutMs(params.timeoutMs)), formatDurationMs) : ""; // SPEC-GAP SIGNAL TO THE CALLER (KEY: AUTOPILOT-58 part A) — the scope- // CONTENT advisory only; the approval contract above already REFUSED any // missing required field, so a gapped item that reached here is fully // specified (the tag is about scope quality, never admission). const gapNote = specGapNote(scope, cwd, status); return { text: `added '${key}' (${status})${provisionalNote}${addBudgetNote}${gapNote}`, details: {} }; } catch (e) { return err(e, "queue_add"); } } /** PROVISIONAL-KEY RENAME — the ONE implementation, shared by queue_update * (approval via tool) and the decision panel (approve in the UI). Call when * the item is ALREADY approved in `store` and carries a cwd. Resolves the * real series EXCLUDING the item's own vote (a freshly-specified provisional * Q handle would otherwise win updatedAt ties against its repo's history), * records cwd → series for future adds, renames the key when its series * differs, and drops the provisional marker. Returns the new key, or null * when the key already sits in the right series (marker cleared in place). * The caller persists the store. */ export function renameProvisionalKey(stateDir: string, store: QueueStore, key: string): string | null { const updated = store.items[key]; if (!updated || updated.provisionalKey !== true || !updated.cwd) return null; const realSeries = resolveSeries(stateDir, updated.cwd, { excludeKey: key }); recordSeries(stateDir, updated.cwd, realSeries); const currentSeries = /^([A-Za-z0-9_-]+?)-\d+/.exec(updated.key)?.[1] ?? ""; if (currentSeries === realSeries) { updated.provisionalKey = undefined; // already in the real series — the marker is just dropped return null; } const newKey = nextKeyFor(store, realSeries); const renameNote = `(renamed from ${updated.key} at approval — provisional handle → real series ${realSeries})`; store.items[newKey] = { ...updated, key: newKey, provisionalKey: undefined, notes: updated.notes ? `${updated.notes}\n\n${renameNote}` : renameNote, }; delete store.items[updated.key]; return newKey; } /** queue_update — validated transitions, blocker, free-form fields. */ export async function queueUpdate(ctx: QueueOpsCtx, params: Record): Promise { try { const key = params.key as string; // WORKER MODEL OVERRIDE (AUTOPILOT-79): validated BEFORE the mutation so a // bad value writes nothing; an absent param leaves the recorded model alone. let model: string | undefined; if (params.model !== undefined) { const m = normalizeModel(params.model); if (!m.ok) return { text: `queue_update: ${m.reason}`, details: {} }; model = m.model; } // CAPABILITY LEVEL (AUTOPILOT-110): shape-validated before the write; // resolved (and an unknown level refused LOUDLY) at dispatch time. let tier: string | undefined; if (params.tier !== undefined) { const t = normalizeTier(params.tier); if (!t.ok) return { text: `queue_update: ${t.reason}`, details: {} }; tier = t.tier; } // DEPENDENCY GATE (KEY: AUTOPILOT-81) — shape-normalized here (array or // comma-separated string); the real existence/self-ref/cycle validation // happens at the write (validateAfter). const afterParam = normalizeAfter(params.after); // The read (existence + approval gate), the patch, and the provisional-key // rename are ONE mutation: validating against a store snapshot and writing // a different one is the read-modify-write window this fix closes. const outcome = mutateStore(ctx.stateDir, (store) => { const cur = store.items[key]; if (!cur) return { kind: "missing" as const, renamedTo: null }; const nextStatus = (params.status as string | undefined) ?? cur.status; const nextScope = (params.scope as string | undefined) ?? cur.scope; const nextCwd = (params.cwd as string | null | undefined) ?? cur.cwd; const nextRisk = (params.risk as string | undefined) ?? cur.risk; const nextEvidence = (params.evidence as string | undefined) ?? cur.evidence; const nextValue = (params.value as string | undefined) ?? cur.value; const nextUrgency = (params.urgency as string | undefined) ?? cur.urgency; // FINISHER-SOURCE ON A DONE ITEM (AUTOPILOT-85): the shipping lane's tip // resolution accepts `finisherSource` from ANY item now, specifically so // the human/orchestrator can supply the ship tip on a `done` item that the // preservation journal missed — WITHOUT re-opening it. That only helps if // the value is real: an unvalidated typo would silently make the NEXT // sweep's tip resolution fail exactly the same way, just for a different // reason. Validated ONLY on a `done` item (the shape this exists for) — a // finisher-class dispatch's OWN finisherSource (set at spawn time, for a // non-done item) is unaffected, matching its existing, already-tolerant // resolution in captureFinisherBaseline. const finisherSourceInput = typeof params.finisherSource === "string" ? params.finisherSource.trim() : undefined; if ( finisherSourceInput && nextStatus === "done" && (!nextCwd || !commitResolvesIn(nextCwd, finisherSourceInput)) ) { return { kind: "invalid-finisher-source" as const, renamedTo: null, finisherSourceInput, cwd: nextCwd ?? null, }; } // HUMAN RE-DISPATCH THROUGH THE TOOL SURFACE (KEY: AUTOPILOT-69): the // orchestrator (the tool CALLER) has no host panel to click — its only // route to "human-review → active with findings" is this tool call. A // bare status flip here used to leave the item `active` with NO runId // and no findings marker: queue_dispatch refuses `active` items (only // approved/ai-review/failed), so NOTHING could ever spawn it — the // review tick's own advised path ("queue_update status: active to // re-dispatch with findings") was broken as written. Mirror panels.ts's // "redispatch" action exactly instead of inventing a second mechanism: // REQUIRE findings, and set `pendingRedispatchFindings` in the SAME // write — the harness's already-landed sweep // (dispatchPendingHumanRedispatch, AUTOPILOT-59) reads that marker // regardless of which producer wrote it (panel or this tool) and spawns // the redo on its next pass. queue_dispatch is never called for this path. const humanRedispatch = cur.status === "human-review" && nextStatus === "active"; const findings = typeof params.findings === "string" ? params.findings.trim() : ""; if (humanRedispatch && !findings) return { kind: "needs-findings" as const, renamedTo: null }; // THE ITEM-SPECIFICATION CONTRACT (AUTOPILOT-128): whenever this write // leaves the item APPROVED it must satisfy the `→ approved` requirements — // a fresh proposal→approved, a release, a re-open, OR a re-statement of an // already-approved item (so clearing risk/value/... can never slip // through). A write that does not end at `approved` is never gated here. if (nextStatus === "approved") { const gaps = validateTransitionContract( { scope: nextScope, cwd: nextCwd, evidence: nextEvidence, value: nextValue, urgency: nextUrgency, risk: nextRisk }, cur.status, "approved", ); if (gaps.length) return { kind: "contract" as const, renamedTo: null, gaps, from: cur.status }; } // An approval may complete a PROVISIONAL key: run the shared rename AFTER // the patch (resolveSeries excludes the item's own vote, so the rename // cannot resolve to its own provisional Q). const renamingProvisional = cur.provisionalKey === true && nextStatus === "approved" && !!nextCwd; // DEPENDENCY GATE (KEY: AUTOPILOT-81) — dependents named ONCE, in this // response, when this write REJECTS an item other items depend on // (`after`): they stay WAITING, never auto-rejected. const dependentsWaiting = nextStatus === "rejected" && cur.status !== "rejected" ? Object.values(store.items) // a dependent that is itself terminal (done/rejected/no-change) is // CLOSED, not waiting — the shared closed set keeps this exhaustive .filter((d) => d.key !== key && (d.after ?? []).includes(key) && !isTerminalStatus(d.status)) .map((d) => d.key) : []; // FAILURE-OVERRIDE RECORD (AUTOPILOT-34): overriding a run's failure // verdict used to be hand-written prose in `notes`, so a PATTERN of // overrides was invisible. `overrideReason` records it on the item as // structured, append-only history (and still writes the human line). const overrideReason = typeof params.overrideReason === "string" ? params.overrideReason.trim() : ""; const override = overrideReason ? { at: new Date().toISOString(), by: "orchestrator" as const, runId: cur.runId, reason: overrideReason, evidence: cur.landedEvidence ?? null, } : null; const overrideNote = override ? `[override] ${override.at} — the run's failure verdict was OVERRIDDEN by the orchestrator: ${override.reason}` : ""; const patchedNotes = params.notes as string | undefined; // NOTES-IS-A-DEAD-LETTER-FOR-RE-DISPATCH (KEY: AUTOPILOT-60 part B): a // re-dispatch task is built from `scope` alone (workerTask/recoveryTask) // — `notes` is NEVER read by auto-recovery, a review-FAIL re-dispatch, or // a fresh auto-dispatch. `notes` stays a pure audit trail on purpose (it // already carries structured recovery/override history — appendNote in // auto-recovery.ts — that must NOT be replayed back at a worker as an // instruction). Observed live (AUTOPILOT-42, AUTOPILOT-43): a corrected, // narrowed re-dispatch brief written into `notes` on a `failed` item was // silently ignored twice by auto-recovery, which re-ran the ORIGINAL // `scope` — the only lever that reliably worked was overwriting `scope` // directly. This flags the exact shape of that mistake AS IT HAPPENS — // notes written, scope untouched, item already `failed` (the state where // auto-recovery is the very next consumer) — instead of the operator // discovering it by hand, twice, a run later. const notesDeadLetterOnFailed = typeof patchedNotes === "string" && !override && params.scope === undefined && cur.status === "failed" && nextStatus === "failed"; updateItem(store, key, { status: params.status as never, blocker: params.blocker as never, title: params.title as string | undefined, scope: params.scope as string | undefined, cwd: params.cwd as string | null | undefined, // COMPLETED-DELIVERABLE EVIDENCE (KEY: AUTOPILOT-93): `completedRun` // records the identity of the run that COMPLETED this item's deliverable // onto `runId` — the exact shape the normal completion path leaves // (active → ai-review keeps `runId`; only done/human-review/failed clear // it). It is the evidence the `blocked → ai-review` transition requires // (completedDeliverableEvidence) and it rides in the SAME write, so a // finished-but-parked item reaches review without ever transiting the // auto-dispatchable `approved` state. An absent param leaves `runId` // alone; an explicit `status` still governs whether runId is cleared. ...(typeof params.completedRun === "string" && params.completedRun.trim() ? { runId: params.completedRun.trim() } : {}), evidence: params.evidence as string | undefined, value: params.value as string | undefined, urgency: params.urgency as string | undefined, risk: params.risk as string | undefined, // Explicit param wins; absent param leaves the recorded budget alone. ...(params.timeoutMs !== undefined ? { timeoutMs: normalizeTimeoutMs(params.timeoutMs) } : {}), ...(model ? { model } : {}), ...(tier ? { tier } : {}), ...(isDispatchClass(params.dispatchClass) ? { dispatchClass: params.dispatchClass } : {}), // WHAT a finisher lands (branch/tag/sha) is item state, not per-run // state: the harness lanes re-dispatch without dispatch params and // read it from here. ...(typeof params.finisherSource === "string" ? { finisherSource: params.finisherSource.trim() || null } : {}), // DEPENDENCY GATE (KEY: AUTOPILOT-81) — validated inside updateItem // (validateAfter): unknown key / self-reference / cycle throw, and the // throw propagates out of mutateStore — nothing is written. ...(afterParam !== undefined ? { after: afterParam } : {}), ...(override ? { overrides: appendOverride(cur, override) } : {}), notes: override ? [patchedNotes ?? cur.notes, overrideNote].filter(Boolean).join("\n\n") : humanRedispatch ? [patchedNotes ?? cur.notes, `[human re-dispatch findings] ${findings}`].filter(Boolean).join("\n\n") : patchedNotes, // Structured handoff for dispatchPendingHumanRedispatch — see the // humanRedispatch comment above. Explicit here so updateItem's own // active-entry default (null, for every OTHER path into active) never // clobbers it in this same write. ...(humanRedispatch ? { pendingRedispatchFindings: findings } : {}), }); return { kind: "updated" as const, renamedTo: renamingProvisional ? renameProvisionalKey(ctx.stateDir, store, key) : null, // the provisional-key rename above may have moved the item to a new key overrides: override ? (store.items[key]?.overrides?.length ?? 1) : 0, notesDeadLetterOnFailed, humanRedispatch, dependentsWaiting, // SPEC-GAP + APPROVAL-GAP SIGNAL (KEY: AUTOPILOT-58): the fields this // write actually landed — needed AFTER mutateStore returns to build the // advisory note without re-deriving merge semantics out here. finalStatus: nextStatus, finalScope: nextScope, finalCwd: nextCwd, finalRisk: nextRisk, finalEvidence: nextEvidence, finalValue: nextValue, finalUrgency: nextUrgency, }; }); if (outcome.kind === "missing") return { text: `queue_update: no item '${key}'`, details: {} }; if (outcome.kind === "invalid-finisher-source") { return { text: `queue_update: finisherSource '${outcome.finisherSourceInput}' does not resolve to a commit in '${outcome.cwd ?? "(no cwd)"}' — supply an existing branch/sha that exists in the item's cwd`, details: {}, }; } const overrideNote = outcome.kind === "updated" && outcome.overrides > 0 ? ` — failure-verdict OVERRIDE recorded on the item (${outcome.overrides} total; a pattern of overrides means the runtime verdict is systematically wrong for this class of work — or that failures are being waved through)` : ""; const deadLetterNote = outcome.kind === "updated" && outcome.notesDeadLetterOnFailed ? " NOTE: 'notes' is NOT read by any re-dispatch (auto-recovery / review-FAIL / fresh dispatch all build the task from 'scope' alone) — if this note is meant to change what the NEXT attempt does, put it in 'scope' instead (queue_update({key, scope: ...})), or it will be silently ignored." : ""; // KEY: AUTOPILOT-69 — the SAME structured handoff panels.ts's redispatch // action writes, from a second producer (this tool). No queue_dispatch // call follows: it refuses `active` items by design; the harness's // already-landed sweep (dispatchPendingHumanRedispatch) is the ONLY // consumer, for both producers. const humanRedispatchNote = outcome.kind === "updated" && outcome.humanRedispatch ? " — RE-DISPATCHING WITH FINDINGS: do NOT call queue_dispatch for this item (it refuses active-status items); the harness's human re-dispatch sweep (the same automatic consumer the decision panel's redispatch action uses) spawns the redo on its next pass." : ""; // DEPENDENCY GATE (KEY: AUTOPILOT-81) — named ONCE, in this rejection's own // response: dependents stay WAITING (never auto-rejected). const dependentsWaitingNote = outcome.kind === "updated" && outcome.dependentsWaiting.length > 0 ? ` NOTE: ${outcome.dependentsWaiting.length} item(s) depend on '${key}' via 'after' and will stay WAITING now that it is rejected (never auto-rejected): ${outcome.dependentsWaiting.join(", ")}. Edit their 'after' (queue_update) to unblock, or reject them too if the work is no longer wanted.` : ""; // THE ITEM-SPECIFICATION CONTRACT (AUTOPILOT-128): every gap is named in // the refusal (field, value received, allowed form, where it belongs). if (outcome.kind === "contract") { return { text: formatContractRefusal("queue_update", outcome.from, "approved", outcome.gaps, key), details: { contractGaps: outcome.gaps } }; } if (outcome.kind === "needs-findings") { return { text: `queue_update: re-dispatching '${key}' from human-review requires findings — pass {status:'active', findings:''} (the SAME structured handoff the decision panel's redispatch action writes; the harness's human re-dispatch sweep reads it and spawns the redo — queue_dispatch is never called for this path, it refuses active-status items)`, details: {}, }; } // BUDGET VISIBILITY (AUTOPILOT-53): only when the caller EXPLICITLY set a // budget here — an update that leaves timeoutMs alone is not making a // promise and must not be nagged about one. const updateBudgetNote = params.timeoutMs !== undefined ? noBudgetNote(assessRequestedBudget(normalizeTimeoutMs(params.timeoutMs)), formatDurationMs) : ""; // SPEC-GAP SIGNAL TO THE CALLER (KEY: AUTOPILOT-58) — the scope-CONTENT // advisory, built from the fields THIS write actually landed. Admission // itself is decided by the contract above, not by this name. const gapNote = outcome.kind === "updated" ? specGapNote(outcome.finalScope, outcome.finalCwd, outcome.finalStatus) : ""; if (outcome.renamedTo) { const series = /^([A-Za-z0-9_-]+?)-\d+/.exec(outcome.renamedTo)?.[1] ?? outcome.renamedTo; return { text: `updated '${key}' → approved; provisional key renamed to '${outcome.renamedTo}' (series ${series})${updateBudgetNote}${gapNote}${dependentsWaitingNote}`, details: { renamedFrom: key, key: outcome.renamedTo } }; } return { text: `updated '${key}'${overrideNote}${updateBudgetNote}${deadLetterNote}${gapNote}${humanRedispatchNote}${dependentsWaitingNote}`, details: {} }; } catch (e) { return err(e, "queue_update"); } } /** CAPACITY GATE for the manual lane (KEY: AUTOPILOT-48). * The harness lane has always honoured maxSlots (auto-dispatch's freeSlots); * `queue_dispatch` — the lane MOST work actually runs through — consulted it * nowhere: a live queue_list read occupied 11 / totalActive 5 against * maxSlots 3 with nothing refusing, and ~8 concurrent workers had already * produced provider-level failures that day. So the cap BINDS here too: * refuse at/above capacity, and name the one-parameter override in the same * breath. Warn-and-proceed was rejected — the failure this item exists for is * an orchestrator acting on capacity facts it did not re-read, and a warning * it can ignore reproduces exactly that. Leaving it unconstrained keeps the * cap decorative in the lane that matters. The override keeps the deliberate * path the orchestrator relies on when it knows better than the harness, so * no work is stranded — it just becomes a decision instead of an accident. */ function capacityRefusal(occupied: number, maxSlots: number): string { return ( `queue_dispatch: AT CAPACITY — ${occupied} of ${maxSlots} worker slots occupied, nothing dispatched. ` + `Over-subscription is not free (concurrent-worker pileups have caused provider-level failures). ` + `Deliberate override: re-call with overrideCapacity: true (the dispatch proceeds and is marked as over-capacity). ` + `Or raise the cap for everyone: /autopilot capacity . Or wait for a slot — a completion frees one and the harness re-dispatches.` ); } /** queue_dispatch — spawn the worker (worktree isolation, fail-closed repo * check) AND record approved→active + runId atomically. Refuses above the * fleet cap unless overrideCapacity is passed (see capacityRefusal). */ export async function queueDispatch(ctx: QueueOpsCtx, params: Record): Promise { try { const store = ctx.storeOrNew(); const key = params.key as string; const item = store.items[key]; if (!item) return { text: `queue_dispatch: no item '${key}'`, details: {} }; if (item.status === "approved") { // approved = dispatchable (the fold); a blocked item is NOT approved } else if (item.status !== "ai-review" && item.status !== "failed") { return { text: `queue_dispatch: '${key}' is ${item.status}, not dispatchable`, details: {} }; } // LIVE-REVIEWER GUARD (KEY: AUTOPILOT-64, secondary): dispatching an // `ai-review` item moves it to `active` with a NEW worker runId, but // itemByReviewerRunId only matches items still IN `ai-review` — a reviewer // that was genuinely still running for this item becomes an unmatched // completion when it finishes: its verdict is silently dropped, with no // item left in ai-review to attribute it to. queue_review already refuses // this shape for a duplicate reviewer (reviewerRunAlive, same fail-open // liveness check); the worker-dispatch side needs the identical refusal. if (item.status === "ai-review" && item.reviewerRunId && reviewerRunAlive(ctx.backend, item.reviewerRunId)) { return { text: `queue_dispatch: '${key}' has a LIVE reviewer running (run ${item.reviewerRunId.slice(0, 8)}…) — dispatching now would move it out of ai-review and ORPHAN that reviewer's eventual verdict (it can no longer be attributed to this item). Wait for the reviewer, steer it (queue_steer), or clear its stale reviewerRunId first.`, details: { reviewerRunId: item.reviewerRunId, refused: "live-reviewer" }, }; } // DEPENDENCY GATE (KEY: AUTOPILOT-81): refuse to dispatch an item that is // still WAITING on an unfinished `after` dependency, naming the unmet // keys. `overrideCapacity` does NOT reach this refusal — the only path // past it is editing `after` (no override flag exists for this gate). const unmetDeps = unmetDependencies(item, store); if (unmetDeps.length > 0) { return { text: `queue_dispatch: '${key}' is WAITING on unmet dependencies (after: ${unmetDeps.join(", ")}) — not dispatched. overrideCapacity does NOT override this; edit 'after' (queue_update) once they are done.`, details: { refused: "unmet-dependencies", unmet: unmetDeps }, }; } // CAPACITY GATE — before the repo check and the spawn, so a refusal costs // nothing. The item being dispatched is approved/ai-review/failed (never // `active`), so it is not itself part of the occupied count. const overrideCapacity = params.overrideCapacity === true; const maxSlots = ctx.cfg().maxSlots; let fleetTotalActive: number | null = null; try { // A failing/absent fleet view is UNKNOWN, never a fake 0 — the store and // the engine ledger still bind the count below. fleetTotalActive = (await ctx.backend.fleetStatus())?.totalActive ?? null; } catch { fleetTotalActive = null; } // KEY: AUTOPILOT-61 — no ledgerRunning term here (see effectiveOccupiedSlots): // the engine's run ledger leaks entries whose completion event never // arrives with a matching id, and once leaked, never decrements again — // this is the exact counter a live session watched climb ("12 of 6", "13 // of 6", "15 of 6", "17 of 6") against a verified-empty fleet. const occupied = effectiveOccupiedSlots(ctx.stateDir, { backend: ctx.backend, zombieGraceMinutes: ctx.cfg().zombieGraceMinutes, fleetTotalActive, }); // An unreadable/absent cap FAILS OPEN: a missing config must never strand a // dispatch (the harness lane treats it the same way — a default, not a stop). const capBinds = Number.isFinite(maxSlots) && maxSlots >= 1; if (capBinds && occupied >= maxSlots && !overrideCapacity) { return { text: capacityRefusal(occupied, maxSlots), details: { occupied, maxSlots, refused: "capacity" } }; } const overCapacityNote = capBinds && occupied >= maxSlots ? ` WARNING: dispatched ABOVE capacity by explicit override (${occupied + 1} running vs maxSlots ${maxSlots}) — deliberate over-subscription; concurrent-worker pileups have caused provider-level failures. Raise the cap (/autopilot capacity ) if this is the new normal.` : ""; const cwd = (params.cwd as string | undefined) ?? ctx.sessionCwd ?? process.cwd(); const check = await ctx.repoCheck(cwd); if (!check.ok) { return { text: `queue_dispatch: ${check.reason}`, details: { cwd, ...(check.files ? { dirty: check.files } : {}) } }; } // THE ITEM-SPECIFICATION CONTRACT (AUTOPILOT-128) — DISPATCH SIDE: the // framework READS scope/cwd/risk to run this work, so an item that lacks // any of them is REFUSED here (the transition where they become // load-bearing), with EVERY gap named — not three transitions later. The // cwd checked here is the dispatch declaration (`params.cwd`), else the // item's recorded `cwd`, else the session repo — a non-empty cwd the run // can use (the spawn itself resolves params.cwd → session → process.cwd(), // so this requirement never refuses a run the spawn could start). `timeoutMs` is // DEFAULTABLE: never refused — the default is recorded on the item below // and stated in the response. const dispatchGaps = validateTransitionContract( { scope: item.scope, cwd: (params.cwd as string | undefined) ?? item.cwd ?? ctx.sessionCwd, evidence: item.evidence, value: item.value, urgency: item.urgency, risk: item.risk }, item.status, "active", ); if (dispatchGaps.length) { return { text: formatContractRefusal("queue_dispatch", item.status, "active", dispatchGaps, key), details: { contractGaps: dispatchGaps, refused: "contract" } }; } // TIMEOUT PLUMBING: an explicit param wins; otherwise the item's recorded // budget is delivered to EVERY lane's spawn (the historical gap: harness // dispatch/re-dispatch/review dropped the request and children ran at the // runtime default). A manually supplied budget is persisted so re-dispatch // and review inherit it too. const requestedTimeout = normalizeTimeoutMs(params.timeoutMs); const recordedTimeout = requestedTimeout ?? item.timeoutMs ?? null; // WORKER MODEL OVERRIDE (AUTOPILOT-79) + CONFIG DEFAULT (AUTOPILOT-80) + // CAPABILITY LEVELS (AUTOPILOT-110): param(model) > item.model > param tier // > item tier > config.model > config default tier > inherit // (resolveSpawnModel, the ONE precedence rule every backend.spawn site // shares). An explicit param is persisted onto the item (the config default // never is — it is not a fact about this item) so the harness lanes // (auto-dispatch, human re-dispatch, spec pass) spawn the same model/tier; // with nothing set anywhere, `undefined` reaches the backend and the launch // is byte-identical to a pre-override dispatch (the child inherits). let requestedModel: string | undefined; if (params.model !== undefined) { const m = normalizeModel(params.model); if (!m.ok) return { text: `queue_dispatch: ${m.reason}`, details: {} }; requestedModel = m.model; } let requestedTier: string | undefined; if (params.tier !== undefined) { const t = normalizeTier(params.tier); if (!t.ok) return { text: `queue_dispatch: ${t.reason}`, details: {} }; requestedTier = t.tier; } // An unknown/invalid level FAILS LOUDLY here (never a silent inherit): the // caller named a capability the config does not define. const resolvedModel = resolveSpawnModel({ param: requestedModel, tier: requestedTier, item, config: ctx.cfg(), role: "worker", repo: item.cwd ?? cwd, }); if (!resolvedModel.ok) return { text: `queue_dispatch: ${resolvedModel.error}`, details: {} }; const model = resolvedModel.model; // FINISHER-EVIDENCE (AUTOPILOT-34): a dispatch may DECLARE that it writes // OUTSIDE its worktree — a merge finisher lands an approved branch in the // TARGET REPO'S CHECKOUT (`cwd`) and leaves its worktree untouched by // design. Record the class + the cwd's HEAD + the SOURCE it must land as // the dispatch BASELINE, so the completion path can judge that run on THAT // SOURCE LANDING instead of on the runtime's "no worktree edits" signal // (which always misreads this class). const dispatchClass: DispatchClass = isDispatchClass(params.dispatchClass) ? params.dispatchClass : item.dispatchClass === "finisher" ? "finisher" : "worker"; // WHAT this finisher lands. An explicit param wins; otherwise the item's // recorded source rides along (the harness re-dispatch lanes have no // params). With no resolvable source there is NO landed evidence and the // runtime's verdict stands unchanged — the dispatch says so out loud. const finisherSource = dispatchClass === "finisher" ? (typeof params.finisherSource === "string" && params.finisherSource.trim() ? params.finisherSource.trim() : (item.finisherSource ?? null)) : null; const finisherBaseline = dispatchClass === "finisher" ? captureFinisherBaseline(cwd, null, finisherSource) : null; // NO NULL-BUDGET DISPATCH (AUTOPILOT-53): resolve BEFORE the spawn so an // item that never had a budget set gets an EXPLICIT, VISIBLE default // instead of silently inheriting the runtime's own internal fallback — // named in the receipt below and persisted onto the item so it shows in // queue_list and rides every later lane exactly like an operator-set one. const { timeoutMs, appliedDefault } = resolveDispatchBudget(recordedTimeout); const budgetNote = appliedDefault ? ` NOTE: no budget was set — dispatched with the default budget of ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), now recorded on the item. Override with queue_update('${key}', {timeoutMs: }) before the next dispatch if this task needs more or less time.` : ""; // THE EFFECTIVE DEADLINE, MADE VISIBLE (KEY: AUTOPILOT-129): the deadline // that actually fires is the CHILD's `timeoutMs` — the number this dispatch // hands backend.spawn, which for a re-dispatch IS the budget recorded on // the item (resolveDispatchBudget above). The async run around it is a // workflow wrapper with NO deadline of its own, so there is no second cap // to reconcile; the receipt names the clock time the run is cut off at so // an operator can see what will really happen, not just a duration. const effectiveDeadline = new Date(Date.now() + timeoutMs).toISOString(); const deadlineNote = ` ENFORCED DEADLINE: ${formatDurationMs(timeoutMs)} (${timeoutMs}ms) from dispatch, i.e. ${effectiveDeadline} — the child runs under this recorded budget (no separate async-run cap).`; // DEPENDENCY HEADER (KEY: AUTOPILOT-81): manual dispatch supplies its own // task text (not workerTask), so the "Depends on" prefix is built here — // same helper, same content every other dispatch lane's workerTask() uses. const dependsOnPrefix = item.after && item.after.length ? dependsOnHeader(item, store) : ""; // PRIOR-ATTEMPT POINTERS (KEY: AUTOPILOT-129): this lane builds its task // from `params.task` alone, so a human re-dispatch of a FAILED item used to // tell the worker NOTHING about the preserved prior work — the gap behind // AUTOPILOT-126's three rediscoveries of a fix that was already committed. // The harness lanes get this from workerTask(); this lane gets it from the // SAME builder (a first dispatch still gets nothing, by construction). const priorNote = priorAttemptContext(ctx.stateDir, item); const dispatchTask = [dependsOnPrefix, priorNote, params.task as string].filter((part) => part && part.trim()).join("\n\n"); const runId = await ctx.backend.spawn(dispatchTask, { cwd, timeoutMs, model }); if (!runId) return { text: "queue_dispatch: spawned but no run id returned", details: {} }; // The spawn happens OUTSIDE the mutation (mutateStore may re-run its apply // on a conflict; a spawn must happen exactly once). The store change that // records the run goes through the safe path. mutateStore(ctx.stateDir, (s) => { if (s.items[key]) { updateItem(s, key, { status: "active", runId, // AUTOPILOT-85: a MANUAL re-dispatch of a failed/ai-review item assigns // a fresh runId over a possibly-stale item.runWorktree left by the // superseded attempt — clear it in the SAME patch so shipping.ts's // resolveWorkTip never mistakes it for this run's own worktree // (source (c) is gated on runWorktree.runId === item.runId ?? // item.lastRunId). A fresh `approved` dispatch has no attempt to // supersede, but clearing unconditionally is still correct: any // leftover record is necessarily from an earlier, different run. runWorktree: null, ...(requestedTimeout ? { timeoutMs: requestedTimeout } : appliedDefault ? { timeoutMs } : {}), ...(requestedModel ? { model: requestedModel } : {}), ...(requestedTier ? { tier: requestedTier } : {}), dispatchClass, ...(dispatchClass === "finisher" ? { finisherSource } : {}), ...(finisherBaseline ? { finisherBaseline: { ...finisherBaseline, runId } } : {}), }); } }); try { // Worktree handoff preservation starts AT DISPATCH: journal + keep-ref // the parallel branch from its first commit onward (sweeps continue it). preserveRunWorktree({ stateDir: ctx.stateDir, repo: cwd, runId, key }); } catch { // preservation never breaks dispatch } ctx.autopilot().handleAsyncStarted(runId, "worker"); // fleet ledger // Double-dispatch guard: an APPROVED item that the harness would // auto-dispatch (scope + cwd + low/medium risk) should rarely be dispatched // manually — the harness fills free slots itself. The dispatch proceeds // (the override path is legitimate), but the risk is surfaced. const autoNote = item.status === "approved" && isAutoDispatchable(item, store) ? " WARNING: auto-dispatchable (approved + scope + cwd + low/medium) — the harness will dispatch this itself when a slot frees; manual dispatch risks a duplicate worker. Override ok, but only if you mean it." : ""; const finisherNote = dispatchClass === "finisher" ? finisherBaseline?.sourceSha ? ` FINISHER-CLASS: this dispatch writes into ${cwd} (not its worktree); success is judged on '${finisherSource}' (${finisherBaseline.sourceSha.slice(0, 8)}) entering that checkout's history from baseline ${finisherBaseline.sha ? finisherBaseline.sha.slice(0, 8) : "unreadable"} — EITHER as the declared commits (merge/fast-forward) OR as patch-equivalent copies (clean cherry-pick/rebase/squash of a single-commit source), so a runtime 'no edits' verdict is overridden (and recorded) when the work lands in one of those shapes. NOT DETECTED, so the failure stands and closing the item is your deliberate call (queue_update overrideReason): a CONFLICT-RESOLVED cherry-pick (resolving the conflict rewrites the patch), a squash of a MULTI-commit source, and any landing that never moves this checkout — notably shipping flow 'mrs', which only pushes the branch and opens an MR.` : ` FINISHER-CLASS: this dispatch writes into ${cwd} (not its worktree), but ${finisherSource ? `the declared source '${finisherSource}' does not resolve to a commit there` : "NO source branch/sha was declared (finisherSource)"} — LANDED EVIDENCE IS OFF for this run, so a runtime 'no edits in the worktree' verdict will fail it as usual. Re-dispatch with finisherSource= to get the evidence path.` : ""; return { text: `dispatched '${key}' — run ${runId} (model: ${model ?? "inherit"}) [source: ${resolvedModel.source}]${tierNote(resolvedModel)}.${budgetNote}${deadlineNote}${overCapacityNote}${autoNote}${finisherNote}`, details: { runId, dispatchClass, model: model ?? "inherit", modelSource: resolvedModel.source, ...(resolvedModel.tier ? { tier: resolvedModel.tier, tierFrom: resolvedModel.tierFrom, ...(resolvedModel.tierEntry ? { tierEntry: resolvedModel.tierEntry } : {}) } : {}), occupied: occupied + 1, maxSlots, // The deadline the run is ACTUALLY under (AUTOPILOT-129) — the same // number the spawn got and the item records. enforcedTimeoutMs: timeoutMs, effectiveDeadline, ...(overCapacityNote ? { overCapacity: true } : {}), ...(appliedDefault ? { appliedDefaultBudget: true, timeoutMs } : {}), }, }; } catch (e) { return err(e, "queue_dispatch"); } } /** WHERE the reviewer must look (AUTOPILOT-63): a bare queue_review call (no * explicit task) used to hand the reviewer nothing but `cwd` — for a * worker-class item that is ALWAYS the untouched main checkout (a * worker-class run writes ONLY inside its own isolated worktree/parallel * branch, which the runtime is documented to delete at run end (not reliably — AUTOPILOT-74); see * worktree-preservation.ts), so a reviewer with no other instruction * reviews whatever happens to be checked out in cwd. OBSERVED LIVE: a bare * call reviewed an unrelated commit that had just landed on main and * returned a syntactically valid PASS for the WRONG artifact * (AUTOPILOT-WORKTREE-PRESERVATION-2). Resolved from the same durable * journal AUTOPILOT-57's review-targets read (survives the runtime's * worktree deletion): the latest branch@tip preserved for this item. * finisher-class items are exempt — their declared cwd IS the reviewed * location BY DESIGN (FINISHER-EVIDENCE) — so this returns null for them. * Otherwise returns a `note` to splice into the default task (location * resolved) or a `blocked` refusal reason (no pointer resolved — silently * reviewing cwd's HEAD would repeat the exact false-PASS incident this * guards against). */ export function resolveReviewLocation( ctx: Pick, key: string, item: { cwd: string | null; dispatchClass?: DispatchClass }, ): { note: string } | { blocked: string } | null { if (item.dispatchClass === "finisher") return null; // cwd IS the reviewed location by design const repo = item.cwd; if (!repo) { return { blocked: `queue_review: '${key}' has no cwd recorded — cannot locate the work to review; pass task=<...> naming where it lives, or set cwd first` }; } let pointer: { branch: string; tipSha: string } | undefined; try { pointer = reviewPointersFor(ctx.stateDir, key).at(-1); } catch { pointer = undefined; } if (pointer && commitResolvesIn(repo, pointer.tipSha)) { return { note: `THE WORK LIVES ON BRANCH \`${pointer.branch}\` AT COMMIT ${pointer.tipSha} in ${repo} — this is very likely NOT what is currently checked out there. Do NOT review whatever HEAD happens to be; check out or diff that exact commit first, e.g. \`git diff main...${pointer.tipSha}\` or \`git log ${pointer.branch}\`.`, }; } return { blocked: `queue_review: LOCATION UNRESOLVED for '${key}' — no journaled branch/commit pointer was found for this item's work in ${repo} (the worktree-preservation journal has nothing usable for it), so a bare queue_review call cannot safely point the reviewer anywhere but ${repo}'s current HEAD, which may be UNRELATED work (this is exactly how AUTOPILOT-WORKTREE-PRESERVATION-2 produced a false PASS reviewing main's tip instead of the item's branch). Pass an explicit task naming the branch/commit to review, or investigate why the journal has no entry for this item's run before retrying.`, }; } /** queue_review — spawn the reviewer (read-only, no worktree) for a reviewing * item; the verdict contract — including WHERE the work lives, resolved via * resolveReviewLocation — is injected when no task is supplied. */ export async function queueReview(ctx: QueueOpsCtx, params: Record): Promise { try { const store = ctx.storeOrNew(); const key = params.key as string; const item = store.items[key]; if (!item) return { text: `queue_review: no item '${key}'`, details: {} }; if (item.status !== "ai-review") { return { text: `queue_review: '${key}' is ${item.status}, not reviewing`, details: {} }; } // STALE-REF GUARD: refuse only for a reviewer that is genuinely in flight. // A dead reviewer's id used to block this item forever (crash, stop, engine // restart, lost completion) — the liveness check clears it and proceeds. // reviewerRunAlive FAILS OPEN (see run-liveness.ts): undeterminable → not // alive → dispatch. A duplicate read-only reviewer costs tokens; a false // block costs a manual bypass, which breaks verdict attribution. let clearedStaleReviewerRunId: string | null = null; if (item.reviewerRunId) { if (reviewerRunAlive(ctx.backend, item.reviewerRunId)) { return { text: `queue_review: a reviewer is ALREADY running for '${key}' (run ${item.reviewerRunId.slice(0, 8)}…) — steer it or wait for its completion`, details: {} }; } clearedStaleReviewerRunId = item.reviewerRunId; // de-stale durably, even if the spawn below fails mutateStore(ctx.stateDir, (s) => { if (s.items[key]) updateItem(s, key, { reviewerRunId: null }); }); } const explicitTask = (params.task as string | undefined)?.trim(); let locationNote = ""; if (!explicitTask) { const location = resolveReviewLocation(ctx, key, item); if (location && "blocked" in location) return { text: location.blocked, details: { locationUnresolved: true } }; if (location && "note" in location) locationNote = `${location.note}\n\n`; } const task = explicitTask ? (params.task as string) : "Review the completed work for correctness, approach quality, and completeness. Read the work product yourself (the diff/files), do NOT trust the worker's summary.\n\n" + locationNote + "The FIRST line of your response MUST be a verdict line: `Verdict: PASS`, `Verdict: FAIL`, or `Verdict: NO-CHANGE` (the LAST only when the correct outcome was to make NO change — a refusal/withdrawal, no deliverable; it is terminal and never re-dispatched). If FAIL, list each finding as an actionable item.\n\n" + `${REVIEW_CONTRACT_TEXT}\n\n${acceptanceEvidenceStatus(item.acceptanceEvidence)}`; const agentName = ctx.cfg().reviewerAgents[0] ?? "orchestrator-reviewer"; // WORKER/REVIEWER MODEL (AUTOPILOT-79/80) + LEVELS (AUTOPILOT-110): unlike // queue_dispatch's `model`, a queue_review `model`/`tier` param is NEVER // persisted onto the item — the item's own `model`/`tier` fields are facts // about how the WORK was dispatched, not a standing instruction for every // future reviewer call. Same precedence as every other spawn site: // param(model) > item.model > param tier > item tier > config.model > // config default tier > inherit (resolveSpawnModel); a reviewer resolves // through models.reviewDefault, a worker through models.default. let requestedReviewModel: string | undefined; if (params.model !== undefined) { const m = normalizeModel(params.model); if (!m.ok) return { text: `queue_review: ${m.reason}`, details: {} }; requestedReviewModel = m.model; } let requestedReviewTier: string | undefined; if (params.tier !== undefined) { const t = normalizeTier(params.tier); if (!t.ok) return { text: `queue_review: ${t.reason}`, details: {} }; requestedReviewTier = t.tier; } const resolvedModel = resolveSpawnModel({ param: requestedReviewModel, tier: requestedReviewTier, item, config: ctx.cfg(), role: "review", repo: item.cwd, }); if (!resolvedModel.ok) return { text: `queue_review: ${resolvedModel.error}`, details: {} }; // NO NULL-BUDGET DISPATCH (AUTOPILOT-53): same rule as queue_dispatch — an // item with no recorded budget gets an explicit, visible default, // persisted onto the item so it rides every later lane exactly like an // operator-set one (docs/queue-model.md's every-spawn-site guarantee). const { timeoutMs, appliedDefault } = resolveDispatchBudget(normalizeTimeoutMs(params.timeoutMs) ?? item.timeoutMs ?? null); const budgetNote = appliedDefault ? ` NOTE: no budget was set — reviewer dispatched with the default budget of ${formatDurationMs(timeoutMs)} (${timeoutMs}ms), now recorded on the item.` : ""; const runId = await ctx.backend.spawn(task, { agent: agentName, worktree: false, // reviewers are read-only — no worktree cwd: item.cwd ?? undefined, // the work repo — the reviewer locates the product there timeoutMs, model: resolvedModel.model, }); if (!runId) return { text: "queue_review: spawned but no run id returned", details: {} }; mutateStore(ctx.stateDir, (s) => { if (s.items[key]) updateItem(s, key, { reviewerRunId: runId, ...(appliedDefault ? { timeoutMs } : {}) }); }); ctx.emit([{ name: "orch:reviewer-dispatched", data: { key, reviewerRunId: runId } }]); const staleNote = clearedStaleReviewerRunId ? ` (cleared a STALE reviewer ref ${clearedStaleReviewerRunId.slice(0, 8)}… — that run is gone/terminal)` : ""; return { text: `reviewer dispatched for '${key}' — run ${runId} (model: ${resolvedModel.model ?? "inherit"}) [source: ${resolvedModel.source}]${tierNote(resolvedModel)}${staleNote}${budgetNote}`, details: { runId, model: resolvedModel.model ?? "inherit", modelSource: resolvedModel.source, ...(resolvedModel.tier ? { tier: resolvedModel.tier, tierFrom: resolvedModel.tierFrom, ...(resolvedModel.tierEntry ? { tierEntry: resolvedModel.tierEntry } : {}) } : {}), ...(clearedStaleReviewerRunId ? { clearedStaleReviewerRunId } : {}), ...(appliedDefault ? { appliedDefaultBudget: true, timeoutMs } : {}), }, }; } catch (e) { return err(e, "queue_review"); } } /** queue_steer — steer a running worker/reviewer; the backend verifies * delivery (headless children are honestly refused). */ export async function queueSteer(ctx: QueueOpsCtx, params: Record): Promise { try { const store = ctx.storeOrNew(); const key = params.key as string; const item = store.items[key]; if (!item) return { text: `queue_steer: no item '${key}'`, details: {} }; const runId = item.status === "active" ? item.runId : item.status === "ai-review" ? item.reviewerRunId : null; if (!runId) { return { text: `queue_steer: '${key}' has no running run (status ${item.status}) — only active workers / ai-review reviewers are steerable`, details: {} }; } const mode = params.mode === "follow_up" ? "follow_up" : "steer"; const { id, ack } = await ctx.backend.steer(runId, params.message as string, mode, params.ackTimeoutMs as number | undefined); // HONEST uptake semantics: the ack certifies TRANSPORT acceptance only. // "delivered" = injected into the running turn. "queued" = waiting for // the child's NEXT turn boundary — a run that ends first never consumes // it (observed live: steering a reviewer that was seconds from finishing; // the queued message could not retroactively change its verdict). The // caller must know which one happened so it can verify uptake instead of // trusting the acknowledgment. const text = ack === "delivered" ? `steered '${key}' (run ${runId.slice(0, 8)}…, request ${id}) — ${mode} DELIVERED into the running turn (ack: delivered)` : `steered '${key}' (run ${runId.slice(0, 8)}…, request ${id}) — ${mode} QUEUED at the child's next turn boundary (ack: queued). CAVEAT: if the run completes before consuming it, the steer has no effect — verify uptake in the run's output rather than trusting this acknowledgment.`; return { text, details: { requestId: id, runId, ack } }; } catch (e) { return err(e, "queue_steer"); } } /** Fail-closed repo check for dispatch cwds (mirrors the pi extension's * original): the cwd must be inside a git repo (toplevel-resolved) and the * checkout clean — worktree isolation needs a clean main checkout to clone * from. Message formats are load-bearing (tests assert them). * * Both git calls route through the ONE shared helper (AUTOPILOT-121), so a cwd * that is not a repo — a session started outside one — is a NORMAL NEGATIVE * (`null`), not a thrown-and-leaked `fatal: not a git repository …` on the * operator's TUI. Same fail-closed reason either way, now silent. */ export async function repoCheck(cwd: string): Promise<{ ok: boolean; reason?: string; files?: number }> { const notARepo = { ok: false, reason: `${cwd} is not inside a git repository — pass cwd= to queue_dispatch (the session cwd is not the worker's repo)` }; const top = git(cwd, ["rev-parse", "--show-toplevel"]); if (top === null) return notARepo; const status = git(top, ["status", "--porcelain", "--", ".", ":(exclude).pi/subagents"]); if (status === null) return notARepo; // unreadable in a resolvable repo: fail closed, same reason const lines = status.split("\n").filter((l) => l.trim()); if (lines.length > 0) { return { ok: false, files: lines.length, reason: `main checkout ${top} is DIRTY (${lines.length} file(s)) — worktree isolation requires a clean tree. Commit/stash real work, or gitignore tooling artifacts (.pi/, .pi-subagents/, .reviews/), then re-dispatch.` }; } return { ok: true }; } // keep loadStore referenced (storeOrNew helpers in hosts may use it) export { loadStore, newStore };