mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-20 13:10:47 +08:00
Recover paused supervision and tighten approval boundaries
Restore bindings before model availability checks, require explicit reconnect/restart recovery, detach inactive plans, remove the general Intercom actuator, and reject placeholder evidence without hashing the plan log. Preserve synchronous handoff-before-ack; document that the void SDK cannot confirm durable message delivery.
This commit is contained in:
@@ -15,7 +15,7 @@ Plan in one Pi session, then do the work there while a stronger visible Pi sessi
|
||||
|
||||
The two Pi sessions are visible. You can switch to the supervisor pane and talk to it directly. Supervisor instructions are shown in full, including in collapsed tool rows; ordinary messages and emitted thinking use Pi's display settings. The supervisor is prompted to give brief progress assessments and use judgment about when to intervene.
|
||||
|
||||
On resume, monitoring and read-only tools are restored. Views include the latest human direction, source-session path, worker model, and new messages since the last acknowledged view. They report Pi idleness and tracked process/subagent activity separately. Unavailable trackers stay unknown; unregistered detached jobs are not tracked. Approval is blocked while tracked work is active or unknown. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. A receipt confirms transport handling, not execution. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI
|
||||
On resume, monitoring and read-only tools are restored. Views include the latest human direction, source-session path, worker model, and new messages since the last acknowledged view. They report Pi idleness and tracked process/subagent activity separately. Unavailable trackers stay unknown; unregistered detached jobs are not tracked. Approval is blocked while tracked work is active or unknown. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. A receipt confirms adapter handling only—not durable queue persistence, model receipt, or execution. Pi's void message API can fail asynchronously after that acknowledgement; crashes can also cause duplicate handoffs. End-to-end exactly-once or durable delivery is not guaranteed. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI
|
||||
|
||||
## Install
|
||||
|
||||
@@ -44,6 +44,14 @@ Run Pi from the Git repository that the plan will change. **Ready** fails if the
|
||||
|
||||
`/goals clear` keeps the plan file. Starting another plan also keeps older versions.
|
||||
|
||||
If a required model or supervisor is unavailable, the widget says **goals paused** and implementation/sign-off tools are gated. Human input, read-only diagnosis, `/model`, and recovery commands remain available:
|
||||
|
||||
- `/goals reconnect` retries the remembered role model and existing supervisor binding. Reconnect waits five seconds and never replaces a slow or missing pane automatically. A returning peer clears the connection pause automatically.
|
||||
- `/goals restart` explicitly closes only the tracked supervisor pane and starts a replacement for a working plan, preserving its file/version but invalidating old approvals. During planning it clears the failed pane so Ready can launch again.
|
||||
- In the supervisor pane, use `/model` then `/goals reconnect` to recover an unavailable supervisor model.
|
||||
|
||||
A new supervisor may still need up to five minutes for initial compaction. Recovery does not terminate background jobs. Planning/diagnostic command checks are guardrails, not an OS sandbox; loaded extensions and repository Git configuration must be trusted.
|
||||
|
||||
Model choices are remembered per project and role in `.pi/pi-goals/models/`. Use `/model` in planning, worker, or supervisor sessions to change that role's choice. Ready restores the worker choice after the planning fork is ready. An unavailable saved model stops the transition instead of substituting another. `/goals model <model>` explicitly overrides the supervisor choice for launch. -- Pi/OpenAI
|
||||
|
||||
## Plan format
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ export function repositoryState(cwd: string): { repoRoot: string; head: string;
|
||||
}
|
||||
|
||||
export function goalBlock(plan: string, goal: string): string | null {
|
||||
const lines = plan.split("\n");
|
||||
const lines = plan.split(/^##\s+Log\s*$/im, 1)[0].split("\n");
|
||||
const wanted = goal.trim().toLowerCase();
|
||||
const hits = lines.flatMap((line, index) => {
|
||||
const match = GOAL_LINE.exec(line);
|
||||
@@ -52,12 +52,12 @@ export function goalBlock(plan: string, goal: string): string | null {
|
||||
const start = hits[0];
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index++) {
|
||||
if (GOAL_LINE.test(lines[index])) {
|
||||
if (GOAL_LINE.test(lines[index]) || /^#{1,2}\s/.test(lines[index])) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.slice(start, end).join("\n");
|
||||
return lines.slice(start, end).join("\n").trimEnd();
|
||||
}
|
||||
|
||||
export function hashGoalBlock(block: string): string {
|
||||
|
||||
+98
-15
@@ -111,7 +111,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const intercom = new GoalIntercom(pi);
|
||||
const models = new RoleModels(pi);
|
||||
intercom.onSteer = (instruction) => {
|
||||
if (state.phase !== "working") throw new Error("Worker plan is not active; instruction rejected.");
|
||||
if (state.phase !== "working" || modelError) throw new Error("Worker is paused or its plan is not active; instruction not delivered. Use /goals reconnect after selecting an available model.");
|
||||
pi.sendUserMessage(`[supervisor] ${instruction}`, { deliverAs: "steer" });
|
||||
};
|
||||
let state: PlanState = {
|
||||
@@ -122,6 +122,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
planVersion: null,
|
||||
latestDirection: "",
|
||||
};
|
||||
let modelError: string | null = null;
|
||||
intercom.onConnectionChange = (ctx) => updateWidget(ctx);
|
||||
let planningContextPending = false;
|
||||
let resyncReason: string | null = "New session.";
|
||||
|
||||
@@ -142,6 +144,24 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
pi.appendEntry<PlanState>(STATE, state);
|
||||
}
|
||||
|
||||
function pauseReason(): string | null {
|
||||
if (!state.phase) return null;
|
||||
if (modelError) return `${modelError} Select /model, then run /goals reconnect.`;
|
||||
if (state.phase === "working" && !intercom.connected) return "Supervisor disconnected. Run /goals reconnect, or /goals restart to replace its tracked pane without discarding the plan.";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function restoreModel(role: "planning" | "worker", ctx: ExtensionContext): Promise<void> {
|
||||
modelError = `${role} model restoration is pending.`;
|
||||
try {
|
||||
await models.enter(role, ctx);
|
||||
modelError = null;
|
||||
} catch (error) {
|
||||
modelError = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function beginReview(ctx: ExtensionContext): void {
|
||||
for (const goal of scanGoals(readPlan(ctx))) {
|
||||
rmSync(approvalPath(ctx.cwd, ctx.sessionManager.getSessionId(), goal.subject), { force: true });
|
||||
@@ -157,12 +177,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
async function startSupervisor(ctx: ExtensionContext): Promise<void> {
|
||||
if (intercom.ended) throw new Error("Session ended before supervisor startup.");
|
||||
repositoryRoot(ctx.cwd);
|
||||
const sourceSessionFile = ctx.sessionManager.getSessionFile();
|
||||
if (!sourceSessionFile) throw new Error("The current session is not persisted, so it cannot be forked.");
|
||||
if (state.supervisorPaneId && state.approvalId) {
|
||||
intercom.configure(state.approvalId, "worker", ctx);
|
||||
await intercom.waitReady();
|
||||
await intercom.waitReady(5000);
|
||||
return;
|
||||
}
|
||||
beginReview(ctx);
|
||||
@@ -177,6 +198,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
extensionPath: fileURLToPath(import.meta.url),
|
||||
model: state.supervisorModel,
|
||||
}, (opened) => {
|
||||
if (intercom.ended) throw new Error("Session ended during supervisor startup.");
|
||||
paneId = opened;
|
||||
state = { ...state, supervisorPaneId: opened };
|
||||
persist();
|
||||
});
|
||||
@@ -184,6 +207,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
if (paneId) throw new Error(`Supervisor startup failed in Herdr pane ${paneId}; it remains open for inspection. ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
if (intercom.ended) throw new Error("Session ended during supervisor startup.");
|
||||
state = { ...state, supervisorPaneId: paneId };
|
||||
persist();
|
||||
await intercom.waitReady();
|
||||
@@ -194,11 +218,11 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
let viewTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): Promise<void> {
|
||||
if (state.phase !== "working" || intercom.ended) return;
|
||||
if (state.phase !== "working" || modelError || !intercom.bound) return;
|
||||
const generation = ++viewGeneration;
|
||||
const binding = state.approvalId;
|
||||
const background = reason === "started" ? { quiet: false, description: "agent starting; background state not queried" } : await backgroundState(pi);
|
||||
if (intercom.ended || generation !== viewGeneration || binding !== state.approvalId || state.phase !== "working") return;
|
||||
if (!intercom.bound || modelError || generation !== viewGeneration || binding !== state.approvalId || state.phase !== "working") return;
|
||||
const entries = ctx.sessionManager.getBranch();
|
||||
const view = workerView(entries, reason, reason !== "started" && ctx.isIdle(), {
|
||||
sourceSession: ctx.sessionManager.getSessionFile()!, latestDirection: state.latestDirection,
|
||||
@@ -212,6 +236,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
state = { ...state, phase: null };
|
||||
models.leave();
|
||||
persist();
|
||||
intercom.detach();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +252,11 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
async function stopSupervisor(): Promise<boolean> {
|
||||
stopWorkerTimers();
|
||||
if (!state.supervisorPaneId) return true;
|
||||
if (!state.supervisorPaneId) { stopWorkerTimers(); intercom.detach(); return true; }
|
||||
try {
|
||||
await closeSupervisorPane(state.supervisorPaneId);
|
||||
stopWorkerTimers();
|
||||
intercom.detach();
|
||||
state = { ...state, supervisorPaneId: null };
|
||||
persist();
|
||||
return true;
|
||||
@@ -240,6 +266,12 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
function updateWidget(ctx: ExtensionContext): void {
|
||||
const paused = pauseReason();
|
||||
if (paused) {
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "goals paused"));
|
||||
ctx.ui.setWidget(WIDGET_KEY, [`pi-goals paused: ${paused}`]);
|
||||
return;
|
||||
}
|
||||
if (state.phase === "planning") {
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "planning"));
|
||||
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]);
|
||||
@@ -271,9 +303,39 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
// --- /goals: enter plan mode or configure supervision -- Pi/Codex -----------------------------
|
||||
|
||||
pi.registerCommand("goals", {
|
||||
description: `Plan goals, then open a visible supervisor session. /goals <objective> | clear | model <supervisor>`,
|
||||
description: `Plan goals, then open a visible supervisor session. /goals <objective> | reconnect | restart | clear | model <supervisor>`,
|
||||
handler: async (args, ctx) => {
|
||||
const arg = args.trim();
|
||||
if (arg === "reconnect" || arg === "restart") {
|
||||
if (!state.phase) { ctx.ui.notify("No active plan to recover.", "info"); return; }
|
||||
if (!ctx.isIdle()) { ctx.ui.notify("Stop the current turn before recovering goal supervision.", "warning"); return; }
|
||||
try {
|
||||
await restoreModel(state.phase === "planning" ? "planning" : "worker", ctx);
|
||||
if (arg === "restart") {
|
||||
if (!(await stopSupervisor())) throw new Error("Could not close the tracked supervisor pane; no replacement was opened.");
|
||||
state = { ...state, supervisorPaneId: null, approvalId: null };
|
||||
persist();
|
||||
}
|
||||
if (state.phase === "working" || state.supervisorPaneId) {
|
||||
if (arg === "reconnect") {
|
||||
if (!state.approvalId) throw new Error("No saved supervision binding. Use /goals restart.");
|
||||
intercom.configure(state.approvalId, "worker", ctx);
|
||||
await intercom.waitReady(5000);
|
||||
} else await startSupervisor(ctx);
|
||||
}
|
||||
if (intercom.ended) return;
|
||||
if (state.phase === "working") {
|
||||
startWorkerTimers(ctx);
|
||||
await publishWorkerView(ctx, "settled");
|
||||
}
|
||||
ctx.ui.notify(state.phase === "planning" ? "Planning model restored. Choose Ready when the plan is agreed." : "Goal supervision reconnected; the current plan is unchanged.", "info");
|
||||
} catch (error) {
|
||||
if (intercom.ended) return;
|
||||
ctx.ui.notify(`Goal recovery failed: ${String(error)} Use /goals reconnect to retry, or /goals restart to explicitly replace the tracked pane.`, "warning");
|
||||
}
|
||||
updateWidget(ctx);
|
||||
return;
|
||||
}
|
||||
if (arg === "clear") {
|
||||
if (state.planVersion === null) {
|
||||
ctx.ui.notify("No active plan to disconnect.", "info");
|
||||
@@ -286,6 +348,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null };
|
||||
models.leave();
|
||||
modelError = null;
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info");
|
||||
@@ -310,7 +373,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
ctx.ui.notify("Could not close the visible supervisor; no new plan was started.", "warning");
|
||||
return;
|
||||
}
|
||||
await models.enter("planning", ctx);
|
||||
await restoreModel("planning", ctx);
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg };
|
||||
planningContextPending = true;
|
||||
resyncReason = null;
|
||||
@@ -339,6 +402,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
|
||||
// The phase snapshot enters context only when planning starts or context was lost.
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
const paused = pauseReason();
|
||||
if (paused) return { systemPrompt: `${ctx.getSystemPrompt()}\n\nGoal work is paused: ${paused} Do not implement or sign off goals. Human input and read-only diagnosis remain available; wait for recovery before resuming autonomous work.` };
|
||||
if (state.phase === "working") {
|
||||
return {
|
||||
systemPrompt: `${ctx.getSystemPrompt()}\n\nYou are the implementation worker for ${planRel(ctx)}. Keep the full conversation and do the work directly. A stronger read-only supervisor watches this session through pi-intercom and can steer you. Commit clean evidence before asking for sign-off. Stop when a goal appears complete so the supervisor can inspect a settled worker view. Call CompleteGoal only after the supervisor says it recorded approval. -- PI[Kimi K3]`,
|
||||
@@ -385,6 +450,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
const paused = pauseReason();
|
||||
if (paused && !(["read", "grep", "find", "ls"].includes(event.toolName) || (event.toolName === "bash" && isPlanningReadOnlyCommand(String((event.input as { command?: string }).command))))) {
|
||||
return { block: true, terminate: true, reason: `Goal work is paused: ${paused} Only read-only diagnosis is available.` };
|
||||
}
|
||||
if (state.phase === "planning") {
|
||||
if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) {
|
||||
const target = (event.input as { path?: string }).path;
|
||||
@@ -410,7 +479,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
await publishWorkerView(ctx, "settled");
|
||||
return;
|
||||
}
|
||||
if (state.phase !== "planning" || !ctx.hasUI) return;
|
||||
if (state.phase !== "planning" || modelError || !ctx.hasUI) return;
|
||||
let printed = "";
|
||||
while (true) {
|
||||
if (intercom.ended) return;
|
||||
@@ -440,6 +509,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
continue;
|
||||
}
|
||||
if (choice === "Cancel") {
|
||||
if (!(await stopSupervisor())) { ctx.ui.notify("Could not close the tracked supervisor; plan was not discarded.", "warning"); return; }
|
||||
rmSync(planPath(ctx), { force: true });
|
||||
models.leave();
|
||||
state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null };
|
||||
@@ -452,7 +522,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
try {
|
||||
await startSupervisor(ctx);
|
||||
if (intercom.ended) return;
|
||||
await models.enter("worker", ctx);
|
||||
await restoreModel("worker", ctx);
|
||||
state = { ...state, phase: "working" };
|
||||
resyncReason = "The plan was approved.";
|
||||
persist();
|
||||
@@ -463,7 +533,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
pi.sendUserMessage("The plan is approved. Begin implementation as the worker.");
|
||||
} catch (error) {
|
||||
if (intercom.ended) return;
|
||||
ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
||||
ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)} Use /goals reconnect to retry, or /goals restart to replace the tracked pane.`, "warning");
|
||||
state = { ...state, phase: "planning" };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
@@ -485,13 +555,25 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
planVersion: last?.data?.planVersion ?? null,
|
||||
latestDirection: last?.data?.latestDirection ?? "",
|
||||
};
|
||||
if (state.phase) await models.enter(state.phase === "planning" ? "planning" : "worker", ctx);
|
||||
modelError = state.phase ? "Role model restoration is pending." : null;
|
||||
planningContextPending = state.phase === "planning";
|
||||
resyncReason = state.phase === "working" ? "New session." : null;
|
||||
if (state.phase === "working") {
|
||||
intercom.configure(state.approvalId!, "worker", ctx);
|
||||
if (state.phase === "working" && state.approvalId) {
|
||||
intercom.configure(state.approvalId, "worker", ctx, false);
|
||||
startWorkerTimers(ctx);
|
||||
}
|
||||
try {
|
||||
if (state.phase) await restoreModel(state.phase === "planning" ? "planning" : "worker", ctx);
|
||||
} catch (error) {
|
||||
if (!intercom.ended) ctx.ui.notify(`Goal work paused: ${String(error)} Use /model, then /goals reconnect.`, "warning");
|
||||
}
|
||||
if (intercom.ended) return;
|
||||
if (state.phase === "working" && state.approvalId && !modelError) {
|
||||
intercom.markReady();
|
||||
void intercom.waitReady(5000).then(() => publishWorkerView(ctx, "settled")).catch(error => {
|
||||
if (!intercom.ended && state.phase === "working") ctx.ui.notify(`Goal work paused: ${String(error)} Use /goals reconnect or /goals restart.`, "warning");
|
||||
});
|
||||
}
|
||||
updateWidget(ctx);
|
||||
});
|
||||
|
||||
@@ -508,9 +590,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}),
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
if (state.phase !== "working") return result("Planning is not approved. Choose Ready before signing off a goal.", true);
|
||||
if (pauseReason()) return result(`Goal sign-off blocked: ${pauseReason()}`, true);
|
||||
if (!state.approvalId) return result("Goal sign-off blocked: no current supervisor review.", true);
|
||||
const background = await backgroundState(pi);
|
||||
if (intercom.ended || !background.quiet) return result(`Goal sign-off blocked: ${background.description}`, true);
|
||||
if (intercom.ended || !background.quiet || pauseReason()) return result(`Goal sign-off blocked: ${pauseReason() ?? background.description}`, true);
|
||||
const plan = readPlan(ctx);
|
||||
if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true);
|
||||
const block = goalBlock(plan, params.goal);
|
||||
|
||||
+24
-4
@@ -24,6 +24,7 @@ export class GoalIntercom {
|
||||
acknowledgedEntry?: string;
|
||||
onView: (view: View) => void = () => {};
|
||||
onSteer: (text: string) => void = () => {};
|
||||
onConnectionChange: (ctx: ExtensionContext) => void = () => {};
|
||||
|
||||
constructor(private pi: ExtensionAPI) {
|
||||
pi.events.on("intercom:extension-registry-ready", () => this.register());
|
||||
@@ -39,11 +40,11 @@ export class GoalIntercom {
|
||||
});
|
||||
}
|
||||
|
||||
configure(binding: string, role: Role, ctx: ExtensionContext): void {
|
||||
configure(binding: string, role: Role, ctx: ExtensionContext, ready = role === "worker"): void {
|
||||
this.binding = binding;
|
||||
this.role = role;
|
||||
this.ctx = ctx;
|
||||
this.ready = role === "worker";
|
||||
this.ready = ready;
|
||||
this.peer = undefined;
|
||||
this.peerReady = false;
|
||||
this.pending.clear();
|
||||
@@ -66,9 +67,22 @@ export class GoalIntercom {
|
||||
this.hello();
|
||||
}
|
||||
|
||||
// End this plan's binding without disposing the session's transport.
|
||||
detach(): void {
|
||||
this.ready = false;
|
||||
this.hello();
|
||||
this.binding = "";
|
||||
this.peer = undefined;
|
||||
this.peerReady = false;
|
||||
this.latestView = undefined;
|
||||
this.pending.clear();
|
||||
if (this.ctx) this.onConnectionChange(this.ctx);
|
||||
}
|
||||
|
||||
markReady(): void { if (!this.stopped) { this.ready = true; this.hello(); } }
|
||||
get ended(): boolean { return this.stopped; }
|
||||
get connected(): boolean { return Boolean(this.channel?.snapshot().connected && this.peerReady); }
|
||||
get bound(): boolean { return !this.stopped && Boolean(this.binding); }
|
||||
get connected(): boolean { return Boolean(!this.stopped && this.ready && this.binding && this.channel?.snapshot().connected && this.peerReady); }
|
||||
|
||||
async waitReady(timeoutMs = 300_000): Promise<void> {
|
||||
if (this.connected) return;
|
||||
@@ -86,6 +100,7 @@ export class GoalIntercom {
|
||||
}
|
||||
|
||||
view(text: string, reason: string, through?: string, backgroundQuiet = false): View {
|
||||
if (!this.bound) throw new Error("No active supervision binding for a worker view.");
|
||||
const id = randomUUID();
|
||||
const message: Message = { binding: this.binding, role: this.role, kind: "view", id, text: `${text}\n\nworker view id: ${id}`, reason, through, backgroundQuiet };
|
||||
this.record("out", message);
|
||||
@@ -121,11 +136,13 @@ export class GoalIntercom {
|
||||
this.peer = undefined; this.peerReady = false;
|
||||
}
|
||||
else this.hello();
|
||||
if (this.ctx) this.onConnectionChange(this.ctx);
|
||||
return;
|
||||
}
|
||||
if (event.type === "session_left" && event.sessionId === this.peer) {
|
||||
this.peer = undefined; this.peerReady = false;
|
||||
this.ctx?.ui.notify("Goal supervision peer disconnected; reconnect the existing session.", "warning");
|
||||
if (this.ctx) this.onConnectionChange(this.ctx);
|
||||
return;
|
||||
}
|
||||
if (event.type === "session_joined") { this.hello(); return; }
|
||||
@@ -139,11 +156,12 @@ export class GoalIntercom {
|
||||
this.peerReady = Boolean(message.ready);
|
||||
if (changed) {
|
||||
this.hello();
|
||||
if (this.peerReady) {
|
||||
if (this.peerReady && this.ready) {
|
||||
if (this.role === "worker" && this.latestView) this.publish({ binding: this.binding, role: this.role, kind: "view", ...this.latestView });
|
||||
for (const pending of this.pending.values()) this.publish(pending);
|
||||
}
|
||||
}
|
||||
if (changed && this.ctx) this.onConnectionChange(this.ctx);
|
||||
for (const wake of this.waiters) wake();
|
||||
return;
|
||||
}
|
||||
@@ -166,6 +184,8 @@ export class GoalIntercom {
|
||||
this.publish({ binding: this.binding, role: this.role, kind: "received", id: message.id });
|
||||
}
|
||||
} else if (message.kind === "steer" && this.role === "worker") {
|
||||
// Pi's void message API provides synchronous handoff, not a durable queue receipt.
|
||||
// Ack only after that handoff; asynchronous enqueue errors are not observable here.
|
||||
this.onSteer(message.text!);
|
||||
this.received.add(message.id);
|
||||
this.record("in", message);
|
||||
|
||||
@@ -23,6 +23,7 @@ export class RoleModels {
|
||||
leave(): void { this.role = undefined; }
|
||||
|
||||
async enter(role: ModelRole, ctx: ExtensionContext, useCurrent = false): Promise<void> {
|
||||
if (this.stopped) throw new Error("Role model session ended.");
|
||||
this.role = role;
|
||||
this.ctx = ctx;
|
||||
let choice: Choice | undefined;
|
||||
|
||||
+32
-10
@@ -10,7 +10,7 @@ import { RoleModels } from "./role-models.js";
|
||||
const BOOTSTRAPPED = "pi-goals-visible-supervisor-v2";
|
||||
const INITIAL_COMPACT_AT_TOKENS = 20_000;
|
||||
const COMPACT_AT_TOKENS = 100_000;
|
||||
const WRITER_TOOLS = new Set(["bash", "edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit", "edit_file", "write_file", "quick_edit", "target_edit"]);
|
||||
const BLOCKED_TOOLS = new Set(["intercom", "bash", "edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit", "edit_file", "write_file", "quick_edit", "target_edit"]);
|
||||
|
||||
interface SupervisorConfig {
|
||||
workerSessionId: string;
|
||||
@@ -49,7 +49,7 @@ function hasEvidenceEntry(block: string): boolean {
|
||||
const childIndent = lines[child].match(/^\s*/)?.[0].length ?? 0;
|
||||
if (lines[child].trim() && childIndent <= indent) break;
|
||||
const entry = /^\s+[-*]\s+(.+?)\s*$/.exec(lines[child]);
|
||||
if (entry?.[1].trim()) return true;
|
||||
if (entry?.[1].trim() && !/^\(empty until sign-off\)$/i.test(entry[1].trim())) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -93,6 +93,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
const settings = config();
|
||||
let compacting = false;
|
||||
let bootstrapping = false;
|
||||
let modelError: string | null = null;
|
||||
const intercom = new GoalIntercom(pi);
|
||||
const models = new RoleModels(pi);
|
||||
intercom.onView = (view) => pi.sendUserMessage(view.text, { deliverAs: "followUp" });
|
||||
@@ -103,16 +104,16 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
bootstrapping = true;
|
||||
try {
|
||||
const active = pi.getActiveTools();
|
||||
pi.setActiveTools(active.filter((tool) => !WRITER_TOOLS.has(tool.toLowerCase())));
|
||||
const writers = pi.getActiveTools().filter((tool) => WRITER_TOOLS.has(tool.toLowerCase()));
|
||||
if (writers.length) throw new Error(`Could not remove supervisor writing tools: ${writers.join(", ")}`);
|
||||
pi.setActiveTools(active.filter((tool) => !BLOCKED_TOOLS.has(tool.toLowerCase())));
|
||||
const blocked = pi.getActiveTools().filter((tool) => BLOCKED_TOOLS.has(tool.toLowerCase()));
|
||||
if (blocked.length) throw new Error(`Could not remove supervisor writing or messaging tools: ${blocked.join(", ")}`);
|
||||
if (!entries.some((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === BOOTSTRAPPED)) {
|
||||
pi.appendEntry(BOOTSTRAPPED, { version: 2, workerSessionId: settings.workerSessionId, planPath: settings.planPath });
|
||||
}
|
||||
intercom.markReady();
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`Supervisor startup failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
} finally { bootstrapping = false; }
|
||||
};
|
||||
|
||||
const bootstrapAfterInitialCompaction = (ctx: ExtensionContext): void => {
|
||||
@@ -139,11 +140,30 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const start = async (ctx: ExtensionContext): Promise<void> => {
|
||||
modelError = "Supervisor model restoration is pending.";
|
||||
intercom.configure(settings.approvalId, "supervisor", ctx);
|
||||
pi.setActiveTools(pi.getActiveTools().filter((tool) => !WRITER_TOOLS.has(tool.toLowerCase())));
|
||||
await models.enter("supervisor", ctx, process.env.PI_GOALS_MODEL_EXPLICIT === "1");
|
||||
setImmediate(() => { bootstrapAfterInitialCompaction(ctx); });
|
||||
pi.setActiveTools(pi.getActiveTools().filter((tool) => !BLOCKED_TOOLS.has(tool.toLowerCase())));
|
||||
try {
|
||||
await models.enter("supervisor", ctx, process.env.PI_GOALS_MODEL_EXPLICIT === "1");
|
||||
modelError = null;
|
||||
setImmediate(() => { if (!intercom.ended) bootstrapAfterInitialCompaction(ctx); });
|
||||
} catch (error) {
|
||||
modelError = String(error);
|
||||
if (!intercom.ended) ctx.ui.notify(`Supervisor paused: ${modelError} Select /model, then /goals reconnect.`, "error");
|
||||
}
|
||||
};
|
||||
pi.on("session_start", async (_event, ctx) => start(ctx));
|
||||
pi.registerCommand("goals", {
|
||||
description: "Retry supervisor model restoration and readiness: /goals reconnect",
|
||||
handler: async (args, ctx) => {
|
||||
if (args.trim() !== "reconnect") { ctx.ui.notify("Use /goals reconnect here; manage the plan or restart the pane from the worker session.", "info"); return; }
|
||||
if (!ctx.isIdle() || compacting) { ctx.ui.notify("Wait for the supervisor to settle before reconnecting.", "warning"); return; }
|
||||
await start(ctx);
|
||||
},
|
||||
});
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (BLOCKED_TOOLS.has(event.toolName.toLowerCase())) return { block: true, terminate: true, reason: "Supervisor is read-only; use SteerWorker for the bound worker, not the general intercom tool." };
|
||||
});
|
||||
pi.on("before_agent_start", async (_event, ctx) => ({ systemPrompt: `${ctx.getSystemPrompt()}\n\n${supervisorPrompt(settings)}` }));
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
@@ -174,6 +194,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
return new Text(`${theme.fg("toolTitle", "Supervisor → worker")}\n${args.instruction ?? ""}`, 0, 0);
|
||||
},
|
||||
async execute(_id, params) {
|
||||
if (modelError) return result(`Supervisor paused: ${modelError} Use /model, then /goals reconnect.`, true);
|
||||
const instruction = params.instruction.trim();
|
||||
if (!instruction) return result("A worker instruction cannot be empty.", true);
|
||||
const id = intercom.steer(instruction);
|
||||
@@ -191,6 +212,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
verifyOutputPath: Type.String({ description: "Nonempty repository-relative file containing the verification output you inspected." }),
|
||||
}),
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
if (modelError) return result(`Supervisor paused: ${modelError} Use /model, then /goals reconnect.`, true);
|
||||
const view = latestWorkerView(ctx);
|
||||
const newest = intercom.latestView;
|
||||
if (!intercom.connected || !newest || view !== newest.text) return result("Cannot approve without inspecting the latest worker view.", true);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { goalBlock, hashGoalBlock } from "../src/approval.js";
|
||||
|
||||
it("hashes only the current goal, excluding the log, interview, and their historical goal text", () => {
|
||||
const goal = "1. [ ] goal: output\n - evidence: output.txt\n";
|
||||
const before = `${goal}\n## Log\n- first entry\n`;
|
||||
const after = `${goal}\n## Log\n- later entry\n\n${goal}\n## Interview\n> new notes\n`;
|
||||
expect(goalBlock(before, "output")).toBe(goal.trimEnd());
|
||||
expect(hashGoalBlock(goalBlock(before, "output")!)).toBe(hashGoalBlock(goalBlock(after, "output")!));
|
||||
expect(goalBlock(`${goal}\n## Interview\n> notes`, "output")).toBe(goal.trimEnd());
|
||||
expect(goalBlock(`${goal}2. [ ] goal: second\n - evidence: second.txt`, "output")).toBe(goal.trimEnd());
|
||||
expect(hashGoalBlock(goalBlock(before.replace("output.txt", "changed.txt"), "output")!)).not.toBe(hashGoalBlock(goalBlock(before, "output")!));
|
||||
});
|
||||
+140
-2
@@ -9,6 +9,7 @@ import { intercomFixture } from "./intercom-fixture.js";
|
||||
|
||||
const openSupervisorPane = vi.fn(async () => "pane-2");
|
||||
const closeSupervisorPane = vi.fn(async () => undefined);
|
||||
const shutdowns: Array<() => Promise<void>> = [];
|
||||
vi.mock("../src/herdr.js", () => ({ openSupervisorPane, closeSupervisorPane }));
|
||||
const { default: piGoalsExtension, isMainSession } = await import("../src/index.js");
|
||||
|
||||
@@ -64,7 +65,8 @@ function setup(selectChoices: Array<string | undefined>, editorChoices: Array<st
|
||||
sendUserMessage: (content: string) => messages.push({ content }),
|
||||
};
|
||||
piGoalsExtension(pi as unknown as ExtensionAPI);
|
||||
return { commands, ctx, cwd, entries, hooks, messages, notifications, tools, transport };
|
||||
shutdowns.push(() => hooks.get("session_shutdown")());
|
||||
return { pi, commands, ctx, cwd, entries, hooks, messages, notifications, tools, transport };
|
||||
}
|
||||
|
||||
function writePlan(cwd: string, content: string): string {
|
||||
@@ -78,7 +80,9 @@ function approvedPlan(cwd: string): string {
|
||||
return writePlan(cwd, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: output exists\n - evidence:\n - `result.txt`: contains ok\n\n## Log\n");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
for (const shutdown of shutdowns.splice(0)) await shutdown();
|
||||
vi.useRealTimers();
|
||||
openSupervisorPane.mockClear();
|
||||
closeSupervisorPane.mockClear();
|
||||
});
|
||||
@@ -104,6 +108,13 @@ describe("/goals flow", () => {
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
const count = views().length;
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null });
|
||||
const binding = (flow.entries.at(-1)?.data as any).approvalId;
|
||||
const messageCount = flow.messages.length;
|
||||
flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "late-completed", text: "Obsolete instruction." });
|
||||
await flow.commands.get("goals").handler("restart", flow.ctx);
|
||||
await flow.commands.get("goals").handler("reconnect", flow.ctx);
|
||||
expect(flow.messages).toHaveLength(messageCount);
|
||||
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(60 * 60_000);
|
||||
expect(views()).toHaveLength(count);
|
||||
} finally {
|
||||
@@ -193,7 +204,14 @@ describe("/goals flow", () => {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
const planPath = approvedPlan(flow.cwd);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
const binding = (flow.entries.at(-1)?.data as any).approvalId;
|
||||
await flow.commands.get("goals").handler("clear", flow.ctx);
|
||||
const messageCount = flow.messages.length;
|
||||
flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "late-cleared", text: "Obsolete instruction." });
|
||||
await flow.commands.get("goals").handler("restart", flow.ctx);
|
||||
await flow.commands.get("goals").handler("reconnect", flow.ctx);
|
||||
expect(flow.messages).toHaveLength(messageCount);
|
||||
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
|
||||
expect(closeSupervisorPane).toHaveBeenCalledWith("pane-2");
|
||||
expect(readFileSync(planPath, "utf8")).toContain("make the file");
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, supervisorPaneId: null, planVersion: null });
|
||||
@@ -221,6 +239,7 @@ describe("/goals flow", () => {
|
||||
verifyOutputPath: "verify.txt",
|
||||
supervisor: { sessionId: "supervisor", runId: null }, timestamp: new Date().toISOString(),
|
||||
});
|
||||
writeFileSync(planPath, `${plan}- Appended manual log after approval.\n`);
|
||||
const signed = await flow.tools.get("CompleteGoal").execute("id", { goal }, undefined, undefined, flow.ctx);
|
||||
expect(signed.isError).toBe(false);
|
||||
expect(readFileSync(planPath, "utf8")).toContain("1. [x] goal: make the file");
|
||||
@@ -236,3 +255,122 @@ describe("process role", () => {
|
||||
expect(isMainSession(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function restoredPlan(flow: ReturnType<typeof setup>, phase: "working" | "planning" = "working") {
|
||||
const path = approvedPlan(flow.cwd);
|
||||
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase, approvalId: "restored-binding", supervisorPaneId: "owned-pane", planVersion: 1 } });
|
||||
return path;
|
||||
}
|
||||
|
||||
it.each(["working", "planning"] as const)("restores %s linkage even when its remembered model is unavailable, and supports explicit recovery", async phase => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
const path = restoredPlan(flow, phase);
|
||||
const plan = readFileSync(path, "utf8");
|
||||
const role = phase === "working" ? "worker" : "planning";
|
||||
mkdirSync(join(flow.cwd, ".pi/pi-goals/models"), { recursive: true });
|
||||
const modelPath = join(flow.cwd, `.pi/pi-goals/models/${role}.json`);
|
||||
writeFileSync(modelPath, JSON.stringify({ provider: "gone", id: "expired" }));
|
||||
flow.ctx.modelRegistry.find = vi.fn().mockReturnValue(undefined);
|
||||
await expect(flow.hooks.get("session_start")({}, flow.ctx)).resolves.toBeUndefined();
|
||||
expect(flow.pi.setModel).not.toHaveBeenCalled();
|
||||
expect(readFileSync(modelPath, "utf8")).toContain("expired");
|
||||
expect(flow.transport.sent.filter(message => message.kind === "hello" && message.ready)).toHaveLength(0);
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "goals paused");
|
||||
if (phase === "working") expect(flow.transport.sent).toContainEqual(expect.objectContaining({ kind: "hello", binding: "restored-binding" }));
|
||||
expect((await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "code.ts" } }, flow.ctx)).block).toBe(true);
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "read", input: { path: "code.ts" } }, flow.ctx)).toBeUndefined();
|
||||
expect(await flow.hooks.get("input")({ source: "interactive", text: "Why are we paused?" }, flow.ctx)).toBeUndefined();
|
||||
const signoff = await flow.tools.get("CompleteGoal").execute("id", { goal: "make the file" }, undefined, undefined, flow.ctx);
|
||||
expect(signoff.isError).toBe(true);
|
||||
flow.ctx.modelRegistry.find = (provider, id) => ({ provider, id });
|
||||
await flow.hooks.get("model_select")({ source: "set", model: { provider: "test", id: "chosen" } }, flow.ctx);
|
||||
await flow.commands.get("goals").handler("reconnect", flow.ctx);
|
||||
expect(flow.pi.setModel).toHaveBeenLastCalledWith({ provider: "test", id: "chosen" });
|
||||
expect(openSupervisorPane).not.toHaveBeenCalled();
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase, approvalId: "restored-binding", planVersion: 1 });
|
||||
const injection = await flow.hooks.get("before_agent_start")({}, flow.ctx);
|
||||
if (phase === "planning") expect(injection.message.content).toContain(path);
|
||||
else expect(injection.systemPrompt).toContain("implementation worker");
|
||||
// Human diagnostic input is retained in the planning interview, never discarded by recovery.
|
||||
expect(readFileSync(path, "utf8")).toContain(plan.trim());
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("shows a missing resumed supervisor, pauses writes, and automatically unpauses when that peer returns", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] });
|
||||
const flow = setup([]);
|
||||
try {
|
||||
restoredPlan(flow);
|
||||
flow.transport.replyToHello(false);
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "goals paused");
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(flow.notifications.some(text => text.includes("/goals restart"))).toBe(true);
|
||||
expect((await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "code.ts" } }, flow.ctx)).terminate).toBe(true);
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "git status" } }, flow.ctx)).toBeUndefined();
|
||||
flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: true });
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("supervised"));
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "code.ts" } }, flow.ctx)).toBeUndefined();
|
||||
expect(openSupervisorPane).not.toHaveBeenCalled();
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("times out stale Ready retries in five seconds, without replacing the pane automatically", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] });
|
||||
const flow = setup(["Ready", "Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
approvedPlan(flow.cwd);
|
||||
flow.transport.replyToHello(false);
|
||||
openSupervisorPane.mockImplementationOnce(async (_input: any, opened: any) => { opened("failed-pane"); throw new Error("pane run failed"); });
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(flow.notifications.at(-1)).toContain("failed-pane");
|
||||
const retry = flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await retry;
|
||||
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
|
||||
expect(closeSupervisorPane).not.toHaveBeenCalled();
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", supervisorPaneId: "failed-pane" });
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("explicitly restarts only the tracked pane, keeps the plan, and invalidates old approval binding", async () => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
const path = restoredPlan(flow);
|
||||
const before = readFileSync(path, "utf8");
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
const checkpoint = approvalPath(flow.cwd, "session-a", "make the file");
|
||||
mkdirSync(join(flow.cwd, ".pi/pi-goals/approvals"), { recursive: true });
|
||||
writeFileSync(checkpoint, "old checkpoint");
|
||||
await flow.commands.get("goals").handler("restart", flow.ctx);
|
||||
expect(closeSupervisorPane).toHaveBeenCalledExactlyOnceWith("owned-pane");
|
||||
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
|
||||
expect(readFileSync(path, "utf8")).toBe(before);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", planVersion: 1 });
|
||||
expect((flow.entries.at(-1)?.data as any).approvalId).not.toBe("restored-binding");
|
||||
expect(() => readFileSync(checkpoint)).toThrow();
|
||||
await flow.commands.get("goals").handler("clear", flow.ctx);
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "unrelated.ts" } }, flow.ctx)).toBeUndefined();
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("does not persist startup results or launch work after session shutdown", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
let finish: (() => void) | undefined;
|
||||
openSupervisorPane.mockImplementationOnce(() => new Promise(resolve => { finish = () => resolve("late-pane"); }));
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
approvedPlan(flow.cwd);
|
||||
const starting = flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await flow.hooks.get("session_shutdown")();
|
||||
const entries = flow.entries.length;
|
||||
const messages = flow.messages.length;
|
||||
finish!();
|
||||
await starting;
|
||||
expect(flow.entries).toHaveLength(entries);
|
||||
expect(flow.messages).toHaveLength(messages);
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export function intercomFixture() {
|
||||
let autoHello = true;
|
||||
let registration: any;
|
||||
const sent: any[] = [];
|
||||
let connected = true;
|
||||
const receive = (payload: any, fromSessionId = "peer") => registration.onEvent({ type: "message", fromSessionId, payload });
|
||||
return {
|
||||
sent, receive,
|
||||
replyToHello: (value: boolean) => { autoHello = value; },
|
||||
event: (event: any) => registration.onEvent(event),
|
||||
connect: (value: boolean) => { connected = value; registration.onEvent({ type: "connection", connected: value, supported: true }); },
|
||||
events: {
|
||||
@@ -16,7 +18,7 @@ export function intercomFixture() {
|
||||
snapshot: () => ({ connected, supported: true }),
|
||||
publish: (message: any) => {
|
||||
sent.push(message);
|
||||
if (message.kind === "hello") queueMicrotask(() => receive({ ...message, role: message.role === "worker" ? "supervisor" : "worker", ready: true }));
|
||||
if (message.kind === "hello" && autoHello) queueMicrotask(() => receive({ ...message, role: message.role === "worker" ? "supervisor" : "worker", ready: true }));
|
||||
},
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -73,3 +73,31 @@ describe("pi-intercom transport", () => {
|
||||
await rejection;
|
||||
});
|
||||
});
|
||||
|
||||
it("does not acknowledge a synchronous handoff failure, and retries the instruction", async () => {
|
||||
const runtime = setup("worker");
|
||||
await runtime.link.waitReady();
|
||||
const delivery = vi.fn().mockImplementationOnce(() => { throw new Error("Delivery unavailable"); });
|
||||
runtime.link.onSteer = delivery;
|
||||
const message = { binding: "binding", role: "supervisor", kind: "steer", id: "retry", text: "Inspect evidence." };
|
||||
runtime.fixture.receive(message);
|
||||
expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(0);
|
||||
expect(runtime.entries.filter(e => e.data.direction === "in")).toHaveLength(0);
|
||||
runtime.fixture.receive(message);
|
||||
expect(delivery).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("detaches a completed binding and ignores its late advice without replay errors or false acceptance", async () => {
|
||||
const runtime = setup("worker");
|
||||
await runtime.link.waitReady();
|
||||
const delivery = vi.fn();
|
||||
runtime.link.onSteer = delivery;
|
||||
runtime.link.detach();
|
||||
runtime.fixture.receive({ binding: "binding", role: "supervisor", kind: "steer", id: "late", text: "Obsolete advice." });
|
||||
expect(runtime.link.connected).toBe(false);
|
||||
expect(delivery).not.toHaveBeenCalled();
|
||||
expect(runtime.ctx.ui.notify).not.toHaveBeenCalled();
|
||||
expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(0);
|
||||
expect(runtime.fixture.sent.at(-1)).toMatchObject({ kind: "hello", ready: false });
|
||||
});
|
||||
|
||||
@@ -47,12 +47,14 @@ it("runs a forked Pi supervisor and receives its exact instruction in another Pi
|
||||
let supervisor: Driver | undefined;
|
||||
let workerFile: string | undefined;
|
||||
let supervisorFile: string | undefined;
|
||||
let supervisorTools: string[] = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
const input = JSON.parse(body);
|
||||
const latest = input.messages.at(-1);
|
||||
const steer = latest.role === "user" && JSON.stringify(latest.content).includes("The worker stopped.");
|
||||
if (steer) supervisorTools = input.tools.map((tool: any) => tool.function.name);
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const delta = steer ? { tool_calls: [{ index: 0, id: "test-steer", type: "function", function: { name: "SteerWorker", arguments: JSON.stringify({ instruction: advice }) } }] } : { content: "Test context retained. Actual outputs still need inspection." };
|
||||
response.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`);
|
||||
@@ -92,6 +94,9 @@ it("runs a forked Pi supervisor and receives its exact instruction in another Pi
|
||||
expect(JSON.stringify(received)).toContain(advice);
|
||||
const result = await supervisor.wait(message => message.type === "tool_execution_end" && message.toolName === "SteerWorker");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(supervisorTools).toContain("SteerWorker");
|
||||
expect(supervisorTools).not.toContain("intercom");
|
||||
expect(supervisorTools).not.toContain("bash");
|
||||
supervisor.send({ type: "get_state", id: "supervisor-state" });
|
||||
const supervisorState = await supervisor.wait(message => message.type === "response" && message.id === "supervisor-state");
|
||||
supervisorFile = supervisorState.data.sessionFile;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
@@ -19,12 +19,14 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
|
||||
vi.stubEnv("PI_GOALS_APPROVAL_ID", "approval-1");
|
||||
const hooks = new Map<string, any>();
|
||||
const tools = new Map<string, any>();
|
||||
const commands = new Map<string, any>();
|
||||
const entries: any[] = [];
|
||||
const messages: string[] = [];
|
||||
let branch: any[] = [];
|
||||
let activeTools = ["read", "grep", "bash", "write", "edit"];
|
||||
let activeTools = ["read", "grep", "bash", "write", "edit", "intercom"];
|
||||
const ctx = {
|
||||
cwd,
|
||||
isIdle: () => true,
|
||||
getSystemPrompt: () => "base",
|
||||
model: { provider: "test", id: "supervisor" },
|
||||
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
|
||||
@@ -40,6 +42,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
|
||||
hooks.set(name, async (...args: any[]) => { await prior?.(...args); return handler(...args); });
|
||||
},
|
||||
registerTool: (tool: any) => tools.set(tool.name, tool),
|
||||
registerCommand: (name: string, command: any) => commands.set(name, command),
|
||||
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
|
||||
sendUserMessage: (message: string) => messages.push(message),
|
||||
getActiveTools: () => activeTools,
|
||||
@@ -49,7 +52,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
|
||||
registerVisibleSupervisor(pi as unknown as ExtensionAPI);
|
||||
shutdowns.push(() => hooks.get("session_shutdown")());
|
||||
return {
|
||||
activeTools: () => activeTools, branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, transport, messages, tools,
|
||||
commands, pi, activeTools: () => activeTools, branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, transport, messages, tools,
|
||||
ready: () => transport.sent.some(message => message.kind === "hello" && message.role === "supervisor" && message.ready),
|
||||
start: async () => { await hooks.get("session_start")({}, ctx); await new Promise(resolve => setImmediate(resolve)); },
|
||||
view: (id: string, text: string, reason = "settled", backgroundQuiet = true) => {
|
||||
@@ -212,6 +215,15 @@ describe("visible supervisor session", () => {
|
||||
await runtime.start();
|
||||
const view = runtime.view("first", "The worker stopped.\n\ntool calls with no result: none");
|
||||
runtime.branch([{ type: "message", message: { role: "user", content: [{ type: "text", text: view.text }] } }]);
|
||||
const originalPlan = readFileSync(planPath, "utf8");
|
||||
for (const evidence of [" - evidence: (empty until sign-off)", " - evidence:\n - (empty until sign-off)"]) {
|
||||
writeFileSync(planPath, originalPlan.replace(" - evidence:\n - `result.txt`: contains ok", evidence));
|
||||
const rejected = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx);
|
||||
expect(rejected.isError).toBe(true);
|
||||
expect(rejected.content[0].text).toContain("nonblank evidence");
|
||||
expect(existsSync(approvalPath(cwd, "worker-session", "make the file"))).toBe(false);
|
||||
}
|
||||
writeFileSync(planPath, originalPlan);
|
||||
const approved = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx);
|
||||
expect(approved.isError).toBe(false);
|
||||
expect(existsSync(approvalPath(cwd, "worker-session", "make the file"))).toBe(true);
|
||||
@@ -227,3 +239,35 @@ describe("visible supervisor session", () => {
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks the general intercom actuator even if enabled after startup", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-actuators-"));
|
||||
try {
|
||||
const runtime = setup(cwd, join(cwd, "plan.md"));
|
||||
await runtime.start();
|
||||
expect(runtime.activeTools()).not.toContain("intercom");
|
||||
runtime.pi.setActiveTools(["intercom", "SteerWorker", "read"]);
|
||||
expect((await runtime.hooks.get("tool_call")({ toolName: "intercom" }, runtime.ctx)).block).toBe(true);
|
||||
expect(await runtime.hooks.get("tool_call")({ toolName: "SteerWorker" }, runtime.ctx)).toBeUndefined();
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("keeps a supervisor unready after model restoration failure, then recovers explicitly without substituting a model", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-model-recovery-"));
|
||||
try {
|
||||
const runtime = setup(cwd, join(cwd, "plan.md"));
|
||||
mkdirSync(join(cwd, ".pi/pi-goals/models"), { recursive: true });
|
||||
writeFileSync(join(cwd, ".pi/pi-goals/models/supervisor.json"), JSON.stringify({ provider: "gone", id: "expired" }));
|
||||
runtime.ctx.modelRegistry.find = vi.fn().mockReturnValue(undefined);
|
||||
await runtime.start();
|
||||
expect(runtime.ready()).toBe(false);
|
||||
expect(runtime.pi.setModel).not.toHaveBeenCalled();
|
||||
expect((await runtime.tools.get("SteerWorker").execute("id", { instruction: "Do not deliver." })).isError).toBe(true);
|
||||
runtime.ctx.modelRegistry.find = (provider, id) => ({ provider, id });
|
||||
await runtime.hooks.get("model_select")({ source: "set", model: { provider: "test", id: "chosen" } }, runtime.ctx);
|
||||
await runtime.commands.get("goals").handler("reconnect", runtime.ctx);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(runtime.pi.setModel).toHaveBeenLastCalledWith({ provider: "test", id: "chosen" });
|
||||
expect(runtime.ready()).toBe(true);
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user