mirror of
https://github.com/wassname/pi-plan.git
synced 2026-09-26 14:10:23 +08:00
Simplify nested goal supervision
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
844099bdf0
commit
48e2247c00
+3
-3
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* PI: pi-goals owns one versioned plan per session. The main agent is a thin coordinator for a
|
||||
* retained pi-subagents supervisor, which owns a nested retained implementation worker and approval.
|
||||
* retained pi-subagents supervisor, which owns one foreground implementation worker at a time and approval.
|
||||
*
|
||||
* Each /goals call makes `.pi/plan/<session_id>-vN.md`. The selected version survives resume and
|
||||
* compaction. Old plans stay on disk but inactive. A session with no selected plan has no widget,
|
||||
@@ -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, compactPlanning, signal);
|
||||
: await startGoalSupervisor(pi.events, ctx.cwd, task, compactPlanning, state.workerModel, signal);
|
||||
rememberWorkerRun(runId);
|
||||
return runId;
|
||||
} finally {
|
||||
@@ -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.", undefined, choice === "Ready (compact)");
|
||||
await directSupervisor(ctx, "Start by launching the foreground implementation worker. Then supervise the current plan.", undefined, choice === "Ready (compact)");
|
||||
scheduleSupervisorCheck(ctx);
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* SETUP (plan mode) 1. planDrafting — draft goals into the plan file (read-only), sent once
|
||||
* EXEC, after compact 2. resync — the WHOLE file back, once
|
||||
* SIGN-OFF, agent-side 3. completeGoal* — the one blessed tool's description
|
||||
* SUPERVISION worker.ts - retained implementation worker
|
||||
* SUPERVISION worker.ts - retained supervisor and foreground worker
|
||||
*
|
||||
* The goal's test is the DISCRIMINATOR: the concrete observation that tells real success from the
|
||||
* named subtle failure mode. Evidence is empty at planning and filled at sign-off.
|
||||
|
||||
+55
-61
@@ -4,52 +4,46 @@ 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, retainedRunState } from "./worker.js";
|
||||
import { GOAL_WORKER_AGENT, processWorkState } from "./worker.js";
|
||||
|
||||
const NESTED_STATE = "pi-goals-nested-worker";
|
||||
const COMPACTED_STATE = "pi-goals-supervisor-compacted";
|
||||
|
||||
interface NestedState {
|
||||
runId: string | null;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
function result(text: string, isError = false) {
|
||||
return { content: [{ type: "text" as const, text }], details: {}, isError };
|
||||
}
|
||||
|
||||
function targetRun(input: Record<string, unknown>): string | null {
|
||||
const value = input.id ?? input.runId;
|
||||
return typeof value === "string" && value ? value : null;
|
||||
interface GoalBindings {
|
||||
compactPlanning?: boolean;
|
||||
workerModel?: string | null;
|
||||
}
|
||||
|
||||
function compactPlanningRequested(): boolean {
|
||||
function goalBindings(): GoalBindings {
|
||||
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;
|
||||
if (!raw) return {};
|
||||
const binding = (JSON.parse(raw) as { "pi-goals/1"?: GoalBindings })["pi-goals/1"] ?? {};
|
||||
if (binding.workerModel !== undefined && binding.workerModel !== null && typeof binding.workerModel !== "string") throw new Error("pi-goals workerModel binding must be a string or null.");
|
||||
return binding;
|
||||
}
|
||||
|
||||
function messageLaunchesWorker(ctx: { sessionManager: { getBranch(): unknown[] } }): boolean {
|
||||
const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => {
|
||||
const value = candidate as { type?: unknown; message?: { role?: unknown } };
|
||||
return value.type === "message" && value.message?.role === "assistant";
|
||||
}) as { message?: { content?: unknown } } | undefined;
|
||||
if (!Array.isArray(entry?.message?.content)) return false;
|
||||
return entry.message.content.some((part) => {
|
||||
const value = part as { type?: unknown; name?: unknown; arguments?: Record<string, unknown> };
|
||||
return value.type === "toolCall" && value.name === "subagent" && value.arguments?.agent === GOAL_WORKER_AGENT;
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
const event = raw as { id?: unknown; agent?: unknown };
|
||||
if (event.agent !== "goal-worker" || typeof event.id !== "string") return;
|
||||
nested = { runId: event.id, pending: true };
|
||||
persist();
|
||||
});
|
||||
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);
|
||||
let currentTurn = -1;
|
||||
let completedWorkerTurn: number | null = null;
|
||||
let workerModel: string | null = null;
|
||||
const activeWorkerCalls = new Set<string>();
|
||||
|
||||
pi.on("session_before_compact", async (event) => {
|
||||
if (!compacting) return;
|
||||
@@ -67,15 +61,9 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void {
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
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 (nested.pending && nested.runId && (await retainedRunState(pi.events, nested.runId)) === "idle") {
|
||||
nested = { ...nested, pending: false };
|
||||
persist();
|
||||
}
|
||||
if (!compactPlanningRequested()) return;
|
||||
const bindings = goalBindings();
|
||||
workerModel = bindings.workerModel ?? null;
|
||||
if (bindings.compactPlanning !== true) 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) => {
|
||||
@@ -97,6 +85,11 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void {
|
||||
await compactionDone;
|
||||
});
|
||||
|
||||
pi.on("turn_start", async (event) => {
|
||||
activeWorkerCalls.clear();
|
||||
currentTurn = event.turnIndex;
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (event.toolName === "edit" || event.toolName === "write") {
|
||||
return { block: true, reason: "Goal supervision is read-only. Direct project changes to the nested goal-worker." };
|
||||
@@ -106,34 +99,33 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void {
|
||||
}
|
||||
if (event.toolName !== "subagent") return;
|
||||
const input = event.input as Record<string, unknown>;
|
||||
const action = typeof input.action === "string" ? input.action : null;
|
||||
if (!action) {
|
||||
if (input.agent === "goal-worker" && !nested.pending && input.workflowScript === undefined && input.workflowScriptPath === undefined) return;
|
||||
return { block: true, reason: nested.pending ? "Wait for the retained goal-worker instead of starting another worker." : "The supervisor may start only goal-worker." };
|
||||
const allowedKeys = new Set(["agent", "task", "async", "context", ...(workerModel ? ["model"] : [])]);
|
||||
const unexpectedKeys = Object.keys(input).filter((key) => !allowedKeys.has(key));
|
||||
const validWorker = input.agent === GOAL_WORKER_AGENT
|
||||
&& typeof input.task === "string"
|
||||
&& input.task.trim().length > 0
|
||||
&& input.async === false
|
||||
&& input.context === "fork"
|
||||
&& (workerModel ? input.model === workerModel : input.model === undefined)
|
||||
&& unexpectedKeys.length === 0;
|
||||
if (!validWorker) {
|
||||
const model = workerModel ? `, model:${JSON.stringify(workerModel)}` : "";
|
||||
return { block: true, reason: `Launch only ${GOAL_WORKER_AGENT} with task, async:false, context:"fork"${model}, and no other fields.` };
|
||||
}
|
||||
if (action === "list") 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) {
|
||||
if (nested.pending) return;
|
||||
return { block: true, reason: "The retained goal-worker is terminal; start a replacement worker for a correction." };
|
||||
}
|
||||
return { block: true, reason: "The supervisor may inspect or control only its retained goal-worker." };
|
||||
if (activeWorkerCalls.size > 0) return { block: true, reason: "A foreground goal-worker is already running." };
|
||||
activeWorkerCalls.add(event.toolCallId);
|
||||
completedWorkerTurn = null;
|
||||
});
|
||||
|
||||
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.on("tool_result", async (event) => {
|
||||
if (!activeWorkerCalls.delete(event.toolCallId)) return;
|
||||
if (!event.isError) completedWorkerTurn = currentTurn;
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "ApproveGoal",
|
||||
label: "Approve goal",
|
||||
executionMode: "sequential",
|
||||
description: "Record approval after inspecting the plan, repository, evidence, and saved verification output. Active or unknown work blocks approval.",
|
||||
parameters: Type.Object({
|
||||
approvalId: Type.String({ minLength: 1, description: "Exact approval ID from the latest main-coordinator direction." }),
|
||||
@@ -146,7 +138,9 @@ export default function goalSupervisorRuntime(pi: ExtensionAPI): void {
|
||||
inspectedVerifyOutput: Type.Literal(true),
|
||||
}),
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
if (nested.pending) return result("Cannot approve while the retained worker is pending.", true);
|
||||
if (messageLaunchesWorker(ctx) || activeWorkerCalls.size > 0 || completedWorkerTurn === null || completedWorkerTurn >= currentTurn) {
|
||||
return result("Cannot approve in a worker-launch message or before reviewing a finished worker on a later turn.", true);
|
||||
}
|
||||
const processes = processWorkState(pi.events);
|
||||
if (processes !== "idle") return result(`Cannot approve: processes=${processes}.`, true);
|
||||
const planPath = resolve(params.planPath);
|
||||
|
||||
+33
-49
@@ -7,6 +7,7 @@ const RPC_REPLY_PREFIX = "subagents:rpc:v1:reply:";
|
||||
const RPC_VERSION = 1;
|
||||
const RPC_TIMEOUT_MS = 15_000;
|
||||
export const SUPERVISOR_AGENT = "goal-supervisor";
|
||||
export const GOAL_WORKER_AGENT = "pi-goals-worker-v1";
|
||||
|
||||
interface EventBus {
|
||||
on(event: string, handler: (data: unknown) => void): () => void;
|
||||
@@ -39,44 +40,43 @@ 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.
|
||||
Your forked planning history is compacted before your first turn. Launch one goal-worker, then call bg_wait with its run ID
|
||||
so this supervisory turn stays alive until the worker completes or needs attention. Do not poll status or repeatedly steer
|
||||
an active worker. Use CheckWorkerState once only after a needs-attention notice or a scheduled review. If a terminal worker
|
||||
needs a correction, launch one replacement goal-worker instead of resuming its old run ID. 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`;
|
||||
Your forked planning history may be compacted before your first turn. Launch ${GOAL_WORKER_AGENT} in the foreground with exactly
|
||||
agent, task, async:false, context:"fork", and, when named in the current direction, that worker model. Wait for its result; do not use
|
||||
bg_wait or worker run IDs. 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. If the evidence needs a correction, launch a new foreground ${GOAL_WORKER_AGENT} with one concrete task
|
||||
and wait for it. On a later turn, when HEAD is committed, the worktree is clean, and the evidence proves the discriminator, call
|
||||
ApproveGoal with the current approval ID. 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));
|
||||
const request: Record<string, unknown> = {
|
||||
version: 1,
|
||||
name: SUPERVISOR_AGENT,
|
||||
definition: {
|
||||
description: "Read-only supervisor that owns a nested retained implementation worker.",
|
||||
systemPrompt: supervisorSystemPrompt,
|
||||
tools: ["read", "grep", "find", "ls", "bash", "subagent", "bg_wait", "CheckWorkerState", "ApproveGoal"],
|
||||
allowNestedSubagents: true,
|
||||
subagentOnlyExtensions: [supervisorRuntime],
|
||||
...(model ? { model } : {}),
|
||||
systemPromptMode: "replace",
|
||||
thinking: "low",
|
||||
inheritProjectContext: false,
|
||||
inheritGlobalContext: false,
|
||||
inheritSkills: false,
|
||||
defaultContext: "fork",
|
||||
defaultAsync: true,
|
||||
defaultProgress: true,
|
||||
},
|
||||
};
|
||||
function registerRuntimeAgent(events: EventBus, name: string, definition: Record<string, unknown>): Registration {
|
||||
const request: Record<string, unknown> = { version: 1, name, definition };
|
||||
events.emit(REGISTER_EVENT, request);
|
||||
const result = request.result as { ok?: boolean; registration?: Registration; error?: Error } | undefined;
|
||||
if (!result) throw new Error("pi-subagents is not installed or not ready.");
|
||||
if (!result.ok || !result.registration) throw result.error ?? new Error("pi-subagents rejected the goal-supervisor agent.");
|
||||
if (!result.ok || !result.registration) throw result.error ?? new Error(`pi-subagents rejected the ${name} agent.`);
|
||||
return result.registration;
|
||||
}
|
||||
|
||||
export function registerGoalSupervisor(events: EventBus, model: string | null): Registration {
|
||||
const supervisorRuntime = fileURLToPath(new URL("./supervisor-runtime.ts", import.meta.url));
|
||||
return registerRuntimeAgent(events, SUPERVISOR_AGENT, {
|
||||
description: "Read-only supervisor that owns a foreground implementation worker.",
|
||||
systemPrompt: supervisorSystemPrompt,
|
||||
tools: ["read", "grep", "find", "ls", "bash", "subagent", "ApproveGoal"],
|
||||
allowNestedSubagents: true,
|
||||
subagentOnlyExtensions: [supervisorRuntime],
|
||||
...(model ? { model } : {}),
|
||||
systemPromptMode: "replace",
|
||||
thinking: "low",
|
||||
inheritProjectContext: false,
|
||||
inheritGlobalContext: false,
|
||||
inheritSkills: false,
|
||||
defaultContext: "fork",
|
||||
defaultAsync: true,
|
||||
defaultProgress: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function rpc(events: EventBus, method: "spawn" | "resume" | "steer" | "status" | "stop", params: Record<string, unknown>, signal?: AbortSignal): Promise<RpcData> {
|
||||
if (signal?.aborted) throw new Error("Goal-worker request aborted.");
|
||||
const requestId = randomUUID();
|
||||
@@ -114,7 +114,7 @@ function asyncRunId(data: RpcData): string {
|
||||
return runId;
|
||||
}
|
||||
|
||||
export async function startGoalSupervisor(events: EventBus, cwd: string, task: string, compactPlanning: boolean, signal?: AbortSignal): Promise<string> {
|
||||
export async function startGoalSupervisor(events: EventBus, cwd: string, task: string, compactPlanning: boolean, workerModel: string | null, signal?: AbortSignal): Promise<string> {
|
||||
const data = await rpc(events, "spawn", {
|
||||
agent: SUPERVISOR_AGENT,
|
||||
task,
|
||||
@@ -122,7 +122,7 @@ export async function startGoalSupervisor(events: EventBus, cwd: string, task: s
|
||||
context: "fork",
|
||||
async: true,
|
||||
mission: false,
|
||||
extensionBindings: { "pi-goals/1": { compactPlanning } },
|
||||
extensionBindings: { "pi-goals/1": { compactPlanning, workerModel } },
|
||||
}, signal);
|
||||
return asyncRunId(data);
|
||||
}
|
||||
@@ -157,15 +157,6 @@ function validSnapshot(snapshot: AsyncSnapshot | undefined): snapshot is AsyncSn
|
||||
return snapshot?.kind === "pi-subagents.async-status-snapshot" && snapshot.version === 1 && snapshot.omitted.runs === 0 && snapshot.omitted.children === 0 && !snapshot.omitted.byteLimitExceeded;
|
||||
}
|
||||
|
||||
function findNode(nodes: AsyncNode[], runId: string): AsyncNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === runId) return node;
|
||||
const child = node.children && findNode(node.children, runId);
|
||||
if (child) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function asyncSnapshot(events: EventBus): Promise<AsyncSnapshot | undefined> {
|
||||
return (await rpc(events, "status", {})).asyncSnapshot;
|
||||
}
|
||||
@@ -176,13 +167,6 @@ export async function subagentWorkState(events: EventBus): Promise<WorkState> {
|
||||
return snapshot.runs.some(activeNode) ? "active" : "idle";
|
||||
}
|
||||
|
||||
export async function retainedRunState(events: EventBus, runId: string): Promise<WorkState> {
|
||||
const snapshot = await asyncSnapshot(events);
|
||||
if (!validSnapshot(snapshot)) return "unknown";
|
||||
const node = findNode(snapshot.runs, runId);
|
||||
return node && activeNode(node) ? "active" : "idle";
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
status: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user