diff --git a/README.md b/README.md index ce8ea97..f6a914c 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,8 @@ pi -e npm:pi-subagents -e . 1. Plan. The agent explores read-only and drafts the plan. 2. Review. After Pi settles, the full plan is printed in the transcript. Check that User-visible - result names the final artifact or behavior you expect. Ready forks the retained supervisor and - preserves the main context. Ready (compact) first forks that supervisor from the full main context, + result names the final artifact or behavior you expect. Ready starts the retained supervisor with + a small fresh context and preserves the main context. Ready (compact) starts that supervisor first, then requests Pi's normal compaction of the main session only. It never compacts the retained supervisor or worker. Refine collects short notes. Edit opens the full plan in Pi's editor. 3. Work. The topology is: diff --git a/slop/audits/20260905_nested-supervisor-validation.txt b/slop/audits/20260905_nested-supervisor-validation.txt index be73336..2d9cf36 100644 --- a/slop/audits/20260905_nested-supervisor-validation.txt +++ b/slop/audits/20260905_nested-supervisor-validation.txt @@ -71,8 +71,21 @@ $ git diff --stat 2852432 test/worker.test.ts | 18 ++- 12 files changed, 354 insertions(+), 143 deletions(-) -## Runtime boundary +## Dogfood run -No model-backed Ready -> goal-supervisor -> goal-worker run was executed here. Unit tests cover the package-discovery contract, nested tool restrictions, review freshness, clean-state filtering, stop dispatch, stale-run recovery, and mechanical sign-off. The README manual check is the user acceptance test. +The model-backed run produced commit `0a33ff2` and independently verified 47 text-file word counts with zero set, count, or order mismatches. Approval then deadlocked: + +> Cannot approve while the retained worker is pending. + +The worker process was terminal, but its model result was `Request was aborted`; the completion event did not clear retained state. A supervisor resume also failed because `subagent_supervisor` was unavailable in its strict tool list. + +Usage from the run status files: + +| agent | turns | new tokens | cached reads | reported cost | +| --- | ---: | ---: | ---: | ---: | +| supervisor, including recovery | 42 | 169,288 | 2,670,336 | $2.35 | +| worker | 17 | 67,803 | 812,544 | $0.90 | + +The corrective patch starts the supervisor with fresh context, removes global/project/skill inheritance from that supervisor, blocks status polling, removes the unavailable tool, and treats process-terminal as terminal worker state. Unit tests pass; a second model-backed run is still required. -- PI[gpt-5.6-sol] diff --git a/src/supervisor-runtime.ts b/src/supervisor-runtime.ts index a9af0aa..afcb99f 100644 --- a/src/supervisor-runtime.ts +++ b/src/supervisor-runtime.ts @@ -4,7 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { goalBlock, hashGoalBlock, repositoryState, writeApproval } from "./approval.js"; import { isSupervisorReadOnlyCommand } from "./index.js"; -import { processWorkState, subagentWorkState } from "./worker.js"; +import { processWorkState } from "./worker.js"; const NESTED_STATE = "pi-goals-nested-worker"; @@ -32,11 +32,14 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void { nested = { runId: event.id, pending: true }; persist(); }); - pi.events.on("subagent:async-complete", (raw) => { - if ((raw as { runId?: unknown }).runId !== nested.runId) return; + const completeNested = (raw: unknown) => { + const event = raw as { id?: unknown; runId?: unknown }; + if ((event.runId ?? event.id) !== nested.runId) return; nested = { ...nested, pending: false }; persist(); - }); + }; + pi.events.on("subagent:async-complete", completeNested); + pi.events.on("subagent:process-terminal", completeNested); pi.on("session_start", async (_event, ctx) => { const last = ctx.sessionManager.getEntries() @@ -60,7 +63,7 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void { return { block: true, reason: nested.runId ? "Resume the retained goal-worker instead of starting another worker." : "The supervisor may start only goal-worker." }; } if (action === "list") return; - if (action === "status" && (!targetRun(input) || targetRun(input) === nested.runId)) return; + if (action === "status") return { block: true, reason: "Do not poll the retained worker. Use its native progress and completion updates." }; if (["resume", "steer", "interrupt", "stop"].includes(action) && targetRun(input) === nested.runId) return; return { block: true, reason: "The supervisor may inspect or control only its retained goal-worker." }; }); @@ -81,8 +84,8 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void { }), async execute(_id, params, _signal, _onUpdate, ctx) { if (nested.pending) return result("Cannot approve while the retained worker is pending.", true); - const [subagents, processes] = await Promise.all([subagentWorkState(pi.events), Promise.resolve(processWorkState(pi.events))]); - if (subagents !== "idle" || processes !== "idle") return result(`Cannot approve: subagents=${subagents}; processes=${processes}.`, true); + const processes = processWorkState(pi.events); + if (processes !== "idle") return result(`Cannot approve: processes=${processes}.`, true); const planPath = resolve(params.planPath); let plan: string; let repository: ReturnType; diff --git a/src/worker.ts b/src/worker.ts index a4895c1..3e580af 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -38,15 +38,13 @@ interface AsyncSnapshot { export type WorkState = "active" | "idle" | "unknown"; -export const supervisorSystemPrompt = `You are the retained goal supervisor. The main Pi session is a thin human-facing coordinator. -You own the current plan review and the retained implementation worker. At every review, reread the full -current plan named in your task, identify the exact goal block, inspect the repository, cited artifacts, and -saved verification output, then launch, resume, or steer the one nested goal-worker as needed. Use the worker model -named in each direction when spawning it; resume the retained worker after that. Do not edit project files. Use -read/search and standard verification commands only. Keep approval IDs and checkpoint paths from the worker. The worker -must commit its changes before approval. When no nested work is active, HEAD is committed, the worktree is clean, and -you inspected the plan, repository, evidence, and saved verification output, call ApproveGoal with the current approval -ID. Otherwise continue or redirect the worker. Only ApproveGoal creates acceptance. -- Pi/Codex`; +export const supervisorSystemPrompt = `You are the retained goal supervisor. The main Pi session only coordinates with the human. +Launch one goal-worker, then rely on native progress and completion updates. Do not poll status, wait, or repeatedly +steer an active worker. Read the current plan, repository, cited evidence, and saved verification output yourself after +the worker finishes. Do not edit project files. Use read/search and standard verification commands only. The worker must +commit its changes before approval. When no nested work is active, HEAD is committed, the worktree is clean, and the +evidence proves the discriminator, call ApproveGoal with the current approval ID. Otherwise give the retained worker +one concrete correction. Only ApproveGoal creates acceptance. -- Pi/Codex`; export function registerGoalSupervisor(events: EventBus, model: string | null): Registration { const supervisorRuntime = fileURLToPath(new URL("./supervisor-runtime.ts", import.meta.url)); @@ -56,15 +54,16 @@ export function registerGoalSupervisor(events: EventBus, model: string | null): definition: { description: "Read-only supervisor that owns a nested retained implementation worker.", systemPrompt: supervisorSystemPrompt, - tools: ["read", "grep", "find", "ls", "bash", "subagent", "subagent_supervisor", "ApproveGoal"], + tools: ["read", "grep", "find", "ls", "bash", "subagent", "ApproveGoal"], allowNestedSubagents: true, subagentOnlyExtensions: [supervisorRuntime], ...(model ? { model } : {}), systemPromptMode: "replace", - inheritProjectContext: true, - inheritGlobalContext: true, - inheritSkills: true, - defaultContext: "fork", + thinking: "low", + inheritProjectContext: false, + inheritGlobalContext: false, + inheritSkills: false, + defaultContext: "fresh", defaultAsync: true, defaultProgress: true, }, @@ -118,7 +117,7 @@ export async function startGoalSupervisor(events: EventBus, cwd: string, task: s agent: SUPERVISOR_AGENT, task, cwd, - context: "fork", + context: "fresh", async: true, mission: false, }, signal); diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index 57da22d..11ca363 100644 --- a/test/goals-flow.test.ts +++ b/test/goals-flow.test.ts @@ -255,7 +255,7 @@ describe("/goals draft flow", () => { await flow.hooks.get("agent_settled")({}, flow.ctx); expect(flow.events).toEqual(["display", "select"]); - expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fork" } }); + expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fresh" } }); expect(flow.messages.filter((message) => !message.display)).toHaveLength(1); const supervisor = await flow.hooks.get("before_agent_start")({}, flow.ctx); expect(supervisor.systemPrompt).toContain("thin human-facing coordinator"); @@ -430,7 +430,7 @@ describe("/goals draft flow", () => { const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: produce report\n - discriminator: report.txt contains PASS\n - evidence:\n - report.txt: `PASS`\n\n## Log\n"); await flow.hooks.get("agent_settled")({}, flow.ctx); - expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fork" } }); + expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fresh" } }); flow.eventBus.emit("subagent:async-complete", { runId: "worker-1", results: [{ success: true }] }); const resumed = await flow.tools.get("GuideGoalWorker").execute("", { instruction: "Verify report.txt." }, undefined, undefined, flow.ctx); diff --git a/test/supervisor-runtime.test.ts b/test/supervisor-runtime.test.ts index 3a5b1a3..8b27517 100644 --- a/test/supervisor-runtime.test.ts +++ b/test/supervisor-runtime.test.ts @@ -63,6 +63,7 @@ describe("supervisor-only runtime", () => { expect((await runtime.hooks.get("tool_call")({ toolName: "bash", input: { command: "git branch new-name" } }, runtime.ctx))?.block).toBe(true); expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", input: { agent: "worker" } }, runtime.ctx))?.block).toBe(true); expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", input: { agent: "goal-worker" } }, runtime.ctx))).toBeUndefined(); + expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", input: { action: "status", id: "nested-1" } }, runtime.ctx))?.block).toBe(true); expect((await runtime.hooks.get("tool_call")({ toolName: "bash", input: { command: "git status && npm test" } }, runtime.ctx))).toBeUndefined(); } finally { rmSync(runtime.cwd, { recursive: true, force: true }); @@ -90,6 +91,8 @@ describe("supervisor-only runtime", () => { mkdirSync(join(runtime.cwd, ".pi/plan"), { recursive: true }); writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: ship it\n - evidence: verify.log: PASS\n"); const checkpoint = approvalPath(runtime.cwd, "main-session", "ship it"); + runtime.events.emit("subagent:async-started", { id: "nested-1", agent: "goal-worker" }); + runtime.events.emit("subagent:process-terminal", { runId: "nested-1", state: "observed" }); const accepted = await runtime.tools.get("ApproveGoal").execute("", { approvalId: "review-1", goal: "ship it", diff --git a/test/worker.test.ts b/test/worker.test.ts index fcb4bc4..4ce8c0d 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -46,18 +46,23 @@ describe("goal hierarchy registration", () => { registerGoalSupervisor(events, "provider/cheap-model"); expect(definition?.model).toBe("provider/cheap-model"); - expect(definition?.defaultContext).toBe("fork"); + expect(definition?.defaultContext).toBe("fresh"); + expect(definition?.thinking).toBe("low"); + expect(definition?.inheritProjectContext).toBe(false); + expect(definition?.inheritGlobalContext).toBe(false); + expect(definition?.inheritSkills).toBe(false); expect(definition?.defaultProgress).toBe(true); expect(definition?.allowNestedSubagents).toBe(true); - expect(definition?.tools).toEqual(["read", "grep", "find", "ls", "bash", "subagent", "subagent_supervisor", "ApproveGoal"]); + expect(definition?.tools).toEqual(["read", "grep", "find", "ls", "bash", "subagent", "ApproveGoal"]); expect(definition?.subagentOnlyExtensions).toEqual([expect.stringContaining("supervisor-runtime.ts")]); - expect(supervisorSystemPrompt).toContain("nested goal-worker"); + expect(supervisorSystemPrompt).toContain("Launch one goal-worker"); + expect(supervisorSystemPrompt).toContain("Do not poll status"); expect(supervisorSystemPrompt).toContain("ApproveGoal"); }); }); describe("goal worker RPC", () => { - it("starts from a fork, resumes retained context, and steers a live run", async () => { + it("starts fresh, resumes retained context, and steers a live run", async () => { const events = new Events(); const requests: any[] = []; replyToRpc(events, (request) => { @@ -70,7 +75,7 @@ describe("goal worker RPC", () => { await steerGoalSupervisor(events, "run-2", "report"); await stopGoalSupervisor(events, "run-2"); - expect(requests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", cwd: "/repo", context: "fork", async: true } }); + expect(requests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", cwd: "/repo", context: "fresh", async: true } }); expect(requests[1]).toMatchObject({ method: "resume", params: { id: "run-1", message: "continue" } }); expect(requests[2]).toMatchObject({ method: "steer", params: { id: "run-2", message: "report", mode: "steer" } }); expect(requests[3]).toMatchObject({ method: "stop", params: { id: "run-2" } });