Compact approved worker before visible supervisor fork

This commit is contained in:
wassname
2026-09-09 22:06:30 +08:00
parent 82e2d87149
commit 564ad5e040
11 changed files with 281 additions and 63 deletions
+7 -5
View File
@@ -4,10 +4,10 @@ Plan in one Pi session, then do the work there while a stronger visible Pi sessi
## How it works
1. `/goals <objective>` creates `.pi/plan/<session_id>-vN.md` and enters read-only plan mode.
1. `/goals <objective>` creates `.pi/plan/<session_id>-vN.md` and enters read-only plan mode when no plan is active. With an active plan, `/goals` opens safe actions and only `/goals plan <objective>` deliberately replaces planning.
2. Pi asks only material questions, writes the plan, and shows **Ready / Refine / Edit / Cancel**.
3. **Ready** opens a second Herdr pane. The new Pi session explicitly forks the planning session and compacts that fork.
4. The original session becomes the implementation worker. It keeps the full conversation and normal tools.
3. **Ready** compacts the approved worker conversation once, then opens a second Herdr pane. The new Pi session forks that compacted worker session.
4. The original session becomes the implementation worker. It keeps normal tools and resumes from the same compacted boundary.
5. The fork becomes an inspection-only supervisor by instruction, with normal Pi tools and extensions available. Worker views and supervisor instructions travel over pi-intercom's extension channel, scoped to this plan pairing.
6. Ready approves the displayed plan and waits for the supervisor's Intercom readiness message. If startup fails, the worker loudly switches to unsupervised work only after rechecking that approved content and restoring its worker model.
7. The supervisor compacts again when its context reaches 100k tokens.
@@ -38,7 +38,9 @@ Run Pi from the Git repository that the plan will change. **Ready** fails if the
## Commands
```text
/goals <objective> create a new plan
/goals show safe status/actions for the current plan
/goals <objective> create a first plan when none is active
/goals plan <objective> deliberately start or replace planning
/goals model <model> select the visible supervisor model
/goals model use the remembered supervisor model
/goals work reconnect the existing approved worker pairing
@@ -52,7 +54,7 @@ Run Pi from the Git repository that the plan will change. **Ready** fails if the
Pi argument autocomplete shows a short description for each available verb. The worker status distinguishes **supervised worker** from **UNSUPERVISED**; the supervisor status says **supervising**, **starting/reconnecting**, or **paused**.
`work` and `supervise` are role-aware recovery commands, not role conversion or new-pairing commands. Wrong or missing identities are rejected. `noplan` preserves the draft/history, leaves planning restrictions, and does not select Ready, start implementation or launch a supervisor. `/goals clear` closes the tracked pane and keeps the plan file. Starting another plan also keeps older versions.
`/goals` with no argument opens a status/action menu and does nothing until a deliberate action is chosen. While a draft, approved worker, or supervisor pairing exists, unknown/free-text `/goals ...` input is rejected without stopping or replacing it; use `/goals plan <objective>` to deliberately replace planning. With no active plan, `/goals <objective>` remains a convenient first-plan shortcut. `work` and `supervise` are role-aware recovery commands, not role conversion or new-pairing commands. Wrong or missing identities are rejected. `noplan` preserves the draft/history, leaves planning restrictions, and does not select Ready, start implementation or launch a supervisor. `/goals clear` closes the tracked pane and keeps the plan file. Starting another plan also keeps older versions.
If the worker model is unavailable or fails after Pi's automatic recovery, work stays **paused**; solo does not bypass that failure or substitute another model. Human input, read-only diagnosis, `/model`, and recovery commands remain available.
@@ -0,0 +1,28 @@
# Worker compaction and recovery implementation evidence
Scope: safe `/goals` entry, bounded peer hello retries, pre-fork worker compaction, deferred Intercom delivery during compaction, and preserving a completed healthy pairing.
## Implemented behavior
- A bare `/goals` opens an action menu. An active plan is not replaced by unknown/free-text input; `/goals plan <objective>` is required, including when the objective happens to be named `restart`.
- The worker status displays `👁` only when the supervised Intercom pair is connected.
- An unanswered active binding emits the original hello plus at most two delayed retries (1 s and 5 s). The normal five-minute readiness/solo policy remains authoritative.
- Ready compacts the approved worker session before the worker model is restored and the supervisor pane forks it. Planning/resync and queued Intercom delivery do not append messages during that compaction. The supervisor already recognizes an inherited compaction entry and skips its initial compaction.
- A healthy completed pairing remains bound after sign-off. While the worker peer is absent, one identical steer is persisted once and replays after a successful reconnect; the tool result tells the supervisor not to repeat the instruction or produce a long recap.
## Validation
- `npx vitest run test/goals-flow.test.ts test/intercom.test.ts test/supervisor-session.test.ts` — passed: 3 files, 78 tests.
- `npm run typecheck` — passed.
- `npm run lint` — passed.
- `npm run build` — passed before the final wording-only prompt change; final typecheck/lint passed after it.
- `git diff --check` — passed.
- `npm test` — 155/156 tests passed. `test/rpc-review.test.ts` repeatedly timed out waiting for its first `select` request after the offline model completed planning. This was also reproduced before the new lifecycle edits and was not changed here. The focused flow suite covers the changed paths, but this is a remaining test failure.
## Review notes
Static diff review checked command-word objectives (`/goals plan restart`), active-plan non-destructiveness, old binding preservation after completion, and pending-steer replay. No additional agreed defect found.
## Remaining gaps
No real Herdr/model acceptance was run (no panes used). The user-reported Copilot compaction timeout and long supervisor status loop were not reproduced with a real provider. A truly terminated worker cannot receive the retained instruction until the existing pairing is restored through reload/reconnect/restart.
+1
View File
@@ -1,6 +1,7 @@
import type { AutocompleteItem } from "@earendil-works/pi-tui";
const workerCommands: Record<string, string> = {
plan: "Deliberately start or replace planning: /goals plan <objective>",
work: "Reconnect the existing approved worker pairing (not a role conversion)",
supervise: "Use in the saved supervisor pane to reconnect it",
solo: "Continue an approved plan unsupervised; supervisor sign-off unavailable",
+81 -24
View File
@@ -26,7 +26,7 @@ import { goalCommandCompletions } from "./command-help.js";
import { closeSupervisorPane, openSupervisorPane } from "./herdr.js";
import { GoalIntercom } from "./intercom.js";
import { FOLD_LINE, foldPlan, GOAL_LINE } from "./plan.js";
import { completeGoalDescription, completeGoalParamDescription, planDrafting, planningState, resync, supervisorPlanReview } from "./prompts.js";
import { completeGoalDescription, completeGoalParamDescription, planDrafting, planningState, resync, supervisorPlanReview, workerCompaction } from "./prompts.js";
import { RoleModels } from "./role-models.js";
import { isVisibleSupervisor, registerVisibleSupervisor, restoredSupervisor } from "./supervisor-session.js";
import { workerView } from "./worker-view.js";
@@ -250,6 +250,21 @@ export function registerWorker(pi: ExtensionAPI): void {
}
}
/** Compact the agreed planning conversation once, before the supervisor forks it. */
function compactApprovedWorker(ctx: ExtensionContext): Promise<void> {
return new Promise((resolve, reject) => {
ctx.compact({
customInstructions: workerCompaction(planPath(ctx)),
onComplete: () => resolve(),
onError: error => {
// A small already-compacted session needs no second compacted fork.
if (/^(Already compacted|Nothing to compact)/.test(error.message)) resolve();
else reject(error);
},
});
});
}
function beginReview(ctx: ExtensionContext): void {
for (const goal of scanGoals(readPlan(ctx))) {
rmSync(approvalPath(ctx.cwd, ctx.sessionManager.getSessionId(), goal.subject), { force: true });
@@ -335,14 +350,8 @@ export function registerWorker(pi: ExtensionAPI): void {
state = { ...state, previousPlan: plan };
persist();
}
const goals = scanGoals(plan);
if (goals.length > 0 && goals.every((goal) => (goal.status === "done" && state.signedOffGoals.includes(goalKey(goal.subject))) || goal.status === "cancelled")) {
stopWorkerTimers();
state = { ...state, phase: null };
models.leave();
persist();
intercom.detach();
}
// A fully signed-off plan remains paired: the worker or supervisor may discover a missed
// requirement after completion and exchange one more view without recreating the pairing.
}
function startWorkerTimers(ctx: ExtensionContext): void {
@@ -421,7 +430,9 @@ export function registerWorker(pi: ExtensionAPI): void {
const done = goals.filter(g => g.status === "done" && isSignedOff(g.subject)).length;
const claimed = goals.filter(g => g.status === "done" && !isSignedOff(g.subject));
const liveGoals = goals.filter(g => g.status === "active" || g.status === "open");
const stateLabel = state.phase === "working" && state.mode === "solo" ? " · UNSUPERVISED" : claimed.length ? ` · supervised worker · ${claimed.length} claimed, awaiting review` : liveGoals.length > 0 ? state.phase === "working" ? " · supervised worker" : " · inactive draft" : " · complete";
const supervision = "supervised worker";
const connectedGlyph = intercom.connected ? " 👁" : "";
const stateLabel = state.phase === "working" && state.mode === "solo" ? " · UNSUPERVISED" : claimed.length ? ` · ${supervision} · ${claimed.length} claimed, awaiting review${connectedGlyph}` : liveGoals.length > 0 ? state.phase === "working" ? ` · ${supervision}${connectedGlyph}` : " · inactive draft" : " · complete";
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `${done}/${goals.length} goals${stateLabel}`));
const mark: Record<GoalStatus, string> = { done: "✔", active: "▸", open: "◻", cancelled: "✗" };
// Only live goals get lines so finished work never pushes current work off screen. The active
@@ -440,13 +451,49 @@ export function registerWorker(pi: ExtensionAPI): void {
// --- /goals: enter plan mode or configure supervision -- Pi/Codex -----------------------------
async function chooseGoalsAction(ctx: ExtensionContext): Promise<string | null> {
const status = state.phase === "planning"
? `Draft plan active at ${planRel(ctx)}. It has not approved implementation.`
: state.phase === "working" && state.mode === "solo"
? `UNSUPERVISED worker active for ${planRel(ctx)}. The approved plan is preserved.`
: state.phase === "working"
? `Supervised worker active for ${planRel(ctx)}.`
: "No active plan.";
const choices = state.phase === "planning"
? ["Keep drafting", "Exit planning (keep draft)", "Start a new plan"]
: state.phase === "working" && state.mode === "solo"
? ["Keep working unsupervised", "Restore supervision", "Reconnect worker model", "Start a new plan", "Disconnect current plan"]
: state.phase === "working"
? ["Keep working", "Reconnect existing supervision", "Replace supervisor", "Start a new plan", "Disconnect current plan"]
: ["Start a plan", "Cancel"];
const choice = await ctx.ui.select(status, choices);
if (!choice || choice === "Cancel" || choice.startsWith("Keep")) return null;
if (choice === "Exit planning (keep draft)") return "noplan";
if (choice === "Restore supervision" || choice === "Replace supervisor") return "restart";
if (choice === "Reconnect worker model" || choice === "Reconnect existing supervision") return "reconnect";
if (choice === "Disconnect current plan") return "clear";
const objective = await ctx.ui.editor("What should the new plan achieve?", "");
return objective?.trim() ? `plan ${objective.trim()}` : null;
}
pi.registerCommand("goals", {
description: `Plan goals, then open a visible supervisor session. /goals <objective> | work | supervise | solo | noplan | reconnect | restart | clear | model <supervisor>`,
description: "Plan goals and manage visible supervision. /goals opens safe actions; /goals plan <objective> deliberately starts or replaces planning.",
getArgumentCompletions: prefix => goalCommandCompletions(prefix, "worker"),
handler: async (args, ctx) => {
const command = {};
let arg = args.trim();
if (arg === "solo") {
if (!arg) {
const action = await chooseGoalsAction(ctx);
if (!action) return;
arg = action;
}
const explicitPlan = arg === "plan" || arg.startsWith("plan ");
if (explicitPlan) arg = arg.slice("plan".length).trim();
if (recoveryCommand && !explicitPlan && !["work", "reconnect", "restart", "clear"].includes(arg)) {
ctx.ui.notify("Goal recovery is in progress. Wait for it, or use /goals reconnect, /goals restart, /goals clear, or /goals plan <objective> to intentionally supersede it.", "warning");
return;
}
if (!explicitPlan && arg === "solo") {
if (state.phase !== "working") { ctx.ui.notify("Solo requires an already-approved plan. A draft still needs Ready.", "warning"); return; }
if (modelError) { ctx.ui.notify(`Cannot enter solo: ${pauseReason()}`, "warning"); return; }
if (state.mode === "solo") { ctx.ui.notify("Already UNSUPERVISED; plan preserved, supervisor sign-off unavailable. /goals restart restores supervision.", "warning"); return; }
@@ -455,13 +502,13 @@ export function registerWorker(pi: ExtensionAPI): void {
enterSolo(ctx, "You explicitly selected /goals solo.");
return;
}
if (arg === "supervise") { ctx.ui.notify("This is the worker session. Run /goals supervise in the saved supervisor session; no new pairing was created.", "warning"); return; }
if (arg === "work") {
if (!explicitPlan && arg === "supervise") { ctx.ui.notify("This is the worker session. Run /goals supervise in the saved supervisor session; no new pairing was created.", "warning"); return; }
if (!explicitPlan && arg === "work") {
if (state.phase === "working" && state.mode === "solo") { ctx.ui.notify("Already an unsupervised worker. Use /goals reconnect for worker-model recovery or /goals restart to restore supervision.", "warning"); return; }
if (state.phase !== "working" || !state.approvalId || !state.supervisorPaneId) { ctx.ui.notify("No approved worker pairing to reconnect. A retained draft still needs Ready.", "warning"); return; }
arg = "reconnect";
}
if (arg === "noplan") {
if (!explicitPlan && arg === "noplan") {
if (state.phase !== "planning") { ctx.ui.notify("Not in planning mode; the current plan is unchanged.", "info"); return; }
commandAttempt = command;
readyAttempt = undefined;
@@ -476,7 +523,7 @@ export function registerWorker(pi: ExtensionAPI): void {
ctx.ui.notify(`Planning exited. Draft preserved at ${planRel(ctx)}; no implementation was approved or started.`, "info");
return;
}
if (arg === "reconnect" || arg === "restart") {
if (!explicitPlan && (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; }
commandAttempt = command;
@@ -529,7 +576,7 @@ export function registerWorker(pi: ExtensionAPI): void {
updateWidget(ctx);
return;
}
if (arg === "clear") {
if (!explicitPlan && arg === "clear") {
if (state.planVersion === null) {
ctx.ui.notify("No active plan to disconnect.", "info");
return;
@@ -548,7 +595,7 @@ export function registerWorker(pi: ExtensionAPI): void {
ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info");
return;
}
if (arg === "model" || arg.startsWith("model ")) {
if (!explicitPlan && (arg === "model" || arg.startsWith("model "))) {
if (state.phase === "working") {
ctx.ui.notify("Run /goals clear before changing the active supervisor model.", "warning");
return;
@@ -564,6 +611,10 @@ export function registerWorker(pi: ExtensionAPI): void {
ctx.ui.notify(`Goal-supervisor model ${ref ? `set to ${ref}` : "reset to the remembered supervisor model"}.`, "info");
return;
}
if (state.phase && !explicitPlan) {
ctx.ui.notify("The active plan is unchanged. Use /goals plan <objective> to deliberately replace it, or submit /goals with no arguments for safe actions and recovery commands.", "warning");
return;
}
commandAttempt = command;
if (!(await stopSupervisor())) {
ctx.ui.notify("Could not close the visible supervisor; no new plan was started.", "warning");
@@ -616,7 +667,9 @@ export function registerWorker(pi: ExtensionAPI): void {
pi.on("context", async (event, ctx) => {
const messages = state.phase === "planning" ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT);
const removedPlanningContext = messages.length !== event.messages.length;
if (state.phase === "planning" && planningContextPending) {
// Ready compacts the worker before changing phase. Its custom instructions already preserve
// the approved plan, so never append an extension message to that compaction transaction.
if (state.phase === "planning" && planningContextPending && !readyAttempt) {
planningContextPending = false;
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text: planningState(planPath(ctx)) }], timestamp: Date.now() }] };
}
@@ -666,8 +719,9 @@ export function registerWorker(pi: ExtensionAPI): void {
// A compaction loses context, so restore either the planning snapshot or the working plan once.
pi.on("session_compact", async () => {
if (state.phase === "planning") planningContextPending = true;
else resyncReason = "The session was just compacted.";
if (state.phase === "planning") {
if (!readyAttempt) planningContextPending = true;
} else resyncReason = "The session was just compacted.";
});
pi.on("agent_end", async event => {
@@ -742,14 +796,17 @@ export function registerWorker(pi: ExtensionAPI): void {
};
try {
checkApprovedPlan();
let supervisorFailure: SupervisorFailure | undefined;
try { await startSupervisor(ctx, current); }
catch (error) { if (!(error instanceof SupervisorFailure)) throw error; supervisorFailure = error; }
await compactApprovedWorker(ctx);
if (!current()) return;
checkApprovedPlan();
await restoreModel("worker", ctx);
if (!current()) return;
checkApprovedPlan();
let supervisorFailure: SupervisorFailure | undefined;
try { await startSupervisor(ctx, current); }
catch (error) { if (!(error instanceof SupervisorFailure)) throw error; supervisorFailure = error; }
if (!current()) return;
checkApprovedPlan();
state = { ...state, phase: "working", mode: "supervised", soloReason: null };
resyncReason = "The plan was approved.";
persist();
+43 -10
View File
@@ -23,6 +23,8 @@ export class GoalIntercom {
private received = new Set<string>();
private inbox = new Map<string, Message>();
private deliveryTimer?: ReturnType<typeof setTimeout>;
private peerRetryTimer?: ReturnType<typeof setTimeout>;
private peerRetryCount = 0;
private idleChecks = 0;
private delivering?: string;
private compacting = false;
@@ -57,11 +59,14 @@ export class GoalIntercom {
}
});
pi.on("session_before_compact", async () => { this.compacting = true; });
pi.on("session_compact", async () => { this.compacting = true; this.resumeDelivery(); });
pi.on("session_compact_failed", async () => { this.compacting = true; this.resumeDelivery(); });
// Do not append a queued supervision message into compaction. agent_settled releases it
// after Pi has finished rebuilding context (including a failed compaction recovery).
pi.on("session_compact", async () => { this.compacting = true; });
pi.on("session_compact_failed", async () => { this.compacting = true; });
pi.on("agent_settled", async () => this.resumeDelivery());
pi.on("session_shutdown", async () => {
if (this.deliveryTimer) clearTimeout(this.deliveryTimer);
this.clearPeerRetry();
this.stopped = true;
this.peerReady = false;
for (const wake of this.waiters) wake();
@@ -85,6 +90,7 @@ export class GoalIntercom {
this.idleChecks = 0;
if (this.deliveryTimer) clearTimeout(this.deliveryTimer);
this.deliveryTimer = undefined;
this.clearPeerRetry();
this.latestView = undefined;
this.acknowledgedEntry = undefined;
for (const entry of ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries()) {
@@ -102,6 +108,7 @@ export class GoalIntercom {
if (message.kind === "view") this.latestView = { id: message.id, text: message.text!, reason: message.reason!, through: message.through, backgroundQuiet: message.backgroundQuiet === true };
}
this.hello();
this.schedulePeerRetry();
this.scheduleDelivery(0);
}
@@ -109,6 +116,7 @@ export class GoalIntercom {
detach(reason?: string): void {
if (this.deliveryTimer) clearTimeout(this.deliveryTimer);
this.deliveryTimer = undefined;
this.clearPeerRetry();
this.inbox.clear();
this.delivering = undefined;
this.ready = false;
@@ -125,7 +133,7 @@ export class GoalIntercom {
}
failReady(reason: string): void { this.failure = reason; this.setReady(false); }
markReady(): void { this.failure = undefined; this.setReady(true); this.resumeDelivery(); }
markReady(): void { this.failure = undefined; this.setReady(true); this.schedulePeerRetry(); this.resumeDelivery(); }
markNotReady(): void { this.setReady(false); }
private setReady(ready: boolean): void {
if (this.stopped) return;
@@ -168,13 +176,18 @@ export class GoalIntercom {
return this.latestView;
}
steer(text: string): string {
if (!this.connected) throw new Error(this.peerFailure ? `Worker is disconnected: ${this.peerFailure} No instruction was sent.` : "Worker is disconnected; no instruction was sent.");
steer(text: string): { id: string; queued: boolean } {
if (!this.bound) throw new Error("Worker pairing is not active; no instruction was retained.");
if (this.failure || this.peerFailure) throw new Error(this.failure ?? this.peerFailure);
if (!this.connected) {
const retained = [...this.pending.values()].find(message => message.text === text);
if (retained) return { id: retained.id, queued: true };
}
const message: Message = { binding: this.binding, role: this.role, kind: "steer", id: randomUUID(), text };
this.record("out", message);
this.pending.set(message.id, message);
this.publish(message);
return message.id;
if (this.connected) this.publish(message);
return { id: message.id, queued: !this.connected };
}
// The inbox is persisted before handoff. Receipt means Pi started the user message, not model judgment or execution.
@@ -219,24 +232,43 @@ export class GoalIntercom {
private hello(reply = false): void {
if (!this.stopped && this.binding && this.channel?.snapshot().connected) this.publish({ binding: this.binding, role: this.role, kind: "hello", id: "hello", ready: this.ready, failure: this.failure, reply });
}
private clearPeerRetry(): void {
if (this.peerRetryTimer) clearTimeout(this.peerRetryTimer);
this.peerRetryTimer = undefined;
this.peerRetryCount = 0;
}
// Reload/reconnect races can lose the initial hello. Retry twice only while no peer exists;
// normal readiness timeout and explicit recovery commands remain the authority after that.
private schedulePeerRetry(): void {
if (this.peerRetryTimer || this.peer || this.failure || !this.bound || !this.channel?.snapshot().connected || this.peerRetryCount >= 2) return;
const delay = this.peerRetryCount++ === 0 ? 1_000 : 5_000;
this.peerRetryTimer = setTimeout(() => {
this.peerRetryTimer = undefined;
if (this.peer || this.failure || !this.bound || !this.channel?.snapshot().connected) return;
this.hello();
this.schedulePeerRetry();
}, delay);
}
private receive(event: IntercomExtensionEvent): void {
if (this.stopped) return;
if (event.type === "connection") {
if (!event.connected) {
if (this.peerReady) this.ctx?.ui.notify("Goal supervision disconnected from pi-intercom.", "warning");
this.peer = undefined; this.peerReady = false;
this.clearPeerRetry();
}
else this.hello();
else { this.hello(); this.schedulePeerRetry(); }
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");
this.hello(); this.schedulePeerRetry();
this.ctx?.ui.notify("Goal supervision peer disconnected; reconnecting the existing session.", "warning");
if (this.ctx) this.onConnectionChange(this.ctx);
return;
}
if (event.type === "session_joined") { this.hello(); return; }
if (event.type === "session_joined") { this.hello(); this.schedulePeerRetry(); return; }
if (event.type !== "message") return;
const message = event.payload as Message;
if (!message || message.binding !== this.binding || message.role !== (this.role === "worker" ? "supervisor" : "worker")) return;
@@ -244,6 +276,7 @@ export class GoalIntercom {
if (this.peer && this.peer !== event.fromSessionId) throw new Error("Two peers claim this supervision binding. Stop the duplicate session.");
const changed = !this.peer || this.peerReady !== Boolean(message.ready) || this.peerFailure !== message.failure;
this.peer = event.fromSessionId;
this.clearPeerRetry();
this.peerReady = Boolean(message.ready);
this.peerFailure = message.failure;
if (message.failure) {
+8 -1
View File
@@ -160,6 +160,10 @@ Ready.`;
* appendix included. Modelled on pi-goal-x's [POST-COMPACTION RESYNC] one-shot. This is the
* only place the below-the-fold sections are pushed; otherwise the agent reads them on demand.
* ──────────────────────────────────────────────────────────────────────── */
export function workerCompaction(planPath: string): string {
return `The plan at ${planPath} was just approved and is the source of truth. Preserve the user's intent, agreed goals, scope boundaries, and unresolved risks while removing planning discussion that no longer helps implementation. The worker will continue implementation after this compaction; do not treat the summary as plan approval or completion.`;
}
export function resync(plan: string, planRel: string, why: string, solo = false): string {
return `\
<system-reminder>
@@ -240,11 +244,14 @@ export function supervisorPlanReview(claims: string[], changes: string[], diff:
}
/* 5. Steering: a visible message is an assessment; this tool sends an actionable instruction. */
export const steerWorkerDescription = "Send one concrete instruction to the implementation worker. Use it to resume useful work after a stop, request a needed check, or correct drift toward the agreed goal. A recap alone does not send an instruction. Do not interrupt productive work or repeat ineffective steering without changing the approach.";
export const steerWorkerDescription = "Send one concrete instruction to the implementation worker. Use it to resume useful work after a stop, request a needed check, or correct drift toward the agreed goal. A recap alone does not send an instruction. Do not interrupt productive work or repeat ineffective steering without changing the approach. If the worker is disconnected, one instruction is retained for reconnect; wait for a new worker view or reconnect rather than repeating it.";
export const steerWorkerInstructionDescription = "The next useful action and its purpose toward the agreed goal; include the check or result needed to assess progress.";
export function workerInstructionSent(id: string): string {
return `Worker instruction ${id} sent through pi-intercom. Receipt and execution are not confirmed by this result.`;
}
export function workerInstructionQueued(id: string): string {
return `Worker instruction ${id} is retained locally because the worker is disconnected. It can be sent if the existing pairing reconnects; no receipt or execution is confirmed. Do not repeat this instruction or compose another long recap until a worker view arrives.`;
}
/* 6. Approval: the supervisor's acceptance action AFTER judgment, not a request to judge. */
export const approveGoalDescription = "Use only after judging that the actual result satisfies the user's intended outcome and the goal's discriminator. This tool records your acceptance; its mechanical checks cannot establish success. If the goal is unmet or evidence is insufficient, do not approve: use SteerWorker to request the next useful work or check.\n\nRequirements: inspect the current goal, repository, evidence, and a saved nonempty verification-output file, with a current stopped worker view and no active work. force overrides only dirty-worktree rejection and requires a reason; later Git/content changes invalidate approval.";
+3 -3
View File
@@ -7,7 +7,7 @@ import { approvalPath, goalBlock, hashGoalBlock, repositoryState, verifyOutputPa
import { goalCommandCompletions } from "./command-help.js";
import { GoalIntercom } from "./intercom.js";
import { planViews } from "./plan-view.js";
import { approveGoalDescription, approveGoalParameters, goalApprovalRecorded, steerWorkerDescription, steerWorkerInstructionDescription, supervisorCompaction, supervisorOrientation, supervisorReviewContext, workerInstructionSent } from "./prompts.js";
import { approveGoalDescription, approveGoalParameters, goalApprovalRecorded, steerWorkerDescription, steerWorkerInstructionDescription, supervisorCompaction, supervisorOrientation, supervisorReviewContext, workerInstructionQueued, workerInstructionSent } from "./prompts.js";
import { RoleModels } from "./role-models.js";
const BOOTSTRAPPED = "pi-goals-visible-supervisor-v2";
@@ -256,8 +256,8 @@ export function registerVisibleSupervisor(pi: ExtensionAPI, restored?: Superviso
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);
return result(workerInstructionSent(id));
const delivery = intercom.steer(instruction);
return result(delivery.queued ? workerInstructionQueued(delivery.id) : workerInstructionSent(delivery.id));
},
});
+70 -13
View File
@@ -35,6 +35,7 @@ function setup(selectChoices: Array<string | undefined>, editorChoices: Array<st
isIdle: vi.fn(() => true),
getSystemPrompt: () => "base prompt",
getContextUsage: () => ({ percent: 25 }),
compact: vi.fn((options: { onComplete?: () => void }) => options.onComplete?.()),
model: { provider: "test", id: "tiny" },
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
sessionManager: {
@@ -206,7 +207,7 @@ describe("/goals flow", () => {
expect(flow.messages.at(-1)?.content).toContain("Revise the plan at");
expect((await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "README.md" } }, flow.ctx))?.block).toBe(true);
await flow.commands.get("goals").handler("second objective", flow.ctx);
await flow.commands.get("goals").handler("plan second objective", flow.ctx);
expect(readFileSync(first, "utf8")).toContain("preserve this");
expect(flow.messages.at(-1)?.content).toContain("session-a-v2.md");
} finally {
@@ -324,14 +325,10 @@ describe("/goals flow", () => {
expect(readFileSync(planPath, "utf8")).toContain("1. [x] goal: make the file");
expect(readFileSync(planPath, "utf8")).toContain("1. [ ] goal: make the file");
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, signedOffGoals: [goal] });
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", approvalId, signedOffGoals: [goal] });
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]);
await flow.hooks.get("session_start")({}, flow.ctx);
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]);
const completedViews = flow.transport.sent.filter(message => message.kind === "view").length;
flow.transport.receive({ binding: approvalId, role: "supervisor", kind: "hello", id: "hello", ready: true });
await new Promise(resolve => setImmediate(resolve));
expect(flow.transport.sent.filter(message => message.kind === "view")).toHaveLength(completedViews);
flow.transport.receive({ binding: approvalId, role: "supervisor", kind: "steer", id: "post-signoff", text: "Inspect the late finding." });
expect(flow.messages.at(-1)?.content).toBe("[supervisor] Inspect the late finding.");
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
@@ -477,7 +474,7 @@ it.each(["launch", "model"])("rejects plan content changes during Ready %s witho
if (stage === "launch") openSupervisorPane.mockImplementationOnce(async () => { mutate(); return "pane-2"; });
else flow.pi.setModel.mockImplementationOnce(async () => { mutate(); return true; });
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", supervisorPaneId: "pane-2" });
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", supervisorPaneId: stage === "launch" ? "pane-2" : null });
expect(flow.messages.some(message => message.content.includes("Begin implementation"))).toBe(false);
expect(flow.notifications.join("\n")).toContain("plan changed after Ready");
await flow.hooks.get("agent_settled")({}, flow.ctx);
@@ -486,6 +483,29 @@ it.each(["launch", "model"])("rejects plan content changes during Ready %s witho
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("compacts the approved worker before forking the supervisor without injecting planning context", async () => {
const flow = setup(["Ready"]);
try {
let complete: (() => void) | undefined;
flow.ctx.compact.mockImplementationOnce((options: any) => { complete = options.onComplete; });
await flow.commands.get("goals").handler("make the file", flow.ctx);
approvedPlan(flow.cwd);
const ready = flow.hooks.get("agent_settled")({}, flow.ctx);
await new Promise(resolve => setImmediate(resolve));
expect(flow.ctx.compact).toHaveBeenCalledOnce();
expect(flow.ctx.compact.mock.calls[0][0].customInstructions).toContain("was just approved");
expect(openSupervisorPane).not.toHaveBeenCalled();
await flow.hooks.get("session_compact")({}, flow.ctx);
const context = await flow.hooks.get("context")({ messages: [] }, flow.ctx);
expect(context?.messages).toBeUndefined();
complete!();
await ready;
expect(flow.pi.setModel).toHaveBeenCalledBefore(openSupervisorPane);
expect(openSupervisorPane).toHaveBeenCalledOnce();
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working" });
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("falls back loudly after explicit Ready launch failure, preserving its failed pane and approved content", async () => {
const flow = setup(["Ready"]);
try {
@@ -565,11 +585,11 @@ it("keeps a failed Ready model not-ready and recovers the same real supervisor b
});
await flow.hooks.get("agent_settled")({}, flow.ctx);
await new Promise(resolve => setImmediate(resolve));
const binding = (flow.entries.at(-1)?.data as any).approvalId;
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", planVersion: 1 });
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", planVersion: 1, approvalId: null });
expect(openSupervisorPane).not.toHaveBeenCalled();
expect(wire.worker.sent.filter(message => message.kind === "hello" && message.ready)).toHaveLength(0);
expect(supervisor.connected).toBe(false);
expect(() => supervisor.steer("Must wait.")).toThrow("disconnected");
expect(() => supervisor.steer("Must wait.")).toThrow("pairing is not active");
await flow.hooks.get("model_select")({ source: "set", model: { provider: "test", id: "chosen" } }, flow.ctx);
flow.ctx.modelRegistry.find = (provider, id) => ({ provider, id });
await flow.commands.get("goals").handler("reconnect", flow.ctx);
@@ -577,6 +597,7 @@ it("keeps a failed Ready model not-ready and recovers the same real supervisor b
await flow.hooks.get("agent_settled")({}, flow.ctx);
await new Promise(resolve => setImmediate(resolve));
expect(supervisor.connected).toBe(true);
const binding = (flow.entries.at(-1)?.data as any).approvalId;
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", approvalId: binding, planVersion: 1 });
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
expect(closeSupervisorPane).not.toHaveBeenCalled();
@@ -783,10 +804,46 @@ it("stays loudly solo when replacement fails and never restores a cleared plan a
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it.each([
["planning", "planning" as const, "Keep drafting"],
["supervised work", "working" as const, "Keep working"],
["solo work", "working" as const, "Keep working unsupervised"],
])("keeps an active %s plan when bare or free-text /goals is submitted", async (_name, phase, keepAction) => {
const flow = setup([keepAction]);
try {
const path = restoredPlan(flow, phase);
await flow.hooks.get("session_start")({}, flow.ctx);
if (keepAction === "Keep working unsupervised") await flow.commands.get("goals").handler("solo", flow.ctx);
const before = readFileSync(path, "utf8");
const stateBefore = flow.entries.at(-1)?.data;
await flow.commands.get("goals").handler("", flow.ctx);
await flow.commands.get("goals").handler("describe a different project", flow.ctx);
expect(readFileSync(path, "utf8")).toBe(before);
expect(flow.entries.at(-1)?.data).toMatchObject(stateBefore as object);
expect(closeSupervisorPane).not.toHaveBeenCalled();
expect(flow.notifications.at(-1)).toContain("active plan is unchanged");
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("keeps the first-plan objective shortcut but requires plan to deliberately replace an active draft", async () => {
const flow = setup([]);
try {
await flow.commands.get("goals").handler("first objective", flow.ctx);
const firstPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
expect(readFileSync(firstPath, "utf8")).toBe("");
expect(flow.messages.at(-1)?.content).toContain("Objective: first objective");
await flow.commands.get("goals").handler("plan restart", flow.ctx);
expect(readFileSync(firstPath, "utf8")).toBe("");
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", planVersion: 2, latestDirection: "restart" });
expect(flow.messages.at(-1)?.content).toContain("Objective: restart");
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("offers per-verb autocomplete descriptions without treating an objective as a verb", () => {
const flow = setup([]);
try {
const complete = flow.commands.get("goals").getArgumentCompletions;
expect(complete("plan")).toEqual([{ value: "plan", label: "plan", description: expect.stringContaining("Deliberately") }]);
expect(complete("solo")).toEqual([{ value: "solo", label: "solo", description: expect.stringContaining("sign-off unavailable") }]);
expect(complete("re").map((item: any) => item.value)).toEqual(["reconnect", "restart"]);
expect(complete("write a report")).toBeNull();
@@ -882,7 +939,7 @@ it.each(["explicit solo", "terminal failure"])("announces %s to the old supervis
await new Promise(resolve => setImmediate(resolve));
expect(supervisor.connected).toBe(false);
expect(supervisorCtx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Worker entered solo mode; this pairing is detached"), "error");
expect(() => supervisor.steer("Obsolete advice.")).toThrow("pairing is detached");
expect(() => supervisor.steer("Obsolete advice.")).toThrow(cause === "explicit solo" ? "pairing is detached" : "Supervisor quota exceeded");
expect(flow.entries.at(-1)?.data).toMatchObject({ mode: "solo", approvalId: null });
expect(flow.messages.filter(message => message.display && message.content.startsWith("UNSUPERVISED WORKER"))).toHaveLength(1);
expect(wire.worker.sent.filter(message => message.failure?.startsWith("Worker entered solo"))).toHaveLength(1);
+2 -2
View File
@@ -1,5 +1,5 @@
export function intercomFixture() {
let autoHello = true;
export function intercomFixture(initialAutoHello = true) {
let autoHello = initialAutoHello;
let registration: any;
const sent: any[] = [];
let connected = true;
+18 -5
View File
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest";
import { GoalIntercom } from "../src/intercom.js";
import { intercomFixture } from "./intercom-fixture.js";
function setup(role: "worker" | "supervisor", entries: any[] = []) {
const fixture = intercomFixture();
function setup(role: "worker" | "supervisor", entries: any[] = [], autoHello = true) {
const fixture = intercomFixture(autoHello);
const hooks = new Map<string, any>();
const ctx = { isIdle: vi.fn(() => true), hasPendingMessages: vi.fn(() => false), sessionManager: { getEntries: () => entries }, ui: { notify: vi.fn() } };
const api = { events: fixture.events, on: (name: string, hook: any) => hooks.set(name, hook), appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }) };
@@ -39,7 +39,7 @@ describe("pi-intercom transport", () => {
const first = setup("supervisor");
first.link.markReady();
await first.link.waitReady();
const id = first.link.steer("Read the full output.");
const { id } = first.link.steer("Read the full output.");
await first.hooks.get("session_shutdown")();
const resumed = setup("supervisor", [...first.entries]);
resumed.link.markReady();
@@ -50,10 +50,11 @@ describe("pi-intercom transport", () => {
resumed.fixture.receive({ binding: "binding", role: "worker", kind: "received", id });
resumed.fixture.connect(false);
expect(resumed.link.connected).toBe(false);
expect(() => resumed.link.steer("Must not send.")).toThrow("disconnected");
const queued = resumed.link.steer("Must wait for reconnect.");
expect(queued.queued).toBe(true);
resumed.fixture.connect(true);
await resumed.link.waitReady();
expect(resumed.fixture.sent.filter(message => message.kind === "steer")).toHaveLength(retries.length);
expect(resumed.fixture.sent.filter(message => message.kind === "steer")).toHaveLength(retries.length + 1);
});
it("advances the incremental overview only after acknowledgment", async () => {
@@ -78,6 +79,18 @@ describe("pi-intercom transport", () => {
});
});
it("retries an unanswered active-binding hello twice, then leaves normal readiness recovery paused", async () => {
vi.useFakeTimers();
const runtime = setup("worker", [], false);
await vi.advanceTimersByTimeAsync(6_000);
expect(runtime.fixture.sent.filter(message => message.kind === "hello" && !message.reply)).toHaveLength(3);
await vi.advanceTimersByTimeAsync(60_000);
expect(runtime.fixture.sent.filter(message => message.kind === "hello" && !message.reply)).toHaveLength(3);
expect(runtime.link.connected).toBe(false);
await runtime.hooks.get("session_shutdown")();
vi.useRealTimers();
});
it("does not acknowledge a synchronous handoff failure, and retries the instruction", async () => {
vi.useFakeTimers();
const runtime = setup("worker");
+20
View File
@@ -244,6 +244,26 @@ describe("visible supervisor session", () => {
} finally { rmSync(cwd, { recursive: true, force: true }); }
});
it("retains one instruction while a worker is disconnected and delivers it once after reconnect", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
try {
const runtime = setup(cwd, join(cwd, "plan.md"));
await runtime.start();
runtime.transport.connect(false);
const tool = runtime.tools.get("SteerWorker");
const first = await tool.execute("id-1", { instruction: "Inspect the saved verification." });
const duplicate = await tool.execute("id-2", { instruction: "Inspect the saved verification." });
expect(first.isError).toBe(false);
expect(duplicate.isError).toBe(false);
expect(first.content[0].text).toContain("retained locally");
expect(runtime.transport.sent.filter(message => message.kind === "steer")).toHaveLength(0);
runtime.transport.connect(true);
await new Promise(resolve => setImmediate(resolve));
expect(runtime.transport.sent.filter(message => message.kind === "steer")).toMatchObject([{ text: "Inspect the saved verification." }]);
expect(runtime.transport.sent.filter(message => message.kind === "steer")).toHaveLength(1);
} finally { rmSync(cwd, { recursive: true, force: true }); }
});
it("records approval only from a stopped view with evidence and no active work", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
try {