Allow goal-worker inspection and restore supervision guidance

This commit is contained in:
wassname2
2026-09-16 13:31:50 +08:00
parent 732182bd07
commit b2b1347cce
6 changed files with 75 additions and 14 deletions
+11
View File
@@ -29,6 +29,17 @@ The supervisor should:
- Keep recoverable solo mode: confirm other writers stopped before taking over. Solo completion is self-verification.
- Record distinct runtime ID, Intercom ID and saved-session path with provenance. A handle or delivery receipt is not proof of liveness or action. User model changes are authorized; do not silently restore an old preference.
## Waiting and check-in judgment
- Followed long job: let it run, verify follow-up and check less often.
- Unfollowed job: arrange coverage through existing controls; do not assume a wake.
- Owned subagent still running: inspect through its owner; an ended worker turn is not completion.
- Later wake: inspect new results/failure and continue or steer without replaying completed work.
Reassess cadence by editing the existing owned check-in: slower for reliable long waits, faster when steering is needed. Consider a more capable worker within user model/budget preferences. Preserve custom prompts and foreign jobs; do not add timers.
— wassname's guidance; Pi wording and spelling edits.
## Tests
Run `npm test`, `npm run typecheck` and `npm run lint` before committing.
+4 -1
View File
@@ -600,7 +600,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, fullPlanContextDue ? snapshot.text : unfinishedGoals(snapshot.text), fullPlanContextDue ? "full" : "short"), display: false }
: pendingUpkeep?.generation === generation && pendingUpkeep.workingSet === foldPlan(snapshot.text)
&& ["supervising", "solo"].includes(state.mode) && unfinishedGoals(snapshot.text)
? { customType: "pi-goals-upkeep", content: upkeep(state.plan!, unfinishedGoals(snapshot.text)), display: false } : undefined;
? { customType: "pi-goals-upkeep", content: upkeep(state.plan!, unfinishedGoals(snapshot.text), state.mode === "supervising" ? ctx.sessionManager.getBranch().filter(entry => entry.type === "custom_message" && entry.customType === "pi-goals-upkeep").length : undefined), display: false } : undefined;
if (message) turnsStale = 0;
notice = false;
fullPlanContextDue = false;
@@ -637,6 +637,8 @@ export default function mainSupervisor(pi: ExtensionAPI) {
if (owned && typeof input.prompt === "string" && input.prompt !== input.prompt.trim().replace(/\s+/g, " ")) return { block: true, reason: schedulerMessages.format };
}
if (event.toolName !== "subagent" && event.toolName !== "OpenGoalWorker") return;
// Stock inspection actions do not authorize execution or extra goal workers.
if (event.toolName === "subagent" && typeof event.input?.action === "string" && ["list", "get", "models", "guide", "status", "children.list", "project.status"].includes(event.input.action)) return;
if (state.child || ["planning", "paused", "solo"].includes(state.mode)) return { block: true, reason: goalToolBlocked(state.child ? "worker" : state.mode) };
});
@@ -798,6 +800,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
parameters: Type.Object({ task: Type.String({ minLength: 1 }), model: Type.Optional(Type.String({ description: nativeMessages.modelDescription })) }),
async execute(_id, params, signal, _update, ctx) {
if (state.child || state.mode !== "supervising" || !state.plan) return result(goalToolBlocked(state.mode));
// TODO(2026-11+, Pi): Recheck pi-subagents/project-panes v1's one-pane-per-cwd limit before adding multiple visible workers.
if (opening || state.worker) return result(nativeMessages.alreadyRecorded);
if (!params.task.trim()) return result(nativeMessages.taskRequired);
const preference = params.model?.trim() || notedPlanValue("preferred worker model");
+18 -3
View File
@@ -166,10 +166,16 @@ export function workerAssignment(plan: string, parent: string, requestId: string
const preference = model ? `User-supplied model preference: ${JSON.stringify(model)}. This is an instruction, not observed configuration. Configure it through supported controls in this worker session and report the actual provider/model after verification. Preserve later human model changes; do not reapply an older preference. If this choice is unavailable, report that specific limitation without silently substituting or stalling unrelated authorized work.` : "Inherit the native model; no model switch was requested by this assignment.";
return `You are a new goals-worker in a native project pane for plan ${plan}; request ${requestId}. First call AttachGoalPlan with path ${JSON.stringify(plan)}, parent ${JSON.stringify(parent)} and requestId ${JSON.stringify(requestId)}. Until attachment succeeds, do not implement. Read the supplied plan, applicable AGENTS.md and skills. Confirm the exact parent Intercom UUID ${parent} in the live roster; send it your initial actual Intercom UUID, saved-session path, resolved provider/model and thinking level. Do not infer one identity from another. Use normal tools. ${preference} After attaching and reporting, WAIT for an explicit assignment from that exact parent Intercom session before implementation; the parent may have paused since opening this pane. Proposed task (context only, not execution permission):\n\n${task}\n\nSave actual artifacts and verification output. Report blocked, error and result evidence through Intercom to that exact parent. The parent independently inspects and may send a concrete correction here. Do not approve goals or launch another writer. Respect human pauses and intervention. Keep this conversation open with the final review visible; do not exit, reset, switch session or close the pane.`;
}
// wassname's guidance, with Pi wording/spelling edits; decisions remain with the supervisor.
const waitingGuidance = `Followed long job: let it run; verify its follow-up and check less often.
Unfollowed job: arrange coverage through existing controls rather than assume a wake.
Owned subagent still running: inspect through its owner; a finished worker turn is not task completion.
Later wake: inspect new results/failure and continue or steer, without replaying completed work.
Reassess your cadence: edit the existing owned check-in, slower for reliable long waits and faster when steering is needed. Consider a more capable worker within the user's model/budget preferences. Preserve custom prompts and foreign jobs; do not add a timer. -- wassname (Pi wording/spelling edits)`;
// Supervision and turn-event upkeep (not a scheduled wake-up).
const supervisorJob = "Your job is to be an autonomous research partner and supervisor with responsibility for the user's goals. Keep perspective, bring diligence, and use research taste and wisdom to sustain work overnight and keep it on track. Resolve routine implementation decisions yourself; ask the user only when their judgment or authorization is needed. At each check-in, inspect the plan and workers for drift, loops and stuck/stopped/blocked work; ensure follow-up and give a brief user-facing plan update rather than repeat the previous recap.";
export function supervisor(workerName: string, planPath: string, supervisorId: string): string {
return `You are the goal supervisor in the main chat for ${planPath}. ${supervisorJob} Inspect actual artifacts, saved verification, applicable AGENTS.md and skills yourself; delegate implementation to '${workerName}'. Keep authorized work moving to the requested outcome, not merely approval paperwork. Investigate blocked/waiting/done claims using recent saved tool calls with arguments and results, then current child/job status when needed. History proves a launch or watch at that time, not current liveness. A worker ending its turn may still await work; verify follow-up and change ineffective instructions. Give brief visible assessments with judgment. You may maintain the plan but must not weaken the goal to accept worker output.
return `You are the goal supervisor in the main chat for ${planPath}. ${supervisorJob}\n${waitingGuidance}\nInspect actual artifacts, saved verification, applicable AGENTS.md and skills yourself; delegate implementation to '${workerName}'. Keep authorized work moving to the requested outcome, not merely approval paperwork. Investigate blocked/waiting/done claims using recent saved tool calls with arguments and results, then current child/job status when needed. History proves a launch or watch at that time, not current liveness. A worker ending its turn may still await work; verify follow-up and change ineffective instructions. Give brief visible assessments with judgment. You may maintain the plan but must not weaken the goal to accept worker output.
You can be playful: let the humor come from what actually happened. Avoid repeating recent jokes, nicknames or kaomoji; plain updates are welcome too. No forced cheerfulness or novelty. If supervision gets repetitive, step back and change your approach. Keep it brief and aimed at the goal, not another reporting chore.
You can speculate and brainstorm around uncertainty or unexpected results. Label guesses as guesses, consider alternative explanations, and look for a useful way to tell them apart. Keep exploration brief, open-minded and fun: take a step back, play with surprising ideas, question the current framing, and enjoy exploring the broader perspective while staying connected to the agreed goal.
(b •_•)b -- wassname
@@ -179,8 +185,17 @@ Keep normal tools and honor human model changes. Inherit by default. If the user
}
// Routine notices quote only selected goal lines; full context stops at Log.
const goalLines = (text: string) => foldPlan(text).split("\n").filter(line => GOAL_LINE.test(line)).join("\n");
export function upkeep(planPath: string, text: string): string {
return `[pi-goals: reminder — upkeep]\nEight unchanged turns: update task ticks, evidence or Log only for new progress. Finish any evidence review already underway; do not restart completed or paused work.\n\n${quotedPlan(planPath, goalLines(text), "unfinished or unreviewed goal lines")}`;
// Restored from pre-acbe21f; curated general-purpose quotes from wassname/ml-debug/fortune.txt.
export const upkeepNudges = [
"Insufficient skepticism doesn't feel like insufficient skepticism from the inside. It just feels like doing research. -- Neel Nanda",
"Don't let your instruments overwhelm your system. -- David J. Agans, *Debugging: The 9 Indispensable Rules*",
"The first step is just making time to stop and ask yourself: do I endorse what I'm doing, and could I be doing something better? -- Neel Nanda",
"It seems important to really commit yourself to always investigate whenever you notice confusion. -- Dan Rahtz",
"QUIT THINKING AND LOOK. -- David J. Agans, *Debugging: The 9 Indispensable Rules*",
];
export function upkeep(planPath: string, text: string, supervisorRound?: number): string {
const nudge = supervisorRound === undefined ? "" : `\n\nPerspective, if useful: ${upkeepNudges[supervisorRound % upkeepNudges.length]}`;
return `[pi-goals: reminder — upkeep]\nEight unchanged turns: update task ticks, evidence or Log only for new progress. Finish any evidence review already underway; do not restart completed or paused work.${nudge}\n\n${quotedPlan(planPath, goalLines(text), "unfinished or unreviewed goal lines")}`;
}
export function planContext(mode: string, path: string | undefined, text: string, tier: "short" | "medium" | "full" = "full"): string {
return `[pi-goals: context resync]\nCurrent goal mode: ${mode}. Earlier role messages are historical; this current role governs. Read the plan file for details and earlier evidence; do not restart completed work.\n\n${quotedPlan(path, tier === "full" ? foldPlan(text) : goalLines(text), tier === "full" ? "active plan above Log" : "unfinished or unreviewed goal lines")}`;
+17 -5
View File
@@ -8,7 +8,6 @@ import { visibleWidth } from "@earendil-works/pi-tui";
import { openProjectPane } from "pi-subagents/project-panes";
import { afterEach, expect, it, vi } from "vitest";
import goalsExtension from "../src/index.js";
import { upkeep } from "../src/prompts.js";
vi.mock("pi-subagents/project-panes", () => ({ openProjectPane: vi.fn(async () => ({ ok: true, data: { bindingPath: "/project/.pi/subagents/project-pane.json", disposition: "opened", binding: { paneId: "native-pane", projectRoot: "/project", command: "pi" } } })) }));
@@ -125,6 +124,7 @@ it.each(["menu", "command"])("enters planning conversation through %s without a
expect(f.messages).toHaveLength(1);
expect(f.messages[0].message.content).toContain(route === "menu" ? "Initial idea: supplied instructions" : "Use the existing conversation");
expect(f.hooks.get("tool_call")({ toolName: "subagent" }).block).toBe(true);
expect(f.hooks.get("tool_call")({ toolName: "subagent", input: { action: "status" } })).toBeUndefined();
});
it("cancelled menu New creates nothing and sends nothing", async () => {
@@ -569,6 +569,7 @@ it("requires confirmed worker stop before solo takeover and never lets two write
expect(f.hooks.get("tool_call")({ toolName: "subagent" }).block).toBe(true);
expect(f.hooks.get("tool_call")({ toolName: "OpenGoalWorker" }).block).toBe(true);
expect(f.hooks.get("tool_call")({ toolName: "read" })).toBeUndefined();
expect(f.hooks.get("tool_call")({ toolName: "subagent", input: { action: "status" } })).toBeUndefined();
mkdirSync(join(f.ctx.cwd, "evidence")); writeFileSync(join(f.ctx.cwd, "evidence/pass.log"), "bytes\n");
const text = (await f.tools.get("CompleteGoal").execute("t", { goal: "first output", evidence: ["evidence/pass.log"], observation: "inspected" }, undefined, undefined, f.ctx)).content[0].text;
expect(text).toContain("self-verification");
@@ -894,15 +895,19 @@ it.each(["supervising", "solo"])("%s repeats concise upkeep every eight unchange
f.hooks.get("session_compact")();
expect(prepare().message.customType).toBe("pi-goals-plan");
const sent = f.messages.length;
let previous: string | undefined;
for (let round = 0; round < 2; round++) {
for (let turn = 0; turn < 7; turn++) f.hooks.get("turn_end")({}, f.ctx);
expect(prepare().message).toBeUndefined();
f.hooks.get("turn_end")({}, f.ctx);
expect(f.messages).toHaveLength(sent);
expect(prepare().message).toMatchObject({
customType: "pi-goals-upkeep",
content: upkeep(f.path, f.plan.split("\n").filter(line => line.includes("goal:")).join("\n")),
});
const reminder = prepare().message;
expect(reminder.customType).toBe("pi-goals-upkeep");
expect(reminder.content).toContain("goal: first output");
if (previous && mode === "supervising") expect(reminder.content).not.toBe(previous);
if (previous && mode === "solo") expect(reminder.content).toBe(previous);
f.ctx.sessionManager.getBranch().push({ type: "custom_message", ...reminder }); // host saves returned messages
previous = reminder.content;
expect(prepare().message).toBeUndefined();
}
});
@@ -1145,6 +1150,13 @@ it("ordinary project peer explicitly attaches as worker, never gaining approval
expect(f.entries.at(-1).data).toMatchObject({ child: true, parent: { intercomId: "live-parent", requestId: "assignment-id" }, plan: path });
expect(f.channel.publish).toHaveBeenCalledWith(expect.objectContaining({ type: "attached", to: "live-parent", sessionFile: f.ctx.sessionManager.getSessionFile(), identity: expect.objectContaining({ model: "offline/inherited" }) }), { audience: "capable" });
await f.command("ready"); await f.command("solo");
await f.command("stop"); // inspection remains available while paused; it does not authorize continuation
expect(f.hooks.get("tool_call")({ toolName: "subagent", input: { action: "status" } })).toBeUndefined();
for (const action of [undefined, "resume", "schedule.create", "update", "project.open", "unknown.action", ["status"]]) {
expect(f.hooks.get("tool_call")({ toolName: "subagent", input: { action, agent: "reviewer" } }).block).toBe(true);
}
expect(f.hooks.get("tool_call")({ toolName: "OpenGoalWorker", input: { action: "status" } }).block).toBe(true);
await f.command("resume");
const reply = await f.tools.get("CompleteGoal").execute("complete", { goal: "first output", evidence: [path], observation: "claim" }, undefined, undefined, f.ctx);
expect(reply.content[0].text).toContain("only to the active parent");
const next = join(f.ctx.cwd, "next.md"); writeFileSync(next, f.plan);
+4 -3
View File
@@ -22,8 +22,9 @@ it.each([true, false])("prepares offline=%s without changing the source profile
let root: string | undefined;
try {
// An absent source profile/SDK proves the offline route does not read or import them.
const prepared = spawnSync(process.execPath, [resolve("scripts/prepare-trial.mjs"), offline ? "unused-sdk" : resolve("node_modules/@earendil-works/pi-coding-agent"), ...(offline ? ["--offline", "http://127.0.0.1:12345"] : [])], { encoding: "utf8", env: { ...process.env, PI_OFFLINE: "1", PI_CODING_AGENT_DIR: offline ? join(source, "absent") : source } });
expect(prepared.status, prepared.stderr).toBe(0);
// Full-suite preparation exceeded 5s; bound the SDK subprocess and allow test cleanup margin.
const prepared = spawnSync(process.execPath, [resolve("scripts/prepare-trial.mjs"), offline ? "unused-sdk" : resolve("node_modules/@earendil-works/pi-coding-agent"), ...(offline ? ["--offline", "http://127.0.0.1:12345"] : [])], { encoding: "utf8", timeout: 10_000, env: { ...process.env, PI_OFFLINE: "1", PI_CODING_AGENT_DIR: offline ? join(source, "absent") : source } });
expect(prepared.status, prepared.error?.message || prepared.stderr).toBe(0);
const trial = JSON.parse(prepared.stdout); root = trial.root;
const actual = JSON.parse(readFileSync(join(trial.agentDir, "settings.json"), "utf8"));
const manifest = JSON.parse(readFileSync(trial.manifest, "utf8"));
@@ -42,4 +43,4 @@ it.each([true, false])("prepares offline=%s without changing the source profile
expect(JSON.parse(readFileSync(join(trial.agentDir, "auth.json"), "utf8"))).toEqual({});
expect(JSON.parse(readFileSync(join(trial.agentDir, "models.json"), "utf8"))).toEqual({ providers: {} });
} finally { if (root) rmSync(root, { recursive: true, force: true }); rmSync(source, { recursive: true, force: true }); }
});
}, 15_000);
+21 -2
View File
@@ -7,6 +7,7 @@ import { basename, join, resolve } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { expect, it } from "vitest";
import { foldPlan } from "../src/plan.js";
import { upkeepNudges } from "../src/prompts.js";
type RpcMessage = { type: string; id?: string; method?: string; [key: string]: unknown };
type ModelRequest = { messages: Array<{ role: string; content: string | Array<{ type: string; text?: string }> }> };
@@ -94,9 +95,10 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
const child = spawn(resolve("node_modules/.bin/pi"), ["--mode", "rpc", "--no-extensions", "--model", "offline/test",
"-e", resolve("test/fixtures/offline-model.ts"), "-e", resolve("src/index.ts"),
"-e", resolve("node_modules/pi-intercom/index.ts"), "-e", resolve("node_modules/@jl1990/pi-scheduler/extensions/scheduler/index.ts"),
...(role === "worker" ? ["-e", resolve("node_modules/pi-subagents/index.ts")] : []),
...(sessionFile ? ["--session", sessionFile] : [])], { cwd, env: {
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("PI_SUBAGENT_") && !name.startsWith("PI_GOALS_") && !name.startsWith("HERDR_"))),
PI_CODING_AGENT_DIR: join(cwd, "agent"), PI_OFFLINE: "1", PI_INTERCOM_SCOPE_ID: basename(cwd),
PI_CODING_AGENT_DIR: join(cwd, "agent"), PI_OFFLINE: "1", PI_INTERCOM_SCOPE_ID: basename(cwd), PI_SUBAGENTS_TEMP_ROOT: join(cwd, "subagents"),
PI_SCHEDULER_STATE_FILE: join(cwd, "scheduler.json"), PI_GOALS_OFFLINE_MODEL_URL: `http://127.0.0.1:${port}/${role}`,
} }); const client = new RpcClient(child); clients.push(client); return client;
}
@@ -160,7 +162,16 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
const parentState = await state(parent);
worker = start("worker");
await run(worker, "worker", call("intercom", { action: "list" }));
await run(worker, "worker", call("AttachGoalPlan", { path: planPath, parent: parentId, requestId: "rpc-assignment" }), call("ReportGoalEvent", { kind: "receipt", summary: "Attached and waiting." }));
const inspectAt = worker.messages.length, parentCount = requests.parent.length;
await run(worker, "worker", call("AttachGoalPlan", { path: planPath, parent: parentId, requestId: "rpc-assignment" }),
call("subagent", { action: "list", capabilities: true, agentScope: "project" }), call("subagent", { action: "status" }),
call("OpenGoalWorker", { task: "Must remain blocked in the attached worker" }), call("ReportGoalEvent", { kind: "receipt", summary: "Attached and waiting." }));
const inspections = worker.messages.slice(inspectAt).filter(m => m.type === "tool_execution_end" && m.toolName === "subagent");
expect(inspections).toHaveLength(2);
for (const inspection of inspections) { expect(inspection.isError).not.toBe(true); expect((inspection.result as any).details.results).toEqual([]); }
expect((inspections.at(-1)!.result as any).details.spawnBudget.used).toBe(0);
expect(worker.messages.slice(inspectAt).find(m => m.type === "tool_execution_end" && m.toolName === "OpenGoalWorker")?.isError).toBe(true);
expect(requests.parent).toHaveLength(parentCount); // inspection/receipt did not wake the supervisor
const workerState = await state(worker), workerFile = workerState.sessionFile;
expect(records(parentState.sessionFile, "pi-goals-worker-event").map(event => event.kind)).toEqual(["receipt"]);
const greeting = join(cwd, "greeting.txt");
@@ -215,6 +226,14 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
expect(savedReviews.map(r => r.verdict)).toEqual(["changes_requested", "accepted"]);
expect(savedReviews.map(r => r.report)).toEqual([failure.id, correction.id]); // old consumers key this wire field
expect(records(parentState.sessionFile, "pi-goals-report-review")).toEqual(savedReviews);
const upkeepAt = requests.parent.length, ordinaryTurns = 17; // two eight-turn periods plus prompt preparation
for (let turn = 0; turn < ordinaryTurns; turn++) await run(parent, "parent");
expect(requests.parent).toHaveLength(upkeepAt + ordinaryTurns); // nudges join ordinary prompts, never create a turn
expect(requests.worker).toHaveLength(workerCount);
const notes = entries(parentState.sessionFile).filter(entry => entry.type === "custom_message" && entry.customType === "pi-goals-upkeep");
const delivered = upkeepNudges.filter(nudge => notes.some(entry => entry.content.includes(nudge)));
expect(delivered.length).toBeGreaterThan(1);
for (const nudge of delivered) expect(JSON.stringify(requests.parent)).toContain(nudge);
await command(worker, "/fixture-reload"); // real shutdown/start after a formal event stays quiet
expect(requests.worker).toHaveLength(workerCount);
expect(records(parentState.sessionFile, "pi-goals-worker-event")).toHaveLength(statusCount);