From 3eca22bd836dadbdd75c709b66868a9399a0e7aa Mon Sep 17 00:00:00 2001 From: wassname2 Date: Wed, 16 Sep 2026 12:17:03 +0800 Subject: [PATCH] Fix worker report recovery and consolidate workflow tests --- AGENTS.md | 2 +- src/index.ts | 58 +++-- src/prompts.ts | 4 +- test/fixtures/offline-model.ts | 18 ++ test/goals.test.ts | 176 ++-------------- test/prompts.test.ts | 93 -------- test/rpc-review.test.ts | 373 +++++++++++++++++---------------- 7 files changed, 269 insertions(+), 455 deletions(-) delete mode 100644 test/prompts.test.ts diff --git a/AGENTS.md b/AGENTS.md index 94826c2..e5accc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ The supervisor should: Run `npm test`, `npm run typecheck` and `npm run lint` before committing. -`test/goals.test.ts` exercises current state, file updates and role restrictions with a Pi API mock. `test/rpc-review.test.ts` starts real Pi with a deterministic local model and schema-only worker tools: it checks automatic proposal, editor/discussion and Ready role transition without credits or launching workers. It does not prove Herdr rendering, live message delivery or model judgment. +`test/rpc-review.test.ts` runs a deterministic parent/worker story using real Pi, saved sessions and stock Intercom: planning/Ready, failure after progress, offline recovery, sourced review, delivery retry, same-worker correction, reload, intentional interruption and busy Clear. The RPC fixture seeds the launch binding rather than calling OpenGoalWorker. It does not prove native pane allocation/rendering or model judgment. `test/goals.test.ts` retains focused file-mutation, ownership and lifecycle checks that are cheaper to exercise at the Pi API boundary. Run targeted tests through `npm test -- ` so ignored investigations stay outside discovery. For functional acceptance, read `herdr --skill`, confirm `HERDR_ENV=1`, and use `scripts/prepare-trial.mjs` to create an isolated project/profile. Open only new no-focus test panes. Observe the actual planning dialogue and Ready selection, worker attachment, Intercom report, independent artifact inspection and CompleteGoal. Record interventions separately from autonomous success. Preserve nonempty byte/test evidence. Never reload or operate active user research panes. Close test panes when finished. diff --git a/src/index.ts b/src/index.ts index 9c9bc33..9ba61bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,7 +63,7 @@ const RUN = "pi-goals-worker-run", STOP = "pi-goals-worker-stop", WORKER_EVENT = type GoalEventKind = "review_request" | "decision" | "blocker" | "completion" | "progress" | "running" | "waiting" | "receipt" | "no_change" | "aborted" | "unclassified"; const REVIEWABLE_EVENTS = new Set(["review_request", "decision", "blocker", "completion"]); interface WorkerStop { type: "stopped"; entryId: string; to: string; requestId: string; plan: string; text: string; identity: Peer; kind?: GoalEventKind; } -interface Report { id: string; plan: string; session: string; sessionFile: string; requestId: string; task?: string; text: string; kind: GoalEventKind; } +interface Report { id: string; plan: string; session: string; sessionFile: string; requestId: string; task?: string; text: string; kind: GoalEventKind; supersedes?: string; } type WorkerEvent = Report; interface ReportReview { id: string; reportId?: string; report?: string; verdict: string; content: string; continuation: string; } const reviewedReportId = (review: ReportReview) => review.reportId ?? review.report; @@ -366,15 +366,18 @@ export default function mainSupervisor(pi: ExtensionAPI) { paneId: process.env.HERDR_PANE_ID ?? "", model: ctx.model ? ctx.model.provider + "/" + ctx.model.id : undefined }; } const records = (ctx: ExtensionContext, type: string): T[] => ctx.sessionManager.getBranch().flatMap(entry => entry.type === "custom" && entry.customType === type ? [entry.data as T] : []); - const pendingReports = (ctx: ExtensionContext) => records(ctx, REPORT).filter(report => - !records(ctx, REVIEW).some(review => reviewedReportId(review) === report.id)); + const pendingReports = (ctx: ExtensionContext) => { + const reports = records(ctx, REPORT), reviews = records(ctx, REVIEW); + return reports.filter(report => !reviews.some(review => reviewedReportId(review) === report.id) + && !reports.some(newer => newer.session === report.session && newer.supersedes === report.id)); + }; const reportLabel = (report: Report) => { - const revision = report.id.slice(report.id.lastIndexOf(":") + 1); + const revision = report.id.split(":").at(-1)!.slice(0, 8); const plainTask = report.task?.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[*_`~<>]/g, "").trim().replace(/\s+/g, " ").slice(0, 100); return `revision ${revision}${plainTask ? ` — ${plainTask}` : ""} (reportId ${report.id})`; }; function recordReport(ctx: ExtensionContext, report: Report, wake = true) { - if (records(ctx, REPORT).some(saved => saved.id === report.id) || records(ctx, WORKER_EVENT).some(saved => saved.id === report.id)) return; + if (records(ctx, REPORT).some(saved => saved.id === report.id)) return; pi.appendEntry(REPORT, report); send(workerReview(report.plan, report.session, `${report.id}\n${report.text}`), false, true); if (wake && ctx.isIdle()) remindReports(ctx); @@ -398,7 +401,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { pi.registerEntryRenderer(REVIEW, (entry, { expanded }, theme) => { const review = entry.data as ReportReview; if (expanded) return new Markdown(review.content, 0, 0, getMarkdownTheme()); - const revision = reviewedReportId(review)?.split(":").at(-1) ?? "unknown"; + const revision = reviewedReportId(review)?.split(":").at(-1)?.slice(0, 8) ?? "unknown"; return { render: (width) => [truncateToWidth(theme.fg("muted", `[pi-goals] Worker review: ${review.verdict} · revision ${revision} · ${keyHint("app.tools.expand", "expand")}`), width)], invalidate() {}, @@ -417,7 +420,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { const run = branch.filter(entry => entry.type === "custom" && entry.customType === RUN).at(-1); const stop = branch.filter(entry => entry.type === "custom" && entry.customType === STOP).at(-1); const stopped = stop?.type === "custom" ? stop.data as WorkerStop : undefined; - if (run && run.id !== stopped?.entryId) entryId = `${run.id}:disconnected`; + if (run && (!stopped || stopped.entryId !== run.id && !stopped.entryId.startsWith(`${run.id}:`))) { entryId = `${run.id}:disconnected`; kind = "blocker"; } else if (stopped) { entryId = stopped.entryId; text = stopped.text; kind = stopped.kind ?? "unclassified"; } else entryId = branch.filter(entry => entry.type === "message" && entry.message.role === "assistant").at(-1)?.id || entryId; } catch { /* Unknown history remains visible without inventing completion or review debt. */ } @@ -460,7 +463,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { } if (data.type === "stopped" && event.fromSessionId === worker.intercomId && typeof data.entryId === "string" && data.entryId && typeof data.text === "string") { const id = `${event.fromSessionId}:${data.entryId}`; - if (records(ctx, REPORT).some(report => report.id === id) || records(ctx, WORKER_EVENT).some(saved => saved.id === id)) return; + if (records(ctx, REPORT).some(report => report.id === id) || !REVIEWABLE_EVENTS.has(data.kind ?? "unclassified") && records(ctx, WORKER_EVENT).some(saved => saved.id === id)) return; if (data.identity) { worker.identity = data.identity; worker.sessionFile = data.identity.sessionFile; save(); } const report: Report = { id, plan: state.plan, session: event.fromSessionId, sessionFile: worker.sessionFile!, requestId: data.requestId, task: worker.task, text: data.text, kind: data.kind ?? "unclassified" }; recordWorkerEvent(ctx, report); @@ -476,7 +479,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { const worker = owner.worker!; try { for (const entry of savedSession(worker.sessionFile!).getBranch()) { - if (entry.type !== "custom" || entry.customType !== STOP) continue; + if (entry.type !== "custom" || ![STOP, WORKER_EVENT].includes(entry.customType)) continue; const stopped = entry.data as WorkerStop; if (stopped.to !== worker.parentId || stopped.requestId !== worker.requestId || stopped.plan !== owner.plan) continue; recordWorkerEvent(ctx, { id: `${worker.intercomId}:${stopped.entryId}`, plan: stopped.plan, session: worker.intercomId!, sessionFile: worker.sessionFile!, requestId: stopped.requestId, task: worker.task, text: stopped.text, kind: stopped.kind ?? "unclassified" }, false); @@ -485,16 +488,26 @@ export default function mainSupervisor(pi: ExtensionAPI) { } if (ctx.isIdle()) remindReports(ctx); } - const reportStop = (text: string, kind: GoalEventKind) => { + const reportStop = (text: string, kind: GoalEventKind, automatic = false) => { if (!state.child || !state.parent || !state.plan || !liveContext) return; const branch = liveContext.sessionManager.getBranch(); - const entryId = branch.filter(entry => entry.type === "custom" && entry.customType === RUN).at(-1)?.id + const runId = branch.filter(entry => entry.type === "custom" && entry.customType === RUN).at(-1)?.id || branch.filter(entry => entry.type === "message" && entry.message.role === "assistant").at(-1)?.id; - if (!entryId) return; - let stopped = records(liveContext, STOP).find(saved => saved.entryId === entryId); - if (stopped) return stopped; - stopped = { type: "stopped", to: state.parent.intercomId, requestId: state.parent.requestId, plan: state.plan, text: Buffer.from(text).subarray(0, 6000).toString("utf8"), entryId, identity: identity(liveContext), kind }; - pi.appendEntry(STOP, stopped); + if (!runId) return; + const inRun = (saved: WorkerStop) => saved.requestId === state.parent?.requestId && saved.plan === state.plan && (saved.entryId === runId || saved.entryId?.startsWith(`${runId}:`)); + if (automatic && kind === "unclassified") { + const ended = records(liveContext, STOP).filter(inRun).at(-1); + if (ended) return ended; + const status = records(liveContext, WORKER_EVENT).filter(inRun).at(-1); + if (status) { pi.appendEntry(STOP, status); return status; } // Finish this run without another status or wake. + } + const type = automatic || REVIEWABLE_EVENTS.has(kind) ? STOP : WORKER_EVENT; + const entryId = `${runId}:${digest(`${state.parent.requestId}:${state.plan}:${kind}:${text}`)}`; + let stopped = records(liveContext, type).find(saved => saved.entryId === entryId); + if (!stopped) { + stopped = { type: "stopped", to: state.parent.intercomId, requestId: state.parent.requestId, plan: state.plan, text: Buffer.from(text).subarray(0, 6000).toString("utf8"), entryId, identity: identity(liveContext), kind }; + pi.appendEntry(type, stopped); + } try { if (!channel?.snapshot().connected) throw new Error("disconnected"); channel.publish(stopped, { audience: "capable" }); @@ -505,7 +518,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { restore(ctx); registerChannel(ctx); reconcileReports(ctx); }); pi.on("session_tree", (_e, ctx) => restore(ctx)); - pi.on("session_shutdown", () => { reportStop(nativeMessages.shuttingDown, "unclassified"); cancelCheckInRemoval(); agentRunActive = false; pauseCheckIn = false; channel = undefined; liveContext = undefined; generation++; finalReviewTurnDigest = undefined; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; }); + pi.on("session_shutdown", () => { reportStop(nativeMessages.shuttingDown, "unclassified", true); cancelCheckInRemoval(); agentRunActive = false; pauseCheckIn = false; channel = undefined; liveContext = undefined; generation++; finalReviewTurnDigest = undefined; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; }); // Only successful compaction needs resync; failed/cancelled attempts leave pending context alone. // Defer to prompt preparation: same-run continuation retains Pi's current role/context. pi.on("session_compact", () => { notice = true; fullPlanContextDue = true; }); @@ -538,7 +551,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { pi.on("agent_end", (event, ctx) => { const last = event.messages.filter(message => message.role === "assistant").at(-1); const text = last?.role === "assistant" ? last.errorMessage || last.content.filter(part => part.type === "text").map(part => part.text).join("\n") || last.stopReason : nativeMessages.noAssistant; - reportStop(text, last?.role === "assistant" && last.stopReason === "aborted" ? "aborted" : "unclassified"); + reportStop(text, last?.role === "assistant" && last.stopReason === "aborted" ? "aborted" : last?.role === "assistant" && (last.stopReason === "error" || last.errorMessage) ? "blocker" : "unclassified", true); finalReviewTurnDigest = undefined; refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); }); let proposedDraft = ""; let proposing = false; @@ -829,7 +842,11 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (stamp !== generation || signal?.aborted) return result(messages.cancelled); if (!peers.some(peer => peer.id === params.parent && peer.pid !== process.pid)) return result(nativeMessages.parentUnavailable); try { if (readFileSync(params.path, "utf8") !== text) return result(messages.invalidAttachment); } catch { return result(messages.invalidAttachment); } - if (!state.child) state = { ...initial(), child: true, mode: "solo" }; + if (!state.child) { + state = { ...initial(), child: true, mode: "solo" }; + // First attachment can occur after agent_start in an ordinary chat. + if (agentRunActive) pi.appendEntry(RUN, { plan: params.path, parent: { intercomId: params.parent, requestId: params.requestId }, session: identity(ctx) }); + } state.parent = { intercomId: params.parent!, requestId: params.requestId! }; } state.plan = params.path; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); @@ -849,7 +866,6 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (!params.summary.trim()) throw new Error("Supply the canonical event summary and exact artifact paths when applicable."); const stopped = reportStop(params.summary.trim(), kind); if (!stopped) throw new Error("No active worker run is available for this event."); - if (stopped.kind !== kind || stopped.text !== Buffer.from(params.summary.trim()).subarray(0, 6000).toString("utf8")) throw new Error(`This worker run already reported ${stopped.kind ?? "an unclassified stop"}; send later guidance through Intercom.`); return result(REVIEWABLE_EVENTS.has(kind) ? "Recorded one canonical event for parent review." : "Recorded one visible status event without formal review."); }, }); @@ -892,7 +908,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { return `${path}${source.entryId ? `#${source.entryId}` : ""}\n${source.quote ? `> ${source.quote}` : "[non-text capture]"}${"observation" in source ? `\nObserved: ${source.observation}` : ""}`; }); const content = reportReviewContent(report.id, report.sessionFile, sources, params.observation, params.unmet, params.verdict, params.continuation || ""); - const review: ReportReview = { id: digest(content), reportId: report.id, verdict: params.verdict, content, continuation: params.continuation || "" }; + const review: ReportReview = { id: digest(content), reportId: report.id, report: report.id, verdict: params.verdict, content, continuation: params.continuation || "" }; if (records(ctx, REVIEW).some(saved => reviewedReportId(saved) === report.id)) return result("This worker revision already has a delivered review; a later stop report is a new revision."); if (!channel?.snapshot().connected || !channel.snapshot().supported || signal?.aborted) throw new Error("Review delivery unavailable; report remains pending."); const payload = { type: "review", to: report.session, sessionFile: report.sessionFile, requestId: report.requestId, plan: report.plan, review }; diff --git a/src/prompts.ts b/src/prompts.ts index e1401c2..b178e9d 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -153,8 +153,8 @@ export const discuss = "Type your changes in chat; the draft stays open."; // Ready and explicit native peer attachment. No worker environment or agent-file contract. export const attachGoalPlanDescription = "Attach the absolute plan path explicitly supplied by the parent. On first attachment or explicit same-parent plan/request change, supply the exact existing Intercom parent UUID and newly assigned requestId. Changes require live parent verification; a different parent cannot take over. Omit these fields only to restore unchanged plan context. Preserve session history and prior reviews. Read the plan without rewriting it. Restores plan context; grants no parent completion authority. No discovery or worker launch."; -export const reportGoalEventDescription = "Report exactly one canonical event for the current delegated worker run. review_request, decision, blocker and completion create a parent review obligation. progress, running, waiting, receipt and no_change stay visible without formal review. Use review_request only for a bounded artifact that needs judgment; completion only when the assigned task is complete; blocker only when autonomous progress cannot continue. Routine intermediate work and queued jobs are progress or waiting."; -export const childPlanRole = "You are the delegated implementation worker. Save evidence and report progress for your delegated work; leave plan maintenance to the parent. Preserve agreed goals, requirements and discriminators; the supervisor owns goal-status changes and completion approval. Do not launch a second writer. Call AttachGoalPlan with the explicit plan path in your task before implementation (also after reconnect if unbound). Immediately report your actual Intercom UUID, saved-session path and current provider/model to the supplied supervisor ID. Identify unavailable fields as unknown; do not equate runtime IDs, session filenames and Intercom IDs. Call ReportGoalEvent exactly once when a run has a canonical result or status. review_request, decision, blocker and completion require parent judgment. progress, running, waiting, receipt and no_change do not; use progress when work changed but the correct instruction is simply to continue. Put the canonical summary and exact artifact paths in the event. Then stay open for live messages. Do not exit or use caller_ping; unsent editor drafts are not visible in model context."; +export const reportGoalEventDescription = "Report a meaningful event for the current delegated worker run. Later results or failures may follow progress; unchanged repetitions are deduplicated. review_request, decision, blocker and completion create a parent review obligation. progress, running, waiting, receipt and no_change stay visible without formal review. Use review_request only for a bounded artifact that needs judgment; completion only when the assigned task is complete; blocker only when autonomous progress cannot continue. Routine intermediate work and queued jobs are progress or waiting."; +export const childPlanRole = "You are the delegated implementation worker. Save evidence and report progress for your delegated work; leave plan maintenance to the parent. Preserve agreed goals, requirements and discriminators; the supervisor owns goal-status changes and completion approval. Do not launch a second writer. Call AttachGoalPlan with the explicit plan path in your task before implementation (also after reconnect if unbound). Immediately report your actual Intercom UUID, saved-session path and current provider/model to the supplied supervisor ID. Identify unavailable fields as unknown; do not equate runtime IDs, session filenames and Intercom IDs. Call ReportGoalEvent when there is a meaningful result or status change, including a later blocker or completion after progress. Do not repeat unchanged events. review_request, decision, blocker and completion require parent judgment. progress, running, waiting, receipt and no_change do not; use progress when work changed but the correct instruction is simply to continue. Put the canonical summary and exact artifact paths in the event. Then stay open for live messages. Do not exit or use caller_ping; unsent editor drafts are not visible in model context."; export function readyApproved(workerName: string, planPath: string, notedWorker: string | undefined, plan: string, supervisorId: string): string { const launch = notedWorker ? `Inspect recorded history ${notedWorker} and actual writer state. If live, steer that exact Intercom session; do not replace its conversation. If stopped, preserve history and drafts and use stock project.status/project.close/project.open only after verified safe stop.` diff --git a/test/fixtures/offline-model.ts b/test/fixtures/offline-model.ts index 2abac66..ca926cb 100644 --- a/test/fixtures/offline-model.ts +++ b/test/fixtures/offline-model.ts @@ -2,6 +2,24 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { workerAttachment } from "../../src/prompts.js"; export default function offlineModel(pi: ExtensionAPI): void { + pi.registerCommand("fixture-reload", { handler: async (_args, ctx) => { await ctx.reload(); } }); + // Seed OpenGoalWorker's launch binding without Herdr; attachment/delivery use real Intercom/Pi. + pi.registerCommand("fixture-worker-binding", { handler: async (args, ctx) => { + const current = ctx.sessionManager.getBranch().findLast(entry => entry.type === "custom" && entry.customType === "pi-goals-main-supervisor-v1"); + if (current?.type !== "custom" || (current.data as { mode?: string }).mode !== "supervising") throw new Error("Ready must precede worker allocation"); + pi.appendEntry("pi-goals-main-supervisor-v1", { ...current.data as object, worker: JSON.parse(args) }); + await ctx.reload(); + } }); + pi.registerCommand("fixture-legacy-supersession", { handler: async (_args, ctx) => { + const entry = ctx.sessionManager.getBranch().findLast(entry => entry.type === "custom" && entry.customType === "pi-goals-main-supervisor-v1"); + if (entry?.type !== "custom") throw new Error("Missing supervisor state"); + const { plan, worker } = entry.data as any; + const report = { plan, session: worker.intercomId, sessionFile: worker.sessionFile, requestId: worker.requestId, text: "Historical artifact" }; + pi.appendEntry("pi-goals-report", { ...report, id: "legacy:A" }); + pi.appendEntry("pi-goals-report", { ...report, id: "legacy:B", supersedes: "legacy:A" }); + pi.appendEntry("pi-goals-report-review", { id: "legacy-review", report: "legacy:B", verdict: "accepted", content: "Historical review", continuation: "" }); + await ctx.reload(); + } }); pi.registerCommand("fixture-attachment-notice", { handler: (_args, ctx) => pi.sendMessage({ customType: "pi-goals-supervision", content: workerAttachment(ctx.cwd, "fixture-peer", "Attachment recorded."), display: true }, { triggerTurn: false }), }); diff --git a/test/goals.test.ts b/test/goals.test.ts index b00c37b..c7cc966 100644 --- a/test/goals.test.ts +++ b/test/goals.test.ts @@ -1,15 +1,14 @@ -import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { createEditTool, type ExtensionAPI, initTheme, SessionManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; -import { Markdown, visibleWidth } from "@earendil-works/pi-tui"; +import { createEditTool, type ExtensionAPI, SessionManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +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, workerAssignment } from "../src/prompts.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" } } })) })); @@ -958,11 +957,6 @@ it("does not approve cancelled goals or display current completion for an unavai expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", [expect.stringContaining("unavailable")]); }); -it("keeps interactive workers open", () => { - const task = workerAssignment("/plan.md", "parent", "request", "bounded task"); - expect(task).toContain("do not exit, reset, switch session or close the pane"); -}); - it.each(["stop", "exit", "edit", "session_tree"])("discards pending upkeep after %s instead of reviving stale work", async change => { const f = fixture(); await f.draft(); f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); @@ -1153,6 +1147,14 @@ it("ordinary project peer explicitly attaches as worker, never gaining approval await f.command("ready"); await f.command("solo"); 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); + const before = f.entries.length; + await tool.execute("missing", { path: next }, undefined, undefined, f.ctx); + f.channel.listSessions.mockResolvedValue([{ id: "live-parent", pid: process.pid + 1 }, { id: "foreign-parent", pid: process.pid + 2 }]); + await tool.execute("foreign", { path: next, parent: "foreign-parent", requestId: "next" }, undefined, undefined, f.ctx); + expect(f.entries).toHaveLength(before); // neither a missing request nor a live stranger can take over + await tool.execute("next", { path: next, parent: "live-parent", requestId: "next" }, undefined, undefined, f.ctx); + expect(f.entries.at(-1).data).toMatchObject({ plan: next, parent: { intercomId: "live-parent", requestId: "next" } }); f.hooks.get("session_start")({}, f.ctx); f.hooks.get("session_compact")(); expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).systemPrompt).toContain("delegated implementation worker"); const assistant = { role: "assistant", content: [{ type: "text", text: "Result at output.txt" }], stopReason: "stop" }; @@ -1203,10 +1205,11 @@ it.each(["inherit", "plan", "explicit"])("hands off %s model policy without clai expect(startup).toContain(requested ? `User-supplied model preference: ${JSON.stringify(requested)}` : "Inherit the native model"); if (requested) expect(startup).toContain("Preserve later human model changes"); expect(vi.mocked(openProjectPane).mock.calls[0][0]).not.toHaveProperty("model"); - const identity = { paneId: "native-pane", sessionId: "worker", sessionFile: "/tmp/worker.jsonl", model: "offline/inherited" }; + const identity = { paneId: "observed-pane", sessionId: "worker", sessionFile: "/tmp/worker.jsonl", model: "offline/inherited" }; f.event({ type: "message", fromSessionId: "worker", payload: { type: "attached", to: worker.parentId, requestId: worker.requestId, plan: f.path, sessionFile: identity.sessionFile, identity } }); await f.command("status"); expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Last observed worker model: offline/inherited"), "info"); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("native pane: observed-pane"), "info"); if (model) { f.event({ type: "message", fromSessionId: "worker", payload: { type: "stopped", to: worker.parentId, requestId: worker.requestId, plan: f.path, entryId: "model-unavailable", kind: "progress", text: "Requested missing/unavailable is unavailable; unrelated work can continue." } }); expect(f.messages.at(-1)?.message.content).toContain("## Worker status: progress"); @@ -1215,156 +1218,3 @@ it.each(["inherit", "plan", "explicit"])("hands off %s model policy without clai } }); - -it("reviews a saved worker revision through inspection, silent delivery, retry and restored pending reminders", async () => { - const parent = fixture(), worker = fixture(); await parent.draft(); await parent.command("ready"); - const sm = SessionManager.create(worker.ctx.cwd, join(worker.ctx.cwd, "sessions")); - worker.ctx.sessionManager = sm as any; - worker.pi.appendEntry = (type, data) => sm.appendCustomEntry(type, data); - const workerId = "worker-intercom"; // Broker identity is distinct from the native saved-session UUID. - parent.channel.publish.mockImplementation(payload => worker.event({ type: "message", fromSessionId: "parent-intercom", payload })); - worker.channel.publish.mockImplementation(payload => parent.event({ type: "message", fromSessionId: workerId, payload })); - worker.channel.listSessions.mockResolvedValue([{ id: "parent-intercom", pid: process.pid + 1 }]); - await parent.tools.get("OpenGoalWorker").execute("open", { task: "Implement first output" }, undefined, undefined, parent.ctx); - const requestId = parent.entries.at(-1).data.worker.requestId; - await worker.tools.get("AttachGoalPlan").execute("attach", { path: parent.path, parent: "parent-intercom", requestId }, undefined, undefined, worker.ctx); - const report = async (text: string, kind = "review_request", stopReason = "stop") => { - worker.hooks.get("agent_start")({}, worker.ctx); - await worker.tools.get("ReportGoalEvent").execute("event", { kind, summary: text }, undefined, undefined, worker.ctx); - const message = { role: "assistant", content: [{ type: "text", text }], stopReason, timestamp: Date.now(), api: "openai-completions", provider: "offline", model: "test", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; - sm.appendMessage(message as any); - worker.hooks.get("agent_end")({ messages: [message] }, worker.ctx); - return `${workerId}:${worker.channel.publish.mock.lastCall?.[0].entryId}`; - }; - const noOp = { role: "assistant", content: [], stopReason: "aborted", errorMessage: "Operation aborted", timestamp: Date.now(), api: "openai-completions", provider: "offline", model: "test", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; - worker.hooks.get("agent_start")({}, worker.ctx); sm.appendMessage(noOp as any); worker.hooks.get("agent_end")({ messages: [noOp] }, worker.ctx); - expect(worker.channel.publish.mock.lastCall?.[0]).toMatchObject({ kind: "aborted", text: "Operation aborted" }); - await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain("Pending worker revision reviews: none"); - parent.ctx.isIdle.mockReturnValue(false); // Another authorized task is still running. - const id = await report("Output is ready."); - const before = parent.messages.length; - await parent.hooks.get("agent_settled")({}, parent.ctx); - expect(parent.messages).toHaveLength(before + 1); - await parent.hooks.get("agent_settled")({}, parent.ctx); - expect(parent.messages).toHaveLength(before + 1); // No self-triggered loop. - parent.hooks.get("session_start")({}, parent.ctx); - expect(parent.hooks.get("before_agent_start")({ systemPrompt: "base" }, parent.ctx).systemPrompt).toContain(id); - const artifact = join(parent.ctx.cwd, "output.txt"); writeFileSync(artifact, "first output\n"); - execFileSync("git", ["init"], { cwd: parent.ctx.cwd }); - execFileSync("git", ["add", "output.txt"], { cwd: parent.ctx.cwd }); - execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "worker evidence"], { cwd: parent.ctx.cwd }); - const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: parent.ctx.cwd, encoding: "utf8" }).trim(); - writeFileSync(artifact, "changed after reported revision\n"); - const form = { reportId: id, goal: { path: parent.path, quote: "goal: first output" }, evidence: [{ path: `git:${commit}:output.txt`, quote: "invented", observation: "Read the reported revision" }], observation: "Inspected actual output and assigned goal", unmet: "none", verdict: "accepted" }; - const review = () => parent.tools.get("review_subagent").execute("review", form, undefined, undefined, parent.ctx); - await expect(review()).rejects.toThrow("Quote does not match"); - form.evidence[0].quote = "first output"; - const workerTurns = worker.messages.length; - parent.channel.publish.mockImplementationOnce(() => {}); // Publish success is not saved delivery. - await review(); await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain(id); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain("Implement first output"); - await review(); await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain("Pending worker revision reviews: none"); - expect(worker.messages).toHaveLength(workerTurns); - expect(readFileSync(sm.getSessionFile()!, "utf8")).toContain("Worker review: accepted"); - initTheme("dark"); - const reviewEntry = sm.getBranch().find(entry => entry.type === "custom" && entry.customType === "pi-goals-report-review")!; - const reviewRenderer = vi.mocked(worker.pi.registerEntryRenderer).mock.calls.find(([type]) => type === "pi-goals-report-review")![1]; - const collapsedReview = reviewRenderer(reviewEntry, { expanded: false }, worker.ctx.ui.theme); - expect(collapsedReview.render(100).join("\n")).toContain("Worker review: accepted"); - expect(collapsedReview.render(100).join("\n")).not.toContain("Inspected actual output"); - expect(reviewRenderer(reviewEntry, { expanded: true }, worker.ctx.ui.theme)).toBeInstanceOf(Markdown); - await review(); worker.hooks.get("session_shutdown")(); - parent.event({ type: "session_left", sessionId: workerId }); - await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain("Pending worker revision reviews: none"); - worker.hooks.get("session_start")({}, worker.ctx); - const staleStop = worker.channel.publish.mock.lastCall?.[0]; - vi.stubEnv("HERDR_PANE_ID", "restored-pane"); - await worker.tools.get("AttachGoalPlan").execute("restore", { path: parent.path }, undefined, undefined, worker.ctx); - parent.event({ type: "message", fromSessionId: workerId, payload: staleStop }); - await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain("native pane: restored-pane"); - await parent.command("stop"); - expect(parent.messages.at(-1)?.message.content).toContain("native pane restored-pane"); - form.reportId = await report("Revision failed", "blocker", "error"); - const paused = parent.messages.length; - await parent.hooks.get("agent_settled")({}, parent.ctx); - expect(parent.messages).toHaveLength(paused); - await parent.command("resume"); - form.verdict = "changes_requested"; - await expect(review()).rejects.toThrow("concrete continuation"); - await parent.tools.get("review_subagent").execute("revision", { ...form, unmet: "Output still needs correction", continuation: "Correct output.txt and rerun verification." }, undefined, undefined, parent.ctx); - expect(worker.messages.at(-1)).toMatchObject({ savedPrompt: true, message: { content: expect.stringContaining("Correct output.txt") } }); - form.reportId = await report("Cancelled while correcting", "blocker", "aborted"); form.verdict = "blocked"; - await review(); await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain("Pending worker revision reviews: none"); - expect(readFileSync(parent.path, "utf8")).not.toContain("[✓]"); - - // Lost notification and cancellation before any new assistant message: durable run identity. - worker.hooks.get("agent_start")({}, worker.ctx); - worker.channel.publish.mockImplementationOnce(() => {}); - await worker.tools.get("ReportGoalEvent").execute("missed", { kind: "blocker", summary: "Blocked before an assistant summary." }, undefined, undefined, worker.ctx); - worker.hooks.get("agent_end")({ messages: [] }, worker.ctx); - const missedStop = sm.getBranch().filter((entry: any) => entry.customType === "pi-goals-worker-stop").at(-1) as any; - const missed = `${workerId}:${missedStop.data.entryId}`; - expect(missed).not.toBe(form.reportId); - // Same preserved worker, newly approved plan and request: old reviews stay in history. - const history = sm.getBranch(), oldPlan = readFileSync(parent.path, "utf8"); - await parent.command("clear"); await parent.command("new Next output"); - const nextPlan = parent.entries.at(-1).data.plan; writeFileSync(nextPlan, parent.plan); - await parent.command("ready"); - await parent.tools.get("OpenGoalWorker").execute("next", { task: "Implement next output" }, undefined, undefined, parent.ctx); - const nextRequest = parent.entries.at(-1).data.worker.requestId; - const attach = (params: object) => worker.tools.get("AttachGoalPlan").execute("reattach", params, undefined, undefined, worker.ctx); - const unchanged = () => expect(sm.getBranch()).toEqual(history); - expect((await attach({ path: nextPlan })).content[0].text).toContain("explicit authorization"); unchanged(); - worker.channel.listSessions.mockResolvedValue([{ id: "parent-intercom", pid: process.pid + 1 }, { id: "foreign-parent", pid: process.pid + 2 }]); - expect((await attach({ path: nextPlan, parent: "foreign-parent", requestId: nextRequest })).content[0].text).toContain("Different-parent takeover"); unchanged(); - worker.channel.listSessions.mockRejectedValueOnce(new Error("offline")); - expect((await attach({ path: nextPlan, parent: "parent-intercom", requestId: nextRequest })).content[0].text).toContain("still connecting"); unchanged(); - await attach({ path: nextPlan, parent: "parent-intercom", requestId: nextRequest }); - expect(parent.entries.at(-1).data.worker).toMatchObject({ intercomId: workerId, requestId: nextRequest, sessionFile: sm.getSessionFile() }); - expect(sm.getBranch().slice(0, history.length)).toEqual(history); - await attach({ path: nextPlan }); // Context restoration does not require new authorization. - const nextReport = await report("New-plan output needs inspection"); - await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain(nextReport); - expect(worker.channel.publish.mock.lastCall?.[0]).toMatchObject({ type: "stopped", plan: nextPlan, requestId: nextRequest }); - parent.hooks.get("session_start")({}, parent.ctx); // Reconcile the missed stop from real saved worker history. - await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain(missed); - await parent.hooks.get("agent_settled")({}, parent.ctx); - form.reportId = missed; await review(); // Old-plan blocked review remains deliverable after retargeting. - await parent.command("status"); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).not.toContain(missed); - expect(parent.ctx.ui.notify.mock.lastCall?.[0]).toContain(nextReport); - const afterReview = parent.messages.length; - await parent.hooks.get("agent_settled")({}, parent.ctx); - parent.hooks.get("session_start")({}, parent.ctx); - await parent.hooks.get("agent_settled")({}, parent.ctx); - expect(parent.messages).toHaveLength(afterReview); // Shrinking/restoring the same backlog does not wake again. - const ordinary = (id: string, text: string, links = {}, sender = workerId) => { - const details = { from: { id: sender }, message: { id, timestamp: Date.now(), content: { text }, ...links } }; - parent.ctx.sessionManager.getBranch().push({ type: "custom_message", id, customType: "intercom_message", content: text, details }); - parent.hooks.get("message_end")({ message: { role: "custom", customType: "intercom_message", content: text, details } }, parent.ctx); - }; - const beforeOrdinary = parent.messages.length; - ordinary("ordinary-a", "Partial output needs review"); - ordinary("ordinary-b", "Corrected output needs review", { supersedes: "ordinary-a" }); - ordinary("retry-b", "Corrected output needs review", { retryOf: "ordinary-b" }); - ordinary("retry-again", "Corrected output needs review", { retryOf: "retry-b" }); - ordinary("ack-only", "OK"); ordinary("foreign", "Unowned report", {}, "foreign-peer"); - expect(parent.messages).toHaveLength(beforeOrdinary); // No second body beside Intercom's saved/displayed original. - parent.hooks.get("session_start")({}, parent.ctx); - await parent.hooks.get("agent_settled")({}, parent.ctx); - expect(parent.messages).toHaveLength(beforeOrdinary); // Ordinary Intercom messages remain visible but are not review obligations. - await parent.command("status"); - const pending = parent.ctx.ui.notify.mock.lastCall?.[0]; - for (const nonReviewable of ["ordinary-a", "ordinary-b", "retry-b", "retry-again", "ack-only", "foreign-peer"]) expect(pending).not.toContain(nonReviewable); - expect(pending).toContain(nextReport); - expect(readFileSync(parent.path, "utf8")).toBe(oldPlan); - expect((await worker.tools.get("CompleteGoal").execute("deny", { goal: "first output", evidence: [], observation: "claim" }, undefined, undefined, worker.ctx)).content[0].text).toContain("only to the active parent"); -}); diff --git a/test/prompts.test.ts b/test/prompts.test.ts deleted file mode 100644 index a53c0ad..0000000 --- a/test/prompts.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, it } from "vitest"; -import { manualReview, planChangedReview, planContext, planning, planningSeed, readyApproved, upkeep } from "../src/prompts.js"; - -const plan = `# Keep the user context - -Make the requested output easy to inspect. - -## User-visible result -A concrete artifact the user can read. - -## User voice -- > "Preserve this requirement word for word." - -## Goals -1. [/] goal: verify output - - tasks: - 1. [ ] run the full check - - evidence: proof.log - -## Log -old progress report`; - -it("keeps hindsight-judged user outcomes in initial and recurring planning instructions", () => { - const seed = planningSeed("Make search useful", "/plan.md"); - for (const prompt of [seed, planning("/plan.md")]) { - expect(prompt).toContain('"I know it when I see it"'); - expect(prompt).toContain("actual results in hindsight"); - expect(prompt).toContain("do not invent numerical gates to replace judgment"); - expect(prompt).toContain("technical deliverable nouns and verbs"); - expect(prompt).not.toContain("not an implementation task"); - expect(prompt).not.toContain("not a task label"); - } - expect(seed).toContain("goal: "); - expect(seed).toContain("Put observable examples under verification"); - expect(seed).toContain("what distinguishes it from merely looking done"); - expect(seed).toContain("not stricter assistant-invented requirements"); - expect(seed).toContain("by the user or justified by existing evidence"); - expect(seed).not.toContain("imperative outcome"); -}); - -it("keeps routine upkeep to its reason, goal lines and source path", () => { - const base = upkeep("/plan.md", plan); - expect(base).toMatch(/^\[pi-goals: reminder — upkeep\]\n/); - expect(base).not.toContain("Preserve this requirement word for word."); - expect(base).toContain("Eight unchanged turns"); - expect(base).toContain("/plan.md"); - expect(base).toContain("goal: verify output"); - expect(base).not.toContain("run the full check"); -}); - -it("separates routine goal lines from active context without historical Log", () => { - const short = planContext("supervising", "/plan.md", plan, "short"); - expect(short).toContain("unfinished or unreviewed goal lines"); - expect(short).toContain("/plan.md"); - expect(short).not.toContain("Preserve this requirement"); - expect(short).toContain("goal: verify output"); - - const medium = planContext("supervising", "/plan.md", plan, "medium"); - expect(medium).not.toContain("Preserve this requirement word for word."); - expect(medium).toContain("1. [/] goal: verify output"); - expect(medium).not.toContain("run the full check"); - expect(medium).not.toContain("proof.log"); - expect(medium).not.toContain("old progress report"); - - const full = planContext("supervising", "/plan.md", plan, "full"); - expect(full).toContain("run the full check"); - expect(full).toContain("proof.log"); - expect(full).toContain("Preserve this requirement word for word."); - expect(full).not.toContain("old progress report"); -}); - -it("puts selected goal lines in plan-change and manual-review messages", () => { - for (const text of [planChangedReview("/plan.md", plan), manualReview("/plan.md", plan)]) { - expect(text).toContain("goal: verify output"); - expect(text).toContain("/plan.md"); - expect(text).not.toContain("Preserve this requirement word for word."); - expect(text).not.toContain("run the full check"); - } -}); - -it("keeps approved work moving after evidence review without overriding pauses or scope approval", () => { - const text = planChangedReview("/plan.md", plan); - expect(text).toContain("Evidence-only edits do not revoke execution approval"); - expect(text).toContain("Continue only unfinished authorized work"); - expect(text).toContain("respect pauses and do not assume approval for changed scope"); -}); - -it("keeps the current working set in the ready message but omits history", () => { - const approved = readyApproved("goals-worker", "/plan.md", undefined, plan, "pi-session"); - expect(approved).toContain("Preserve this requirement word for word."); - expect(approved).toContain("goal: verify output"); - expect(approved).not.toContain("old progress report"); -}); diff --git a/test/rpc-review.test.ts b/test/rpc-review.test.ts index 2ac1ba3..6eff2d2 100644 --- a/test/rpc-review.test.ts +++ b/test/rpc-review.test.ts @@ -1,16 +1,15 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { type ChildProcessWithoutNullStreams, execFileSync, spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { StringDecoder } from "node:string_decoder"; -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; import { foldPlan } from "../src/plan.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 }> }> }; -const messageText = (content: ModelRequest["messages"][number]["content"]) => typeof content === "string" ? content : content.filter(part => part.type === "text").map(part => part.text).join("\n"); class RpcClient { readonly messages: RpcMessage[] = []; @@ -65,175 +64,199 @@ const isSelect = (message: RpcMessage) => message.type === "extension_ui_request const isEditor = (message: RpcMessage) => message.type === "extension_ui_request" && message.method === "editor"; const systemText = (request: ModelRequest) => request.messages.filter(message => ["system", "developer"].includes(message.role)).map(message => message.content).join("\n"); -describe("RPC review flow", () => { - it.each(["Edit", "Discuss"])("automatically proposes a draft, handles %s, then enters the supervisor role on Ready", async (choice) => { - const cwd = mkdtempSync(join(tmpdir(), "pi-goals-rpc-")); - const requests: ModelRequest[] = []; - const plan = "# Plan\n\n## Goals\n\n1. [ ] goal: name the output\n - subtle failure mode: the output has no name\n - discriminator: the plan names the output\n\n## Log\n"; - let planPath = "", createTaskName = ""; - let holdResponse: (() => Promise) | undefined; - const server = createServer(async (request, response) => { - let body = ""; - for await (const chunk of request) body += chunk; - const modelRequest = JSON.parse(body) as ModelRequest; - requests.push(modelRequest); - if (holdResponse) { const hold = holdResponse; holdResponse = undefined; await hold(); } - if (createTaskName) { - const name = createTaskName; createTaskName = ""; - streamResponse(response, { tool_calls: [{ index: 0, id: "busy-cleanup-task", type: "function", function: { name: "schedule_task", arguments: JSON.stringify({ name, action: "prompt", type: "interval", schedule: "1h", scope: "session", prompt: "Goal check-in." }) } }] }, "tool_calls"); - return; - } - if (requests.length === 1) { - const pathMatch = systemText(modelRequest).match(/Plan only in (.+?);/); - if (!pathMatch) throw new Error("Planning prompt did not name its plan file"); - planPath = pathMatch[1]; - streamResponse(response, { - tool_calls: [{ - index: 0, id: "write-plan", type: "function", - function: { name: "write", arguments: JSON.stringify({ path: planPath, content: plan }) }, - }], - }, "tool_calls"); - return; - } - streamResponse(response, { content: "Plan inspected." }, "stop"); - }); - await new Promise((done) => server.listen(0, "127.0.0.1", done)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("Offline model did not bind a TCP port."); - - const pi = 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/@jl1990/pi-scheduler/extensions/scheduler/index.ts"), - ], { - cwd, - env: { - // Pi/gpt-6-astra: test the parent role even when vitest itself runs in a worker. - ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("PI_SUBAGENT_") && !name.startsWith("PI_GOALS_"))), - PI_CODING_AGENT_DIR: join(cwd, ".agent"), - PI_OFFLINE: "1", - PI_SCHEDULER_STATE_FILE: join(cwd, "scheduler-tasks.json"), - PI_GOALS_OFFLINE_MODEL_URL: `http://127.0.0.1:${address.port}`, - }, - }); - const client = new RpcClient(pi); - const exited = once(pi, "exit"); - try { - client.send({ type: "prompt", id: "goals", message: "/goals new work out the thing" }); - const review = await client.waitFor(isSelect); - expect(review.options).toEqual(["Ready", "Discuss", "Edit", "Cancel"]); - client.send({ type: "get_state", id: "session-name" }); - const state = await client.waitFor(message => message.type === "response" && message.id === "session-name"); - expect(basename(planPath)).toBe(`${(state.data as { sessionId: string }).sessionId.slice(-6)}-v1.md`); - expect(review.title).toContain(planPath); - const proposal = client.messages.find(message => message.type === "message_end" && (message.message as { customType?: string })?.customType === "goal-plan-proposal"); - expect(proposal?.message).toMatchObject({ content: plan, display: true }); - expect(readFileSync(planPath, "utf8")).toBe(plan); - expect(requests).toHaveLength(2); - expect(systemText(requests[0])).toContain("Plan only in"); - - const choiceStart = client.messages.length; - client.send({ type: "extension_ui_response", id: review.id, value: choice }); - let approvedPlan = plan; - if (choice === "Edit") { - const editor = await client.waitFor(isEditor, choiceStart); - expect(editor.prefill).toBe(plan); - expect(requests).toHaveLength(2); - approvedPlan = plan.replace("the plan names the output", "the plan names output.txt and its exact bytes"); - const editStart = client.messages.length; - client.send({ type: "extension_ui_response", id: editor.id, value: approvedPlan }); - await client.waitFor(message => message.type === "extension_ui_request" && message.method === "setWidget", editStart); - expect(readFileSync(planPath, "utf8")).toBe(approvedPlan); - expect(requests).toHaveLength(2); - } else { - await client.waitFor(message => message.type === "agent_settled", choiceStart); - client.send({ type: "get_state", id: "idle-discuss" }); - const idle = await client.waitFor(message => message.type === "response" && message.id === "idle-discuss"); - expect(idle.data).toMatchObject({ isStreaming: false, pendingMessageCount: 0 }); - expect(requests).toHaveLength(2); - expect(client.messages.slice(choiceStart).filter(message => message.type === "agent_start" || isEditor(message))).toEqual([]); - const userStart = client.messages.length; - client.send({ type: "prompt", id: "user-discussion", message: "Keep the output name, but explain the failure mode." }); - await client.waitFor(message => message.type === "agent_settled", userStart); - expect(requests).toHaveLength(3); - expect(systemText(requests[2])).toContain("Plan only in"); - expect(JSON.stringify(requests[2].messages)).toContain("Keep the output name, but explain the failure mode."); - } - const beforeReady = requests.length; - const reopenStart = client.messages.length; - client.send({ type: "prompt", id: "review", message: "/goals review" }); - const ready = await client.waitFor(isSelect, reopenStart); - expect(requests).toHaveLength(beforeReady); - const readyStart = client.messages.length; - client.send({ type: "extension_ui_response", id: ready.id, value: "Ready" }); - await client.waitFor(message => message.type === "agent_end", readyStart); - expect(requests).toHaveLength(beforeReady + 1); - const supervisor = requests.at(-1)!; - expect(systemText(supervisor)).toContain("You are the goal supervisor in the main chat"); - expect(systemText(supervisor)).not.toContain("Plan only in"); - expect(JSON.stringify(supervisor.messages)).toContain(JSON.stringify(foldPlan(approvedPlan)).slice(1, -1)); - const approval = supervisor.messages.filter(message => message.role === "user").map(message => messageText(message.content)).find(text => text.includes("Ready approved this plan:"))!; - expect(approval).toContain("[pi-goals: approval — Ready]"); - expect(approval).toContain(`Plan excerpt (working set before Log) from ${JSON.stringify(planPath)}:\n\x60\x60\x60md\n${foldPlan(approvedPlan)}\n\x60\x60\x60`); - expect(client.messages.filter(message => message.type === "tool_execution_start").map(message => message.toolName)).toEqual(["write"]); - expect(client.messages.filter(message => message.type === "extension_error")).toEqual([]); - const notices = client.messages.filter(message => message.type === "entry_appended" && (message.entry as { customType?: string })?.customType === "pi-goals-notice"); - expect(notices.length).toBeGreaterThanOrEqual(2); - for (const notice of notices) { - const content = (notice.entry as { data: { content: string } }).data.content; - expect(client.messages.some(event => event.type === "message_end" && (event.message as any)?.role === "user" && (event.message as any)?.content[0]?.text === content)).toBe(true); - expect(supervisor.messages.filter(message => message.role === "user" && messageText(message.content) === content)).toHaveLength(1); - } - const restoreStart = client.messages.length; - client.send({ type: "prompt", id: "restore-same-plan", message: `/goals attach ${planPath}` }); - await client.waitFor(message => message.type === "response" && message.id === "restore-same-plan", restoreStart); - expect(client.messages.slice(restoreStart).filter(isSelect)).toEqual([]); - expect(client.messages.slice(restoreStart).some(message => message.type === "extension_ui_request" && message.method === "notify" && JSON.stringify(message).includes("mode and worker binding unchanged"))).toBe(true); - expect(requests).toHaveLength(beforeReady + 1); - const beforeNotice = requests.length, noticeStart = client.messages.length; - client.send({ type: "prompt", id: "attachment-notice", message: "/fixture-attachment-notice" }); - const attachment = await client.waitFor(message => message.type === "message_end" && (message.message as any)?.customType === "pi-goals-supervision", noticeStart); - expect(attachment.message).toMatchObject({ display: true, content: expect.stringContaining("Metadata only; no acknowledgement or review turn requested") }); - await client.waitFor(message => message.type === "response" && message.id === "attachment-notice", noticeStart); - expect(requests).toHaveLength(beforeNotice); - expect(client.messages.slice(noticeStart).filter(message => message.type === "agent_start")).toEqual([]); - const saved = readFileSync((state.data as { sessionFile: string }).sessionFile, "utf8").trim().split("\n").map(line => JSON.parse(line)); - expect(saved.at(-1)).toMatchObject({ type: "custom_message", customType: "pi-goals-supervision", display: true, content: (attachment.message as any).content }); - if (choice === "Edit") { - // One real scheduler task is fixture setup, not another cadence/wake suite. - createTaskName = `goals-${(state.data as { sessionId: string }).sessionId}`; - const setupStart = client.messages.length; - client.send({ type: "prompt", id: "seed-check-in", message: "Prepare the owned check-in fixture." }); - await client.waitFor(message => message.type === "agent_settled", setupStart); - const task = JSON.parse(readFileSync(join(cwd, "scheduler-tasks.json"), "utf8")).tasks[0]; - expect(task).toMatchObject({ name: `goals-${(state.data as { sessionId: string }).sessionId}`, scope: "session" }); - let release!: () => void; - const held = new Promise(done => { release = done; }); - const requested = once(server, "fixture-busy-request", { signal: AbortSignal.timeout(8_000) }); - holdResponse = () => { server.emit("fixture-busy-request"); return held; }; - const busyStart = client.messages.length, beforeBusy = requests.length; - client.send({ type: "prompt", id: "slow-reply", message: "Wait for the fixture's delayed response." }); - try { - await requested; - client.send({ type: "prompt", id: "busy-clear", message: "/goals clear" }); - await client.waitFor(message => message.type === "response" && message.id === "busy-clear", busyStart); - // The original observer deadline was five seconds; no turn_end occurs yet. - await new Promise(done => setTimeout(done, 6_000)); - } finally { release(); } - await client.waitFor(message => message.type === "agent_settled", busyStart); - expect(client.messages.slice(busyStart).filter(message => message.type === "extension_ui_request" && message.method === "notify" && JSON.stringify(message).includes("removal unconfirmed"))).toEqual([]); - await client.waitFor(message => message.type === "extension_ui_request" && message.method === "notify" && JSON.stringify(message).includes(`Removed scheduled task ${task.id}`), busyStart); - expect(JSON.parse(readFileSync(join(cwd, "scheduler-tasks.json"), "utf8")).tasks).toEqual([]); - expect(requests).toHaveLength(beforeBusy + 1); - console.log(`RPC busy Clear: held beyond 5s; removed ${task.id} after safe flush; requests ${beforeBusy} -> ${requests.length} (only the held response).`); - } - console.log(`RPC ${choice}: passive attachment saved/displayed without inference; visible automatic proposal; ${choice === "Edit" ? "editor saved exact plan without model call" : "discussion retained planning role without editor"}; Ready request used supervisor role; planning/Ready executed only write.`); - } finally { - pi.kill(); - await exited; - await new Promise((done) => server.close(() => done())); - rmSync(cwd, { recursive: true, force: true }); +// One real-Pi story: planning, failed work, lost delivery, correction, restored history, cleanup. +it("plans and reviews the same worker across failure, delivery retry and reload", async () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-goals-rpc-")); + const requests = { parent: [] as ModelRequest[], worker: [] as ModelRequest[] }; + const replies = { parent: [] as any[], worker: [] as any[] }; + const clients: RpcClient[] = []; + let planPath = "", serial = 0, hold: (() => Promise) | undefined; + let holdRole: "parent" | "worker" = "parent"; + const plan = '# Plan\n\n## User voice\nKeep the greeting readable.\n\n## Goals\n- [ ] goal: deliver greeting\n - greeting.txt must contain hello.\n\n## Log\nArchived notes stay on disk.\n'; + const call = (name: string, args: object) => ({ tool_calls: [{ index: 0, id: `fixture-${++serial}`, type: "function", function: { name, arguments: JSON.stringify(args) } }] }); + const server = createServer(async (request, response) => { + let body = ""; for await (const chunk of request) body += chunk; + const role = request.url?.startsWith("/worker") ? "worker" : "parent"; + const input = JSON.parse(body) as ModelRequest; requests[role].push(input); + if (hold && role === holdRole) { const pending = hold; hold = undefined; await pending(); } + if (response.destroyed) return; + let answer = replies[role].shift(); + if (role === "parent" && requests.parent.length === 1) { + planPath = systemText(input).match(/Plan only in (.+?);/)![1]; + answer = call("write", { path: planPath, content: plan }); } - }, 25_000); -}); + if (answer?.fail) { response.writeHead(400, { "content-type": "application/json" }); response.end(JSON.stringify({ error: { message: "Fixture execution failed after progress" } })); return; } + streamResponse(response, answer || { content: "Inspected." }, answer?.tool_calls ? "tool_calls" : "stop"); + }); + await new Promise(done => server.listen(0, "127.0.0.1", done)); + const port = (server.address() as import("node:net").AddressInfo).port; + function start(role: "parent" | "worker", sessionFile?: string) { + 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"), + ...(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_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; + } + async function command(client: RpcClient, message: string) { + const after = client.messages.length, id = `command-${++serial}`; + client.send({ type: "prompt", id, message }); + await client.waitFor(m => m.type === "response" && m.id === id, after); + } + async function run(client: RpcClient, role: "parent" | "worker", ...answers: any[]) { + const after = client.messages.length; replies[role].push(...answers); + client.send({ type: "prompt", id: `run-${++serial}`, message: "Continue the isolated fixture task." }); + await client.waitFor(m => m.type === "agent_settled", after); + } + async function state(client: RpcClient): Promise { + const id = `state-${++serial}`; client.send({ type: "get_state", id }); + return (await client.waitFor(m => m.type === "response" && m.id === id)).data; + } + const entries = (path: string) => readFileSync(path, "utf8").trim().split("\n").map(line => JSON.parse(line)); + const records = (path: string, type: string) => entries(path).filter(e => e.type === "custom" && e.customType === type).map(e => e.data); + async function report(parent: RpcClient, after: number) { + const event = await parent.waitFor(m => m.type === "entry_appended" && (m.entry as any)?.customType === "pi-goals-report", after); + await parent.waitFor(m => m.type === "agent_settled", parent.messages.indexOf(event)); + return (event.entry as any).data; + } + async function stop(client: RpcClient) { const exited = once(client.process, "exit"); client.process.kill(); await exited; } + let parent = start("parent"), worker: RpcClient | undefined; + try { + parent.send({ type: "prompt", id: "new", message: "/goals new deliver the greeting" }); + const proposal = await parent.waitFor(isSelect); + parent.send({ type: "extension_ui_response", id: proposal.id, value: "Edit" }); + const editor = await parent.waitFor(isEditor); + const approved = plan.replace("contain hello", "contain hello followed by a newline"); + const editAt = parent.messages.length; + parent.send({ type: "extension_ui_response", id: editor.id, value: approved }); + await parent.waitFor(m => m.type === "extension_ui_request" && m.method === "setWidget", editAt); + expect(readFileSync(planPath, "utf8")).toBe(approved); expect(requests.parent).toHaveLength(2); + const discussion = parent.messages.length; + parent.send({ type: "prompt", id: "discuss", message: "/goals review" }); + const discuss = await parent.waitFor(isSelect, discussion); + parent.send({ type: "extension_ui_response", id: discuss.id, value: "Discuss" }); + await parent.waitFor(m => m.type === "response" && m.command === "prompt", discussion); + const discussionAt = parent.messages.length; + parent.send({ type: "prompt", id: "discussion", message: "Keep the edited requirement." }); + const ready = await parent.waitFor(isSelect, discussionAt); + expect(systemText(requests.parent.at(-1)!)).toContain("Plan only in"); + const readyAt = parent.messages.length; + parent.send({ type: "extension_ui_response", id: ready.id, value: "Ready" }); + const approvedTurn = await parent.waitFor(m => m.type === "agent_end", readyAt); + await parent.waitFor(m => m.type === "agent_settled", parent.messages.indexOf(approvedTurn)); + expect(systemText(requests.parent.at(-1)!)).toContain("goal supervisor"); + expect(JSON.stringify(requests.parent.at(-1)!.messages)).toContain(JSON.stringify(foldPlan(approved)).slice(1, -1)); + const initialCount = requests.parent.length; + await command(parent, `/goals attach ${planPath}`); + expect(requests.parent).toHaveLength(initialCount); + + // Only native pane allocation is replaced by fixture setup; everything below uses real IPC/history. + const selfAt = parent.messages.length; await run(parent, "parent", call("intercom", { action: "status" })); + const selfResult = parent.messages.slice(selfAt).find(m => m.type === "tool_execution_end" && m.toolName === "intercom") as any; + const parentId = JSON.stringify(selfResult.result.content).match(/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}/)![0]; + await command(parent, `/fixture-worker-binding ${JSON.stringify({ parentId, requestId: "rpc-assignment", task: "Deliver greeting" })}`); + 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 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"); + const workerAt = worker.messages.length; + let releaseWorker!: () => void; const heldWorker = new Promise(done => { releaseWorker = done; }); + const workerRequested = once(server, "worker-held", { signal: AbortSignal.timeout(8_000) }); + holdRole = "worker"; hold = () => { server.emit("worker-held"); return heldWorker; }; + replies.worker.push(call("write", { path: greeting, content: "helo\n" }), call("ReportGoalEvent", { kind: "progress", summary: "Greeting written; verifying." }), { fail: true }); + const workerId = records(parentState.sessionFile, "pi-goals-main-supervisor-v1").at(-1).worker.intercomId; + try { + await run(parent, "parent", call("intercom", { action: "send", to: workerId, message: "Explicit assignment: deliver greeting.txt per the plan, verify it and report the result." })); + await workerRequested; await stop(parent); // lose notification while retaining the real worker history + } finally { releaseWorker(); } + await worker.waitFor(m => m.type === "agent_settled", workerAt); + parent = start("parent", parentState.sessionFile); await state(parent); + const failure = records(parentState.sessionFile, "pi-goals-report").at(-1); + await run(parent, "parent"); + expect(systemText(requests.parent.at(-1)!)).toContain(failure.id); + expect(failure.kind).toBe("blocker"); expect(failure.text).toContain("Fixture execution failed after progress"); + expect(records(parentState.sessionFile, "pi-goals-report")).toHaveLength(1); // receipts/progress/normal stops stayed quiet + + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + git("init", "--quiet"); git("add", "greeting.txt"); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "Initial artifact"); + const revision = git("rev-parse", "HEAD"); + const form = { reportId: failure.id, goal: { path: planPath, quote: "goal: deliver greeting" }, evidence: [{ path: `git:${revision}:greeting.txt`, quote: "helo", observation: "Read the incorrect greeting" }], observation: "The greeting is missing a letter", unmet: "Expected hello", verdict: "changes_requested", continuation: "Replace greeting.txt with hello followed by one newline, read it back, and report the corrected artifact." }; + await run(parent, "parent", call("read", { path: greeting }), call("review_subagent", { ...form, evidence: [{ ...form.evidence[0], quote: "invented bytes" }] })); + expect(records(parentState.sessionFile, "pi-goals-report-review")).toHaveLength(0); + await stop(worker); // exact exit, not disappearance; failed delivery must remain pending + await run(parent, "parent", call("review_subagent", form)); + expect(records(parentState.sessionFile, "pi-goals-report-review")).toHaveLength(0); + worker = start("worker", workerFile); await state(worker); + const inspectionAt = parent.messages.length; + await run(worker, "worker", call("intercom", { action: "list" })); + await parent.waitFor(m => m.type === "entry_appended" && (m.entry as any)?.customType === "pi-goals-worker-event", inspectionAt); + const correctionAt = parent.messages.length, correctionWorkerAt = worker.messages.length; + const statusCount = records(parentState.sessionFile, "pi-goals-worker-event").length; + const correctedEvent = { kind: "review_request", summary: `Corrected artifact: ${greeting}` }; + replies.worker.push(call("write", { path: greeting, content: "hello\n" }), call("read", { path: greeting }), call("ReportGoalEvent", correctedEvent), call("ReportGoalEvent", correctedEvent)); + await run(parent, "parent", call("review_subagent", form)); + const correction = await report(parent, correctionAt); + await worker.waitFor(m => m.type === "agent_settled", correctionWorkerAt); + expect(records(parentState.sessionFile, "pi-goals-worker-event")).toHaveLength(statusCount); + expect(correction.id).not.toBe(failure.id); expect(correction.sessionFile).toBe(workerFile); + expect(readFileSync(greeting, "utf8")).toBe("hello\n"); + const workerCount = requests.worker.length; + await run(parent, "parent", call("read", { path: greeting }), call("review_subagent", { ...form, reportId: correction.id, evidence: [{ path: greeting, quote: "hello", observation: "Read corrected greeting" }], observation: "Matches the requested greeting", unmet: "none", verdict: "accepted", continuation: "" })); + await command(parent, "/goals status"); + expect(requests.worker).toHaveLength(workerCount); // acceptance does not wake or close worker + expect(worker.process.exitCode).toBeNull(); expect(worker.process.signalCode).toBeNull(); + const savedReviews = records(workerFile, "pi-goals-report-review"); + 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); + 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); + expect(records(workerFile, "pi-goals-report-review")).toEqual(savedReviews); + const abortAt = worker.messages.length, abortParentAt = parent.messages.length; + let releaseAbort!: () => void; const abortedRequest = new Promise(done => { releaseAbort = done; }); + const abortRequested = once(server, "aborting", { signal: AbortSignal.timeout(8_000) }); + holdRole = "worker"; hold = () => { server.emit("aborting"); return abortedRequest; }; + worker.send({ type: "prompt", id: "interrupted", message: "Wait for the interruption fixture." }); + try { await abortRequested; worker.send({ type: "abort", id: "abort" }); await worker.waitFor(m => m.type === "agent_settled", abortAt); } finally { releaseAbort(); } + await parent.waitFor(m => m.type === "entry_appended" && (m.entry as any)?.customType === "pi-goals-worker-event" && (m.entry as any).data.kind === "aborted", abortParentAt); + expect(records(parentState.sessionFile, "pi-goals-report")).toHaveLength(2); // intentional interruption is not another formal review + await command(parent, "/fixture-legacy-supersession"); + await run(parent, "parent"); + expect(systemText(requests.parent.at(-1)!)).not.toContain("Pending worker revision reviews:"); + expect(readFileSync(planPath, "utf8")).toBe(approved); // reviews never CompleteGoal + + // Retain the existing busy-Clear discriminator: passive scheduler output can flush after five seconds. + await run(parent, "parent", call("schedule_task", { name: `goals-${parentState.sessionId}`, action: "prompt", type: "interval", schedule: "1h", scope: "session", prompt: "Goal check-in." })); + const task = JSON.parse(readFileSync(join(cwd, "scheduler.json"), "utf8")).tasks[0]; + expect(task.sessionFile).toBe(parentState.sessionFile); + let release!: () => void; const held = new Promise(done => { release = done; }); + const requested = once(server, "held", { signal: AbortSignal.timeout(8_000) }); + holdRole = "parent"; hold = () => { server.emit("held"); return held; }; + const count = requests.parent.length, busyAt = parent.messages.length; + parent.send({ type: "prompt", id: "busy", message: "Wait for delayed fixture response." }); + try { await requested; await command(parent, "/goals clear"); await new Promise(done => setTimeout(done, 6_000)); } finally { release(); } + await parent.waitFor(m => m.type === "agent_settled", busyAt); + await parent.waitFor(m => m.type === "extension_ui_request" && m.method === "notify" && JSON.stringify(m).includes(`Removed scheduled task ${task.id}`), busyAt); + expect(JSON.parse(readFileSync(join(cwd, "scheduler.json"), "utf8")).tasks).toEqual([]); + expect(requests.parent).toHaveLength(count + 1); + if (process.env.PI_GOALS_TEST_EVIDENCE) { + mkdirSync(process.env.PI_GOALS_TEST_EVIDENCE, { recursive: true }); + for (const [name, text] of Object.entries({ "requests.json": JSON.stringify(requests), "parent.jsonl": readFileSync(parentState.sessionFile, "utf8"), "worker.jsonl": readFileSync(workerFile, "utf8"), "events.json": JSON.stringify(clients.map(client => ({ pid: client.process.pid, events: client.messages, stderr: client.stderr }))) })) writeFileSync(join(process.env.PI_GOALS_TEST_EVIDENCE, name), text); + } + } finally { + if (process.env.PI_GOALS_TEST_EVIDENCE) { + mkdirSync(process.env.PI_GOALS_TEST_EVIDENCE, { recursive: true }); + writeFileSync(join(process.env.PI_GOALS_TEST_EVIDENCE, "last-attempt.json"), JSON.stringify({ requests, parent: parent.messages, worker: worker?.messages })); + } + for (const { process: child } of clients) if (child.exitCode === null && child.signalCode === null) { const exited = once(child, "exit"); child.kill(); await exited; } + if (process.env.PI_GOALS_TEST_EVIDENCE) writeFileSync(join(process.env.PI_GOALS_TEST_EVIDENCE, "cleanup.json"), JSON.stringify(clients.map(({ process: child }) => ({ pid: child.pid, exitCode: child.exitCode, signalCode: child.signalCode })))); + await new Promise(done => server.close(() => done())); rmSync(cwd, { recursive: true, force: true }); + } +}, 45_000);