mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-12 12:50:58 +08:00
Compact the supervisor fork before work
Co-Authored-By: PI[gpt-5.6-sol] <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
co-authored by
PI[gpt-5.6-sol]
parent
3eaaec9f5a
commit
6cfeaf44ee
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
+5
-5
@@ -187,7 +187,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function startOrResumeWorker(ctx: ExtensionContext, task: string, signal?: AbortSignal): Promise<string> {
|
||||
async function startOrResumeWorker(ctx: ExtensionContext, task: string, compactPlanning: boolean, signal?: AbortSignal): Promise<string> {
|
||||
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<string> {
|
||||
async function directSupervisor(ctx: ExtensionContext, instruction: string, signal?: AbortSignal, compactPlanning = false): Promise<string> {
|
||||
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) {
|
||||
|
||||
@@ -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, unknown>): 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<NestedState>(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<void>((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",
|
||||
|
||||
+8
-6
@@ -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<string> {
|
||||
export async function startGoalSupervisor(events: EventBus, cwd: string, task: string, compactPlanning: boolean, signal?: AbortSignal): Promise<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, any>();
|
||||
const tools = new Map<string, any>();
|
||||
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",
|
||||
|
||||
+6
-5
@@ -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" } });
|
||||
|
||||
Reference in New Issue
Block a user