From 6cfeaf44ee40a40e546668c2a4d894b68fa19f5f Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:31:46 +0800 Subject: [PATCH] Compact the supervisor fork before work Co-Authored-By: PI[gpt-5.6-sol] <288921227+claudypoo@users.noreply.github.com> --- README.md | 8 +-- .../20260905_nested-supervisor-validation.txt | 2 +- src/index.ts | 10 ++-- src/supervisor-runtime.ts | 58 ++++++++++++++++++- src/worker.ts | 14 +++-- test/goals-flow.test.ts | 6 +- test/supervisor-runtime.test.ts | 35 ++++++++++- test/worker.test.ts | 11 ++-- 8 files changed, 117 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f6a914c..c7fc69d 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ 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 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. + result names the final artifact or behavior you expect. Ready forks the retained supervisor and + preserves the main context. Ready (compact) forks the supervisor, compacts its planning history + before its first turn, then compacts the main session. It never compacts the retained worker. + Refine collects short notes. Edit opens the full plan in Pi's editor. 3. Work. The topology is: ```text diff --git a/slop/audits/20260905_nested-supervisor-validation.txt b/slop/audits/20260905_nested-supervisor-validation.txt index 2d9cf36..da936e3 100644 --- a/slop/audits/20260905_nested-supervisor-validation.txt +++ b/slop/audits/20260905_nested-supervisor-validation.txt @@ -86,6 +86,6 @@ Usage from the run status files: | 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. +The corrective patch keeps the supervisor fork, compacts its planning history before the first turn when Ready (compact) is selected, removes global/project/skill prompt inheritance, replaces raw status polling with a concise worker-state tool, 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/index.ts b/src/index.ts index 3884d9d..6ecf935 100644 --- a/src/index.ts +++ b/src/index.ts @@ -187,7 +187,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } } - async function startOrResumeWorker(ctx: ExtensionContext, task: string, signal?: AbortSignal): Promise { + async function startOrResumeWorker(ctx: ExtensionContext, task: string, compactPlanning: boolean, signal?: AbortSignal): Promise { if (workerLaunchPending) throw new Error("A goal-worker launch is already in progress."); if (!workerRegistration) setupWorker(ctx); if (!workerRegistration) throw new Error(`Goal worker unavailable: ${workerRegistrationError ?? "pi-subagents is not ready"}.`); @@ -196,7 +196,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { try { const runId = state.workerRunId ? await resumeGoalSupervisor(pi.events, state.workerRunId, task, signal) - : await startGoalSupervisor(pi.events, ctx.cwd, task, signal); + : await startGoalSupervisor(pi.events, ctx.cwd, task, compactPlanning, signal); rememberWorkerRun(runId); return runId; } finally { @@ -238,7 +238,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { return `${instruction}\n\nYou are the retained goal-supervisor. Here is the complete current plan; inspect its exact goal blocks and cited evidence before directing or approving work.\nPlan path: ${planPath(ctx)}\nApproval ID: ${state.approvalId}\nNested worker model: ${state.workerModel ?? "pi-subagents default"}\nPass the exact approval ID to ApproveGoal. Keep checkpoint paths and the approval ID from the nested worker.\nPrivate approval checkpoints, one per current goal:\n${checkpoints || "(no open goals)"}\n\n${plan}`; } - async function directSupervisor(ctx: ExtensionContext, instruction: string, signal?: AbortSignal): Promise { + async function directSupervisor(ctx: ExtensionContext, instruction: string, signal?: AbortSignal, compactPlanning = false): Promise { beginReview(ctx); const task = supervisorTask(ctx, instruction); if (state.workerPending && state.workerRunId) { @@ -251,7 +251,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { persist(); } } - return startOrResumeWorker(ctx, task, signal); + return startOrResumeWorker(ctx, task, compactPlanning, signal); } function wakeSupervisor(ctx: ExtensionContext, reason: string): void { @@ -518,7 +518,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { persist(); updateWidget(ctx); try { - await directSupervisor(ctx, "Start by launching or resuming the nested goal-worker. Then supervise the current plan."); + await directSupervisor(ctx, "Start by launching or resuming the nested goal-worker. Then supervise the current plan.", undefined, choice === "Ready (compact)"); scheduleSupervisorCheck(ctx); return true; } catch (error) { diff --git a/src/supervisor-runtime.ts b/src/supervisor-runtime.ts index afcb99f..830472b 100644 --- a/src/supervisor-runtime.ts +++ b/src/supervisor-runtime.ts @@ -7,6 +7,7 @@ import { isSupervisorReadOnlyCommand } from "./index.js"; import { processWorkState } from "./worker.js"; const NESTED_STATE = "pi-goals-nested-worker"; +const COMPACTED_STATE = "pi-goals-supervisor-compacted"; interface NestedState { runId: string | null; @@ -22,8 +23,17 @@ function targetRun(input: Record): string | null { return typeof value === "string" && value ? value : null; } +function compactPlanningRequested(): boolean { + const raw = process.env.PI_SUBAGENT_EXTENSION_BINDINGS; + if (!raw) return false; + const bindings = JSON.parse(raw) as { "pi-goals/1"?: { compactPlanning?: unknown } }; + return bindings["pi-goals/1"]?.compactPlanning === true; +} + export default function goalSupervisorRuntime(pi: ExtensionAPI): void { let nested: NestedState = { runId: null, pending: false }; + let compacting = false; + let compactionDone = Promise.resolve(); const persist = () => pi.appendEntry(NESTED_STATE, nested); pi.events.on("subagent:async-started", (raw) => { @@ -41,11 +51,46 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void { pi.events.on("subagent:async-complete", completeNested); pi.events.on("subagent:process-terminal", completeNested); + pi.on("session_before_compact", async (event) => { + if (!compacting) return; + const branchEntries = event.branchEntries as Array<{ id?: string; type?: string; message?: { role?: string } }>; + const latestMessage = [...branchEntries].reverse().find((entry) => entry.type === "message" && ["user", "assistant"].includes(entry.message?.role ?? "")); + return { + compaction: { + summary: "Planning is complete. The latest retained goal-supervisor task contains the current plan and approval paths; use it as the source of truth. -- PI[gpt-5.6-sol]", + firstKeptEntryId: latestMessage?.id ?? event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: { source: "pi-goals-plan-handoff" }, + }, + }; + }); + pi.on("session_start", async (_event, ctx) => { - const last = ctx.sessionManager.getEntries() + const entries = ctx.sessionManager.getEntries(); + const last = entries .filter((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === NESTED_STATE) .pop() as { data?: NestedState } | undefined; nested = last?.data ?? nested; + if (!compactPlanningRequested()) return; + if (entries.some((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === COMPACTED_STATE)) return; + compacting = true; + compactionDone = new Promise((resolvePromise, reject) => { + ctx.compact({ + onComplete: () => { + compacting = false; + pi.appendEntry(COMPACTED_STATE, { version: 1 }); + resolvePromise(); + }, + onError: (error) => { + compacting = false; + reject(error); + }, + }); + }); + }); + + pi.on("before_agent_start", async () => { + await compactionDone; }); pi.on("tool_call", async (event) => { @@ -68,6 +113,17 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void { return { block: true, reason: "The supervisor may inspect or control only its retained goal-worker." }; }); + pi.registerTool({ + name: "CheckWorkerState", + label: "Check retained worker", + description: "Return concise retained-worker state after a needs-attention notice or scheduled review. This does not return transcript text.", + parameters: Type.Object({}), + async execute() { + const state = nested.runId ? (nested.pending ? "active" : "terminal") : "not-started"; + return result(`retained-worker=${state}${nested.runId ? `; run=${nested.runId}` : ""}`); + }, + }); + pi.registerTool({ name: "ApproveGoal", label: "Approve goal", diff --git a/src/worker.ts b/src/worker.ts index 3e580af..b535368 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -39,8 +39,9 @@ interface AsyncSnapshot { export type WorkState = "active" | "idle" | "unknown"; 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 +Your forked planning history is compacted before your first turn. Launch one goal-worker, then rely on native progress +and completion updates. Do not poll status, wait, or repeatedly steer an active worker. Use CheckWorkerState once only +after a needs-attention notice or a scheduled review. 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 @@ -54,7 +55,7 @@ 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", "ApproveGoal"], + tools: ["read", "grep", "find", "ls", "bash", "subagent", "CheckWorkerState", "ApproveGoal"], allowNestedSubagents: true, subagentOnlyExtensions: [supervisorRuntime], ...(model ? { model } : {}), @@ -63,7 +64,7 @@ export function registerGoalSupervisor(events: EventBus, model: string | null): inheritProjectContext: false, inheritGlobalContext: false, inheritSkills: false, - defaultContext: "fresh", + defaultContext: "fork", defaultAsync: true, defaultProgress: true, }, @@ -112,14 +113,15 @@ function asyncRunId(data: RpcData): string { return runId; } -export async function startGoalSupervisor(events: EventBus, cwd: string, task: string, signal?: AbortSignal): Promise { +export async function startGoalSupervisor(events: EventBus, cwd: string, task: string, compactPlanning: boolean, signal?: AbortSignal): Promise { const data = await rpc(events, "spawn", { agent: SUPERVISOR_AGENT, task, cwd, - context: "fresh", + context: "fork", async: true, mission: false, + extensionBindings: { "pi-goals/1": { compactPlanning } }, }, signal); return asyncRunId(data); } diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index 11ca363..03b6630 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: "fresh" } }); + expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fork" } }); 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"); @@ -339,8 +339,10 @@ describe("/goals draft flow", () => { expect(ready.compactCalls).toHaveLength(0); expect(ready.rpcRequests).toHaveLength(1); + expect(ready.rpcRequests[0]).toMatchObject({ params: { extensionBindings: { "pi-goals/1": { compactPlanning: false } } } }); expect(compacted.compactCalls).toHaveLength(1); expect(compacted.rpcRequests).toHaveLength(1); + expect(compacted.rpcRequests[0]).toMatchObject({ params: { extensionBindings: { "pi-goals/1": { compactPlanning: true } } } }); const resync = await compacted.hooks.get("context")({ messages: [] }, compacted.ctx); expect(resync.messages.at(-1).content[0].text).toContain("The main coordinator was compacted after the retained supervisor started."); } finally { @@ -430,7 +432,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: "fresh" } }); + expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fork" } }); 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 8b27517..b7d240c 100644 --- a/test/supervisor-runtime.test.ts +++ b/test/supervisor-runtime.test.ts @@ -29,6 +29,8 @@ function setup() { execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-qm", "initial"], { cwd }); const hooks = new Map(); const tools = new Map(); + const entries: any[] = []; + const compactCalls: any[] = []; const events = new Events(); events.on("subagents:rpc:v1:request", (raw) => { const request = raw as any; @@ -42,17 +44,18 @@ function setup() { }); const ctx = { cwd, - sessionManager: { getSessionId: () => "supervisor-session" }, + sessionManager: { getSessionId: () => "supervisor-session", getEntries: () => entries }, + compact: (options: any) => compactCalls.push(options), ui: { notify() {} }, }; const pi = { events, on: (name: string, handler: any) => hooks.set(name, handler), - appendEntry() {}, + appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }), registerTool: (tool: any) => tools.set(tool.name, tool), }; supervisorRuntime(pi as any); - return { cwd, ctx, events, hooks, tools }; + return { cwd, ctx, events, hooks, tools, entries, compactCalls }; } describe("supervisor-only runtime", () => { @@ -70,10 +73,34 @@ describe("supervisor-only runtime", () => { } }); + it("compacts a requested fork before the first supervisor turn", async () => { + const previous = process.env.PI_SUBAGENT_EXTENSION_BINDINGS; + process.env.PI_SUBAGENT_EXTENSION_BINDINGS = JSON.stringify({ "pi-goals/1": { compactPlanning: true } }); + const runtime = setup(); + try { + await runtime.hooks.get("session_start")({}, runtime.ctx); + expect(runtime.compactCalls).toHaveLength(1); + const replacement = await runtime.hooks.get("session_before_compact")({ + preparation: { firstKeptEntryId: "old", tokensBefore: 70_000 }, + branchEntries: [{ id: "recent", type: "message", message: { role: "assistant" } }], + }, runtime.ctx); + expect(replacement.compaction).toMatchObject({ firstKeptEntryId: "recent", tokensBefore: 70_000 }); + runtime.compactCalls[0].onComplete({}); + await runtime.hooks.get("before_agent_start")({}, runtime.ctx); + expect(runtime.entries).toContainEqual({ type: "custom", customType: "pi-goals-supervisor-compacted", data: { version: 1 } }); + } finally { + if (previous === undefined) delete process.env.PI_SUBAGENT_EXTENSION_BINDINGS; + else process.env.PI_SUBAGENT_EXTENSION_BINDINGS = previous; + rmSync(runtime.cwd, { recursive: true, force: true }); + } + }); + it("blocks approval while its retained worker is pending", async () => { const runtime = setup(); try { runtime.events.emit("subagent:async-started", { id: "nested-1", agent: "goal-worker" }); + const state = await runtime.tools.get("CheckWorkerState").execute("", {}, undefined, undefined, runtime.ctx); + expect(state.content[0].text).toBe("retained-worker=active; run=nested-1"); const blocked = await runtime.tools.get("ApproveGoal").execute("", {}, undefined, undefined, runtime.ctx); expect(blocked.isError).toBe(true); expect(blocked.content[0].text).toContain("retained worker is pending"); @@ -93,6 +120,8 @@ describe("supervisor-only runtime", () => { 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 state = await runtime.tools.get("CheckWorkerState").execute("", {}, undefined, undefined, runtime.ctx); + expect(state.content[0].text).toBe("retained-worker=terminal; run=nested-1"); 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 4ce8c0d..1f366ac 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -46,23 +46,24 @@ describe("goal hierarchy registration", () => { registerGoalSupervisor(events, "provider/cheap-model"); expect(definition?.model).toBe("provider/cheap-model"); - expect(definition?.defaultContext).toBe("fresh"); + expect(definition?.defaultContext).toBe("fork"); 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", "ApproveGoal"]); + expect(definition?.tools).toEqual(["read", "grep", "find", "ls", "bash", "subagent", "CheckWorkerState", "ApproveGoal"]); expect(definition?.subagentOnlyExtensions).toEqual([expect.stringContaining("supervisor-runtime.ts")]); expect(supervisorSystemPrompt).toContain("Launch one goal-worker"); expect(supervisorSystemPrompt).toContain("Do not poll status"); + expect(supervisorSystemPrompt).toContain("forked planning history is compacted"); expect(supervisorSystemPrompt).toContain("ApproveGoal"); }); }); describe("goal worker RPC", () => { - it("starts fresh, resumes retained context, and steers a live run", async () => { + it("starts from a fork, resumes retained context, and steers a live run", async () => { const events = new Events(); const requests: any[] = []; replyToRpc(events, (request) => { @@ -70,12 +71,12 @@ describe("goal worker RPC", () => { return { text: "ok", details: { asyncId: `run-${requests.length}` } }; }); - await expect(startGoalSupervisor(events, "/repo", "start")).resolves.toBe("run-1"); + await expect(startGoalSupervisor(events, "/repo", "start", true)).resolves.toBe("run-1"); await expect(resumeGoalSupervisor(events, "run-1", "continue")).resolves.toBe("run-2"); await steerGoalSupervisor(events, "run-2", "report"); await stopGoalSupervisor(events, "run-2"); - expect(requests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", cwd: "/repo", context: "fresh", async: true } }); + expect(requests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", cwd: "/repo", context: "fork", async: true, extensionBindings: { "pi-goals/1": { compactPlanning: 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" } });