Retain pre-open worker correlation and surface latest saved failures

This commit is contained in:
wassname2
2026-09-21 11:49:19 +08:00
parent adc50dc322
commit b0bc7816ac
6 changed files with 91 additions and 15 deletions
+2 -2
View File
@@ -157,7 +157,7 @@ The parent and worker keep separate native conversations. Worker attachment and
Supervisors and workers can use ordinary stock async helpers, with one writer per cwd. These are headless subagents, not additional interactive goals-workers; goal lifecycle hooks stay off even when they inherit forked history. The owning session follows results and failures through stock controls; an optional stock inspector only displays their work. Pausing blocks new owner launch/resume requests while keeping inspection and stop/interrupt available. Already-dispatched workflows may continue until stopped through their owner. <!-- Pi/OpenAI -->
`OpenGoalWorker` supplies startup only to a newly created stock Pi context. An existing live binding receives no message, so opening it does not replace its conversation or editor draft. The new worker calls `AttachGoalPlan`, reports its exact Intercom identity/model/saved-session path, and waits for a direct parent assignment. Revisions use that same session. A model preference is an instruction for agent-led configuration and verification, not a CLI override; later human changes take precedence.
`OpenGoalWorker` supplies startup only to a newly created stock Pi context. An existing live binding receives no message, so opening it does not replace its conversation or editor draft. A definite stock pre-open failure retains the prior worker reference and report routing. Ambiguous partial opens retain the reservation and previous saved history; inspect stock state before retrying, without inferring rollback or writer exit. The new worker calls `AttachGoalPlan`, reports its exact Intercom identity/model/saved-session path, and waits for a direct parent assignment. Revisions use that same session. A model preference is an instruction for agent-led configuration and verification, not a CLI override; later human changes take precedence.
`OpenGoalWorker` delegates pane ownership to stock open. When stock opens a new pane, pi-goals preserves and supersedes any recorded worker binding and correlates the replacement. When stock reports an existing pane, pi-goals preserves the current binding and returns that result for normal supervisor handling. This recovers moved/cloned supervisors without a human infrastructure modal or extension-level liveness gate. pi-goals owns attachment, report and stop correlation—not generic writer concurrency. Raw `project.open` is not a goals-worker recovery path because it lacks those hooks. — Pi/OpenAI
@@ -165,7 +165,7 @@ Supervisors and workers can use ordinary stock async helpers, with one writer pe
## Context delivery
`worker_view` reads the attached worker's saved history, or the worker's own history. Its default is incremental VCC Markdown plus current Intercom model/context status, unanswered calls and a child-process count. Tool calls remain VCC one-line summaries; late, failed and background-control results get bounded one-line outcomes. Raw result bodies, JSON, transcript dumps and repeated compaction summaries stay out of the view. Use `detail: "diagnostic"` for bounded IDs and process commands. Detached queues still require their native owner. <!-- Pi/OpenAI -->
`worker_view` reads the attached worker's saved history, or the worker's own history. Its default is incremental VCC Markdown plus current Intercom model/context status, unanswered calls and a child-process count. Tool calls remain VCC one-line summaries; late, failed and background-control results get bounded one-line outcomes. Latest saved failure/stop outcomes stay visible above history paging, without implying current liveness. Compaction preserves retained messages and qualifies earlier activity as unknown. Raw result bodies, JSON, transcript dumps and repeated compaction summaries stay out of the view. Use `detail: "diagnostic"` for bounded IDs and process commands. Detached queues still require their native owner. <!-- Pi/OpenAI -->
Routine injected `[pi-goals]` prompts are one compact custom message; `Ctrl+O` expands the exact text. Role-changing transitions remain normal user prompts so the new role applies before the turn, but render as one nonempty compact line. — Pi/OpenAI
+9 -5
View File
@@ -909,6 +909,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
if (self.length !== 1) return result(nativeMessages.noIdentity);
if (preflight !== generation || opening || signal?.aborted) return result(messages.cancelled);
const superseded = state.worker ? structuredClone(state.worker) : undefined;
const previouslyStopped = state.workerStopped;
const preference = params.model?.trim() || notedPlanValue("preferred worker model");
const model = preference && (preference.includes("/") || !/^(?:none|\(none|default|inherit|not stated)\b/i.test(preference)) ? preference : undefined;
const plan = state.plan;
@@ -918,15 +919,18 @@ export default function mainSupervisor(pi: ExtensionAPI) {
try {
// Stock open sends startup only to a newly created context; existing panes receive nothing.
const pane = await openProjectPane({ cwd: ctx.cwd, message: workerAssignment(plan, self[0].id, requestId, params.task, model), focus: false, signal });
if (pane.ok && state.plan === plan && state.worker?.requestId === requestId) {
if (pane.data.disposition === "already-open" && superseded) state.worker = superseded;
else {
// Stock v1 emits these codes only before pane split/run. Other errors may follow a partial open.
const unopened = !pane.ok && ["INVALID_PROJECT_ROOT", "INVALID_BINDING", "BINDING_READ_FAILED", "PANE_OWNERSHIP_UNVERIFIED"].includes(pane.error.code);
if ((pane.ok || unopened) && state.plan === plan && state.worker?.requestId === requestId) {
if (unopened || pane.ok && pane.data.disposition === "already-open" && superseded) {
state.worker = superseded; state.workerStopped = previouslyStopped;
} else if (pane.ok) {
if (superseded) pi.appendEntry(WORKER_RELEASE, { plan, worker: superseded, supersededBy: identity(ctx), task: params.task, at: new Date().toISOString() });
state.worker.paneId = pane.data.binding.paneId;
state.worker!.paneId = pane.data.binding.paneId;
}
save(); refresh(ctx);
}
return result(pane.ok ? JSON.stringify({ disposition: pane.data.disposition, paneId: pane.data.binding.paneId, projectRoot: pane.data.binding.projectRoot, bindingPath: pane.data.bindingPath }) + nativeMessages.openReceipt : JSON.stringify(pane));
return result(pane.ok ? JSON.stringify({ disposition: pane.data.disposition, paneId: pane.data.binding.paneId, projectRoot: pane.data.binding.projectRoot, bindingPath: pane.data.bindingPath }) + nativeMessages.openReceipt : (unopened ? nativeMessages.unopened : nativeMessages.openUncertain) + JSON.stringify(pane));
} catch (error) { return result(nativeMessages.openFailed + String(error)); }
finally { opening = false; }
},
+5 -1
View File
@@ -15,6 +15,8 @@ export const workerViewText = {
noHistory: "no attached saved session",
identityMismatch: "saved-session identity mismatch",
expand: "expand worker view",
historicalOutcomes: "Latest saved outcomes (not current liveness)",
compactedActivity: "Compaction limits retained activity; earlier unanswered calls are unknown, not proof of no work.",
};
export const workerViewUnavailable = (reason: string) => `Worker view unavailable: ${reason}. Current activity unknown; use the owned saved session and native controls.`;
@@ -291,7 +293,9 @@ export const nativeMessages = {
alreadyRecorded: "A worker launch is already opening. Inspect its result before another launch.",
noIdentity: "Intercom identity unavailable; no worker opened.",
openReceipt: "\nIf opened, await AttachGoalPlan and a correlated Intercom report, then send an explicitly authorized assignment to that exact session. If already-open, no startup was sent: inspect the existing conversation and ownership, do not retask or close it blindly. Any model preference awaits agent configuration/verification. Never infer attachment or implementation from this receipt.",
openFailed: "Native open failed; inspect the binding and possible live writer, fix or record the specific infrastructure defect, then retry or continue through an authorized bounded helper: ",
unopened: "Stock failed before opening; prior worker reference retained. No replacement or writer exit inferred. ",
openUncertain: "Native open outcome uncertain; reservation retained, prior reference remains in saved state history. Inspect the binding and possible live writer before retrying; no rollback, successful replacement or writer exit is established. ",
openFailed: "Native open threw; outcome uncertain. Inspect the binding and possible live writer before retrying; no rollback, successful replacement or writer exit is established. Prior reference remains in saved state history. ",
parentUnavailable: "Parent Intercom identity is not live; no worker attachment changed.",
reattachAuthorization: "Changing an attached plan/request requires explicit authorization from the recorded parent: supply that same parent Intercom UUID and its new requestId. Different-parent takeover or missing fields is refused; no attachment changed.",
intercomNotReady: "Intercom is still connecting. Call intercom status/list, verify the live parent identity, then retry this operation in the same session. No attachment or launch changed.",
+36 -2
View File
@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import { compile } from "@sting8k/pi-vcc/src/core/summarize.js";
import { workerViewText } from "./prompts.js";
export const MAX_WORKER_VIEW_BYTES = 8_000;
const RECALL = /\n*-*\n*Use `vcc_recall`[\s\S]*$/;
@@ -42,7 +43,9 @@ export function viewClip(text: string, bytes: number): string {
function messageRows(entries: SessionEntry[]) {
const boundary = entries.map(entry => entry.type).lastIndexOf("compaction");
const boundaryId = boundary < 0 ? "root" : entries[boundary].id;
const rows = entries.slice(boundary + 1).flatMap(entry => {
const checkpoint = entries[boundary];
const kept = checkpoint?.type === "compaction" ? entries.findIndex(entry => entry.id === checkpoint.firstKeptEntryId) : -1;
const rows = entries.slice(kept >= 0 && kept < boundary ? kept : boundary + 1).flatMap(entry => {
if (entry.type === "message") return [{ id: entry.id, timestamp: entry.timestamp, message: entry.message }];
if (entry.type === "custom_message" && !entry.customType.startsWith("pi-goals-")) {
return [{ id: entry.id, timestamp: entry.timestamp, message: { role: "user" as const, content: entry.content, timestamp: Date.parse(entry.timestamp) } }];
@@ -52,6 +55,34 @@ function messageRows(entries: SessionEntry[]) {
return { boundary: boundaryId ?? `compaction-${boundary}`, rows };
}
// Read the latest outcomes from the whole saved branch, independently of VCC paging/compaction.
function latestOutcomes(entries: SessionEntry[]): string[] {
let latest = "", failure = "";
const compact = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, 500);
for (const entry of entries) {
let outcome = "", failed = false;
if (entry.type === "message") {
const message = entry.message;
if (message.role === "assistant" && (message.errorMessage || ["stop", "error", "aborted"].includes(message.stopReason))) {
failed = Boolean(message.errorMessage) || message.stopReason === "error";
outcome = `assistant ${message.stopReason}: ${compact(message.errorMessage || message.content.filter(block => block.type === "text").map(block => block.text).join(" "))}`;
} else if (message.role === "toolResult" && message.isError) {
failed = true;
outcome = `${message.toolName} failed: ${compact(message.content.filter(block => block.type === "text").map(block => block.text).join(" "))}`;
}
} else if (entry.type === "custom" && entry.customType === "pi-goals-worker-stop") {
const stop = entry.data as { kind?: string; text?: string };
failed = stop.kind === "blocker";
outcome = `${stop.kind || "unclassified"}: ${compact(stop.text || "")}`;
}
if (outcome) {
latest = `${entry.timestamp}: ${outcome}`;
if (failed) failure = latest;
}
}
return [failure && failure !== latest ? `Last failure — ${failure}` : "", latest && `Last outcome — ${latest}`].filter(Boolean);
}
function cleanCompile(messages: unknown[]): string {
return compile({ messages }).replace(RECALL, "").trim();
}
@@ -142,6 +173,7 @@ export function buildWorkerView(
diagnostic = false,
): { text: string; cursor: WorkerViewCursor } {
const current = messageRows(entries), rows = current.rows;
const outcomes = latestOutcomes(entries);
const sameHistory = previous?.sessionFile === sessionFile && previous.boundary === current.boundary;
const anchor = sameHistory && previous.through ? rows.findIndex(row => row.id === previous.through) : -1;
const since = anchor >= 0 ? anchor + 1 : 0;
@@ -173,10 +205,12 @@ export function buildWorkerView(
const remaining = fresh.length - consumed.length;
const lines = [
"## Worker view",
`Task: ${task.replace(/\s+/g, " ").trim().slice(0, 400) || "unknown"}`,
`Launch task: ${task.replace(/\s+/g, " ").trim().slice(0, 400) || "unknown"}`,
`Status: ${status}; last saved activity ${age(lastTimestamp)} ago`,
`Model: ${model}`,
`Background: ${background}; unanswered tool calls: ${pendingNames}`,
...(current.boundary !== "root" ? [workerViewText.compactedActivity] : []),
...(outcomes.length ? ["", `### ${workerViewText.historicalOutcomes}`, ...outcomes.map(outcome => `- ${outcome}`)] : []),
...(stale ? [`Progress: no new file or commit for ${stale} view${stale === 1 ? "" : "s"} with new turns`] : []),
"",
"### VCC summary of new turns",
+38 -5
View File
@@ -107,10 +107,12 @@ it("shows incremental VCC Markdown without raw tool results or compaction dumps"
expect(next).toContain("Implemented the correction");
expect(next).toContain("src/a.ts");
expect(next).toContain("process returned: Process 1820 exited successfully");
expect(next).not.toContain("missing.txt");
history.push({ type: "compaction", id: "later-checkpoint", timestamp, summary: "SECOND_COMPACTION_DUMP_MUST_STAY_HIDDEN" });
expect(next.split("### VCC summary of new turns")[1]).not.toContain("missing.txt");
history.push({ type: "compaction", id: "later-checkpoint", firstKeptEntryId: "pending-call", timestamp, summary: "SECOND_COMPACTION_DUMP_MUST_STAY_HIDDEN" });
for (let i = 0; i < 12; i++) history.push(entry(`post-compaction-${i}`, { role: "assistant", content: [{ type: "text", text: `POST_COMPACTION_TURN_${i}` }] }));
const afterCompaction = (await tool.execute("after-compaction", {}, undefined, undefined, f.ctx)).content[0].text;
expect(afterCompaction).toContain("unanswered tool calls: edit"); // retained process call has its result; retained edit is still unanswered
expect(afterCompaction).toContain("earlier unanswered calls are unknown");
expect(afterCompaction).toContain("POST_COMPACTION_TURN_0");
expect(afterCompaction).toContain("POST_COMPACTION_TURN_11");
expect(afterCompaction).not.toContain("SECOND_COMPACTION_DUMP_MUST_STAY_HIDDEN");
@@ -126,14 +128,20 @@ it("paginates oversized worker history without advancing past omitted turns", ()
type: "message" as const, id: `large-${index}`, parentId: index ? `large-${index - 1}` : null, timestamp,
message: { role: "assistant" as const, content: [{ type: "text" as const, text: `TURN_${index} ${String(index).repeat(900)}` }], stopReason: "stop" as const, timestamp: Date.now() },
}));
const runtime = { connected: true, processes: [{ pid: 7, ppid: 1, command: "node", args: "node /opt/pi-coding-agent/dist/cli.js" }] };
entries.push({ type: "message", id: "provider-failure", parentId: "large-47", timestamp, message: { role: "assistant", content: [], stopReason: "error", errorMessage: "429: provider quota exhausted", timestamp: Date.now() } } as any);
const runtime = { connected: false, processes: [{ pid: 7, ppid: 1, command: "node", args: "node /opt/pi-coding-agent/dist/cli.js" }] };
let view = buildWorkerView(entries as any, "/tmp/worker.jsonl", "large history", runtime);
expect(view.text).toContain("newer saved turns remain");
expect(view.text).toContain("429: provider quota exhausted");
expect(view.text).toContain("Status: disconnected");
expect(view.text).toContain("1 probable child Pi process");
expect(view.cursor.through).not.toBe("large-47");
const first = view.cursor.through;
for (let page = 0; page < 64 && view.cursor.through !== "large-47"; page++) view = buildWorkerView(entries as any, "/tmp/worker.jsonl", "large history", runtime, view.cursor);
expect(view.cursor.through).toBe("large-47");
for (let page = 0; page < 64 && view.cursor.through !== "provider-failure"; page++) view = buildWorkerView(entries as any, "/tmp/worker.jsonl", "large history", runtime, view.cursor);
expect(view.cursor.through).toBe("provider-failure");
view = buildWorkerView(entries as any, "/tmp/worker.jsonl", "large history", runtime, view.cursor);
expect(view.text).toContain("429: provider quota exhausted");
expect(Buffer.byteLength(view.text)).toBeLessThanOrEqual(8_000);
expect(view.cursor.through).not.toBe(first);
});
@@ -1326,6 +1334,31 @@ it("preserves the current worker binding when stock reports an existing pane", a
expect(f.ctx.sessionManager.getBranch().some((entry: any) => entry.customType === "pi-goals-worker-release")).toBe(false);
});
it("retains worker history and report routing after definite pre-open failure, not ambiguous partial open", async () => {
const f = fixture(); await f.draft(); await f.command("ready");
const session = SessionManager.create(f.ctx.cwd, join(f.ctx.cwd, "sessions"));
session.appendMessage({ role: "assistant", content: [{ type: "text", text: "Existing approved work" }], stopReason: "stop", timestamp: Date.now() } as any);
await f.launch({ id: "old-worker", sessionFile: session.getSessionFile()! });
const previous = structuredClone(f.entries.at(-1).data.worker);
const tool = f.tools.get("OpenGoalWorker");
// Exercise the actual stock preflight, which returns before calling Herdr for this missing cwd.
const stock = await vi.importActual<typeof import("pi-subagents/project-panes")>("pi-subagents/project-panes");
vi.mocked(openProjectPane).mockImplementationOnce(options => stock.openProjectPane({ ...options, cwd: join(f.ctx.cwd, "missing-directory") }));
const reply = await tool.execute("failed", { task: "Proposed replacement" }, undefined, undefined, f.ctx);
expect(reply.content[0].text).toContain("INVALID_PROJECT_ROOT");
expect(f.entries.at(-1).data.worker).toEqual(previous);
const view = await f.tools.get("worker_view").execute("view", {}, undefined, undefined, f.ctx);
expect(view.content[0].text).toContain("Existing approved work");
f.event({ type: "message", fromSessionId: "old-worker", payload: { type: "stopped", to: previous.parentId, requestId: previous.requestId, plan: f.path, entryId: "still-routed", kind: "blocker", text: "Original worker reports a failure" } });
expect(f.ctx.sessionManager.getBranch().some((entry: any) => entry.customType === "pi-goals-worker-event" && entry.data.id === "old-worker:still-routed")).toBe(true);
vi.mocked(openProjectPane).mockResolvedValueOnce({ ok: false, error: { code: "BINDING_WRITE_FAILED", message: "Pane started; cleanup uncertain" } });
const uncertain = await tool.execute("partial", { task: "Proposed replacement" }, undefined, undefined, f.ctx);
expect(uncertain.content[0].text).toContain("uncertain");
expect(f.entries.at(-1).data.worker.requestId).not.toBe(previous.requestId);
expect(f.entries.some(entry => entry.data.worker?.requestId === previous.requestId)).toBe(true);
expect(f.ctx.sessionManager.getBranch().some((entry: any) => entry.customType === "pi-goals-worker-release")).toBe(false);
});
it("automatically reports worker turn end and pauses a rejected attachment", async () => {
const f = fixture(); const path = join(f.ctx.cwd, "supplied.md"); writeFileSync(path, f.plan);
await f.tools.get("AttachGoalPlan").execute("attach", { path, parent: "live-parent", requestId: "owned-request" }, undefined, undefined, f.ctx);
+1
View File
@@ -302,6 +302,7 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
const failedViewAt = parent.messages.length;
await run(parent, "parent", call("worker_view", {}));
expect(viewText(failedViewAt)).toContain("greeting.txt");
expect(viewText(failedViewAt)).toContain("Fixture execution failed after progress");
expect(viewText(failedViewAt)).not.toContain("Recent calls and results");
// The supervisor steers the recoverable failure directly. No review form is created.