From d356044ca371aad5e3b65b0908062f96cde5984a Mon Sep 17 00:00:00 2001 From: wassname2 Date: Mon, 21 Sep 2026 14:07:20 +0800 Subject: [PATCH] Allow OpenGoalWorker to target an explicit worker cwd --- src/index.ts | 12 ++++++---- src/prompts.ts | 9 ++++--- test/goals.test.ts | 59 ++++++++++++++++++++++++++++++++++------------ 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7f41a0e..a6edea1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,7 +84,7 @@ interface Peer { interface State { mode: Mode; plan?: string; - worker?: { sessionFile?: string; intercomId?: string; paneId?: string; requestId?: string; parentId?: string; task?: string; identity?: Peer; disconnectRevision?: number }; + worker?: { sessionFile?: string; intercomId?: string; paneId?: string; projectRoot?: string; requestId?: string; parentId?: string; task?: string; identity?: Peer; disconnectRevision?: number }; parent?: { intercomId: string; requestId: string }; workerStopped?: boolean; pausedFrom?: "solo" | "supervising"; @@ -763,6 +763,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { `Selected worker-stop reviews pending delivery: ${pendingReports(ctx).map(reportLabel).join("; ") || "none"}`, `Latest worker status event: ${records(ctx, WORKER_EVENT).at(-1)?.kind ?? "none"}`, `Recorded worker session: ${state.worker?.sessionFile ?? "not recorded"}`, + nativeMessages.workerProjectRoot(state.worker?.projectRoot), `Worker Intercom: ${state.worker?.intercomId ?? "unconfirmed"}; native pane: ${state.worker?.identity?.paneId || state.worker?.paneId || "unconfirmed"}`, notedPlanValue("worker session") ? `Worker session noted in plan: ${notedPlanValue("worker session")}` : "", `Check-in: session-scoped pi-scheduler task ${JSON.stringify(`goals-${ctx.sessionManager.getSessionId()}`)} (default 1h; /schedules all shows current recurrence; manage_scheduled_task updates it)`, @@ -898,12 +899,14 @@ export default function mainSupervisor(pi: ExtensionAPI) { }); pi.registerTool({ name: "OpenGoalWorker", label: "Open native goal worker", description: nativeMessages.openDescription, - parameters: Type.Object({ task: Type.String({ minLength: 1 }), model: Type.Optional(Type.String({ description: nativeMessages.modelDescription })) }), + parameters: Type.Object({ task: Type.String({ minLength: 1 }), cwd: Type.Optional(Type.String({ minLength: 1, description: nativeMessages.cwdDescription })), model: Type.Optional(Type.String({ description: nativeMessages.modelDescription })) }), async execute(_id, params, signal, _update, ctx) { if (state.child || state.mode !== "supervising" || !state.plan) return result(goalToolBlocked(state.mode)); - // TODO(2026-11+, Pi): Recheck pi-subagents/project-panes v1's one-pane-per-cwd limit before adding multiple visible workers. + // Stock owns one binding per canonical cwd, including explicitly selected worktrees. if (opening) return result(nativeMessages.alreadyRecorded); if (!params.task.trim()) return result(nativeMessages.taskRequired); + if (params.cwd !== undefined && !params.cwd.trim()) return result(nativeMessages.cwdRequired); + const cwd = params.cwd === undefined ? ctx.cwd : resolve(ctx.cwd, params.cwd); if (!channel?.snapshot().connected || !channel.snapshot().supported) return result(nativeMessages.intercomNotReady); const preflight = generation; const peers = await channel.listSessions().catch(() => undefined); @@ -921,7 +924,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { state.worker = { requestId, parentId: self[0].id, task: params.task }; state.workerStopped = false; workerRevision++; opening = true; save(); 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 }); + const pane = await openProjectPane({ cwd, message: workerAssignment(plan, self[0].id, requestId, params.task, cwd, model), focus: false, signal }); // Stock v1 emits these codes only before pane split/run. Other errors may follow a partial open. const unopened = !pane.ok && ["INVALID_PROJECT_ROOT", "HERDR_UNSUPPORTED_VERSION", "INVALID_BINDING", "BINDING_READ_FAILED", "PANE_OWNERSHIP_UNVERIFIED"].includes(pane.error.code); if ((pane.ok || unopened) && state.plan === plan && state.worker?.requestId === requestId) { @@ -930,6 +933,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { } 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!.projectRoot = pane.data.binding.projectRoot; } save(); refresh(ctx); } diff --git a/src/prompts.ts b/src/prompts.ts index f4492b9..6dd5876 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -158,9 +158,9 @@ export function readyApproved(workerName: string, planPath: string, notedWorker: } const nativeModelControls = "Simple guidance such as 'same model, low' or 'X then Y if it fails' needs no policy engine. For an authorized change, the supervisor can use stock Herdr controls on the exact owned Pi pane, even when its provider cannot answer: submit /model , then /thinking if requested. Check each native response and read back provider/model/thinking via the footer, Intercom and saved session before continuing; model selection may reset thinking. An unavailable model opens a picker with no matching choice, not a successful change: cancel it and report the limitation, never substitute outside authorization. Do not submit commands into an unsent draft or clear it; use native selectors or coordinate with the human. These are same-conversation controls, not a restart or replay. Never save a global default, operate a foreign pane or restore an old preference over a later human choice."; -export function workerAssignment(plan: string, parent: string, requestId: string, task: string, model?: string): string { +export function workerAssignment(plan: string, parent: string, requestId: string, task: string, cwd: string, model?: string): string { const preference = model ? `User-supplied model guidance: ${JSON.stringify(model)}. This is an instruction, not observed configuration. Report your actual provider/model/thinking; the parent verifies or applies authorized settings through native controls before assignment. Preserve later human choices. If the requested choice is unavailable, report it without silently substituting or stalling unrelated authorized work.` : "Inherit the native model; no model switch was requested by this assignment."; - return `You are a new goals-worker in a native project pane for plan ${plan}; request ${requestId}. First call AttachGoalPlan with path ${JSON.stringify(plan)}, parent ${JSON.stringify(parent)} and requestId ${JSON.stringify(requestId)}. Until attachment succeeds, do not implement. Read the supplied plan, applicable AGENTS.md and skills. Confirm the exact parent Intercom UUID ${parent} in the live roster; send it your initial actual Intercom UUID, saved-session path, resolved provider/model and thinking level. Do not infer one identity from another. Use normal tools. ${preference} After attaching and reporting, WAIT for an explicit assignment from that exact parent Intercom session before implementation; the parent may have paused since opening this pane. Proposed task (context only, not execution permission):\n\n${task}\n\nSave actual artifacts and verification output. Report blocked, error and result evidence through Intercom to that exact parent. The parent independently inspects and may send a concrete correction here. Do not approve goals or launch another writer. Respect human pauses and intervention. Keep this conversation open with the final review visible; do not exit, reset, switch session or close the pane.`; + return `You are a new goals-worker in a native project pane for plan ${plan}; request ${requestId}. Requested execution directory: ${JSON.stringify(cwd)}; report your actual execution cwd with your identity. The plan remains at its supplied absolute path, not in a copy under your cwd. First call AttachGoalPlan with path ${JSON.stringify(plan)}, parent ${JSON.stringify(parent)} and requestId ${JSON.stringify(requestId)}. Until attachment succeeds, do not implement. Read the supplied plan, applicable AGENTS.md and skills. Confirm the exact parent Intercom UUID ${parent} in the live roster; send it your initial actual Intercom UUID, saved-session path, resolved provider/model and thinking level. Do not infer one identity from another. Use normal tools. ${preference} After attaching and reporting, WAIT for an explicit assignment from that exact parent Intercom session before implementation; the parent may have paused since opening this pane. Proposed task (context only, not execution permission):\n\n${task}\n\nSave actual artifacts and verification output. Report blocked, error and result evidence through Intercom to that exact parent. The parent independently inspects and may send a concrete correction here. Do not approve goals or launch another writer. Respect human pauses and intervention. Keep this conversation open with the final review visible; do not exit, reset, switch session or close the pane.`; } // wassname's guidance, with Pi wording/spelling edits; decisions remain with the supervisor. const waitingGuidance = `Followed long job: let it run; verify its follow-up and check less often. @@ -176,7 +176,7 @@ When a worker asks to stop or reports completion/blockage, choose among: steer/r Humour is a reflective meta-learning mechanism, not decoration. At natural checkpoints, occasionally use one short relevant fortune, joke or kaomoji to expose a loop, mistaken frame or surprising result, then say what it changes. Keep it sparse; never put it in formal evidence or force cheerfulness. (b •_•)b -- wassname You can speculate and brainstorm around uncertainty or unexpected results. Label guesses as guesses, consider alternative explanations, and look for a useful way to tell them apart. Keep exploration brief, open-minded and fun: take a step back, play with surprising ideas, question the current framing, and enjoy exploring the broader perspective while staying connected to the agreed goal. Take uncertainty as an invitation to investigate, not something to hide. Have room to play with ideas, question yourself and the worker, and appreciate a good surprise. Investigate surprising results, find mistaken assumptions, make complicated ideas simpler, and disagree usefully rather than agree politely. Keep the work moving without turning supervision into paperwork. A little affectionate teasing is welcome when it fits, and workers can push back too. Keep the humor friendly and the criticism specific. -- Pi/Astra -Use OpenGoalWorker for the native project pane and stock Intercom only for exact-session assignment/report/steering after correlated attachment. Never create a goals-worker with raw Intercom openProjectPaneIfMissing or subagent project.open. A roster row is not attachment; worker_view must show the attached saved session before assignment, otherwise automatic stop supervision is unavailable. Use a bounded stock helper for authorized non-pane work rather than invent an orphan goals-worker. Do not use subagent as a second goals-worker backend. Supervise only this plan's attached worker and owned helpers; foreign agents may be coordinated with, but never stopped, retasked, closed or reviewed without explicit user authority. ${helperGuidance} A stored binding is not proof of liveness; missing runtime state is not proof of stop. Verify actual Intercom identities with list/status; your Pi session ID is ${supervisorId}, a distinct field. Require artifact paths, saved verification and blocker/error reports. When the worker stops for any reason, inspect actual artifacts and saved messages before approving or correcting it in the same open session. A recap or receipt alone sends no instruction and proves no action. Record actual pane identity, '- worker session:' and '- worker intercom session:' with provenance. CompleteGoal belongs only to this parent or explicitly confirmed solo self-verification. +Use OpenGoalWorker for the native project pane and stock Intercom only for exact-session assignment/report/steering after correlated attachment. Pass cwd to target a real, already-authorized task directory or worktree; omission uses this supervisor's cwd. Stock has one binding per canonical cwd, not multiple slots in the same cwd. Inspect artifacts and project status in the returned worker projectRoot (retained in /goals status), not automatically in the supervisor's directory. Directory existence does not authorize work. Never create a goals-worker with raw Intercom openProjectPaneIfMissing or subagent project.open. A roster row is not attachment; worker_view must show the attached saved session before assignment, otherwise automatic stop supervision is unavailable. Use a bounded stock helper for authorized non-pane work rather than invent an orphan goals-worker. Do not use subagent as a second goals-worker backend. Supervise only this plan's attached worker and owned helpers; foreign agents may be coordinated with, but never stopped, retasked, closed or reviewed without explicit user authority. ${helperGuidance} A stored binding is not proof of liveness; missing runtime state is not proof of stop. Verify actual Intercom identities with list/status; your Pi session ID is ${supervisorId}, a distinct field. Require artifact paths, saved verification and blocker/error reports. When the worker stops for any reason, inspect actual artifacts and saved messages before approving or correcting it in the same open session. A recap or receipt alone sends no instruction and proves no action. Record actual pane identity, '- worker session:' and '- worker intercom session:' with provenance. CompleteGoal belongs only to this parent or explicitly confirmed solo self-verification. Keep normal tools and honor human model changes. The human can inspect, talk to and change /model in the worker pane directly; treat direct human instructions and the worker's current model as authoritative rather than assuming an agent changed them. Do not revert either unless the human asks. Inherit by default. If the user supplies model guidance, pass it through OpenGoalWorker or exact-session Intercom and verify the actual setting before assignment. ${nativeModelControls} project.open itself has no model override; a requested model is not proof of configuration. Report a specific unavailable choice without silently substituting or stalling unrelated authorized work. After compaction reread the plan. Lost connection or exhausted credits does not erase work. Preserve drafts and saved sessions; confirm other writers stopped before solo takeover. Revisions use ordinary Intercom in the same context. New workers attach/report and wait for your direct assignment; verify current execution authorization before sending it. For stopped-worker replacement or a cloned/moved supervisor session, inspect saved history and partial work when useful, then use your judgment and call OpenGoalWorker. A newly opened replacement supersedes the recorded runtime binding while preserving its history; an already-open stock pane preserves the current binding. pi-goals owns attachment/report correlation, not generic writer concurrency; coordinate other writers through normal stock controls without turning uncertainty into a human gate. Never replace through raw project.open because it lacks goal stop correlation. If infrastructure fails, use an isolated or bounded helper for safe work and raise the exact defect without stopping unrelated goals; do not invent recovery controls. Do not reapply historical preferences over later human choices. Never replace an unreviewed conversation or start a duplicate writer.`; } // Routine notices quote only selected goal lines; full context stops at Log. @@ -286,6 +286,9 @@ export const nativeMessages = { samePlanRestored: "Plan context refreshed; mode and worker binding unchanged. No new work authorized.", workerPause: (paused: boolean) => `Worker ${paused ? "paused" : "unpaused"} locally; no new task submitted and no approval authority granted.`, taskRequired: "Supply an explicit bounded proposed task for a new worker context.", + cwdDescription: "Existing authorized worker directory or worktree. Relative paths resolve against the supervisor cwd; omit to use that cwd. No directory creation or execution authorization is implied.", + cwdRequired: "Supply a nonempty worker cwd, or omit it to use the supervisor cwd. No worker opened.", + workerProjectRoot: (projectRoot?: string) => `Worker project root (stock canonical binding): ${projectRoot ?? "not recorded"}`, modelDescription: "User model/thinking guidance for verified stock native controls, not a launch override.", modelRecorded: (guidance: string) => `Worker model guidance recorded as ${guidance}; not yet configured. ${nativeModelControls}`, openDescription: "After Ready, ask stock project.open for a native worker pane with a bounded proposed task. A newly opened worker must AttachGoalPlan and report, then wait for exact-session assignment; pi-goals preserves and supersedes any prior runtime binding only after stock opens that replacement. An existing pane receives no startup, so its current binding is preserved and the result is returned for normal supervisor handling. Inherit model defaults unless the user supplies guidance for verified stock native controls. pi-goals owns attachment/report/stop correlation, not generic writer concurrency.", diff --git a/test/goals.test.ts b/test/goals.test.ts index 15a6171..de8ae5c 100644 --- a/test/goals.test.ts +++ b/test/goals.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, 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"; @@ -69,8 +69,8 @@ function fixture(child = false) { await delay(25); }; const start = (_id: string) => hooks.get("tool_call")({ toolName: "OpenGoalWorker" }, ctx); - const launch = async (details: { id: string; sessionFile: string; task?: string }) => { - await tools.get("OpenGoalWorker").execute("open", { task: details.task ?? "Implement first output" }, undefined, undefined, ctx); + const launch = async (details: { id: string; sessionFile: string; task?: string; cwd?: string }) => { + await tools.get("OpenGoalWorker").execute("open", { task: details.task ?? "Implement first output", cwd: details.cwd }, undefined, undefined, ctx); const state = entries.at(-1).data; registration.onEvent({ type: "message", fromSessionId: details.id, payload: { type: "attached", to: state.worker.parentId, requestId: state.worker.requestId, plan: state.plan, sessionFile: details.sessionFile } }); }; @@ -1213,8 +1213,13 @@ it("passive pause is visible immediately while its model notice waits safely for }); // The native surface has one project binding; these replace old launch-schema/helper tests. -it("opens no-focus, records exact-worker stop events without automatic review debt", async () => { +it.each(["default", "absolute", "relative"])("opens no-focus with %s cwd and keeps exact-worker stop correlation", async (mode) => { const f = fixture(); await f.draft(); await f.command("ready"); + const supervisorCwd = f.ctx.cwd; + const workerCwd = mode === "default" ? supervisorCwd : mkdtempSync(join(tmpdir(), "goals-worker-project-")); + if (mode !== "default") roots.push(workerCwd); + const cwd = mode === "default" ? undefined : mode === "relative" ? relative(supervisorCwd, workerCwd) : workerCwd; + const projectRoot = realpathSync(workerCwd); for (const event of [ { toolName: "intercom", input: { action: "send", cwd: "/tmp/other", openProjectPaneIfMissing: true } }, { toolName: "subagent", input: { action: "project.open", cwd: "/tmp/other" } }, @@ -1223,11 +1228,16 @@ it("opens no-focus, records exact-worker stop events without automatic review de f.channel.listSessions.mockRejectedValueOnce(new Error("Intercom is not connected")); const waiting = await f.tools.get("OpenGoalWorker").execute("open", { task: "first" }, undefined, undefined, f.ctx); expect(waiting.content[0].text).toContain("still connecting"); expect(openProjectPane).not.toHaveBeenCalled(); - await f.launch({ id: "worker-id", sessionFile: "/tmp/native-worker.jsonl", task: "Inspect [cached interruption audit](slop/audits/20260916_job1551_a2_cached_interruption_audit.md) before rerun" }); - expect(openProjectPane).toHaveBeenCalledWith(expect.objectContaining({ cwd: f.ctx.cwd, focus: false })); - expect(vi.mocked(openProjectPane).mock.calls[0][0].message).toContain("WAIT for an explicit assignment"); + vi.mocked(openProjectPane).mockResolvedValueOnce({ ok: true, data: { bindingPath: join(projectRoot, ".pi/subagents/project-panes/herdr.json"), disposition: "opened", binding: { paneId: "native-pane", projectRoot, command: "pi" } } }); + await f.launch({ id: "worker-id", sessionFile: "/tmp/native-worker.jsonl", cwd, task: "Inspect [cached interruption audit](slop/audits/20260916_job1551_a2_cached_interruption_audit.md) before rerun" }); + expect(openProjectPane).toHaveBeenCalledWith(expect.objectContaining({ cwd: workerCwd, focus: false })); + const startup = vi.mocked(openProjectPane).mock.calls[0][0].message!; const worker = f.entries.at(-1).data.worker; - expect(worker).toMatchObject({ paneId: "native-pane", intercomId: "worker-id", sessionFile: "/tmp/native-worker.jsonl" }); + expect(worker).toMatchObject({ paneId: "native-pane", projectRoot, parentId: "parent-intercom", intercomId: "worker-id", sessionFile: "/tmp/native-worker.jsonl" }); + for (const reference of [f.path, worker.parentId, worker.requestId, workerCwd]) expect(startup).toContain(JSON.stringify(reference)); + expect(f.entries.at(-1).data.plan).toBe(f.path); + expect(f.ctx.cwd).toBe(supervisorCwd); + expect(f.channel.publish).not.toHaveBeenCalled(); // attachment alone sends no assignment expect(f.messages.at(-1)).toMatchObject({ message: { customType: "pi-goals-supervision", display: true, content: expect.stringContaining("Metadata only; no acknowledgement or review turn requested") }, options: { triggerTurn: false } }); expect(f.messages.at(-1).savedPrompt).toBeUndefined(); f.event({ type: "message", fromSessionId: "orphan-worker", payload: { type: "attached", to: worker.parentId, requestId: "invented-request", plan: f.path, sessionFile: "/tmp/orphan.jsonl" } }); @@ -1266,6 +1276,11 @@ it("opens no-focus, records exact-worker stop events without automatic review de await f.command("status"); expect(f.ctx.ui.notify.mock.lastCall?.[0]).toContain("Latest worker status event: unclassified"); expect(f.ctx.ui.notify.mock.lastCall?.[0]).not.toContain("automatic-stop"); + expect(f.ctx.ui.notify.mock.lastCall?.[0]).toContain(projectRoot); + f.hooks.get("session_start")({}, f.ctx); + await f.command("status"); + expect(f.ctx.ui.notify.mock.lastCall?.[0]).toContain(projectRoot); + expect(f.entries.at(-1).data.worker).toMatchObject({ projectRoot, requestId: worker.requestId, intercomId: "worker-id" }); expect(readFileSync(f.path, "utf8")).not.toContain("[✓]"); await f.command("stop"); f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, entryId: "revision-2", text: "New stop request during pause" } }); @@ -1329,7 +1344,7 @@ it("supersedes an inherited worker binding when the supervisor opens a replaceme const opened = await f.tools.get("OpenGoalWorker").execute("replacement", { task: "Continue the approved plan" }, undefined, undefined, f.ctx); expect(opened.content[0].text).toContain('"disposition":"opened"'); const replacement = f.entries.at(-1).data.worker; - expect(replacement).toMatchObject({ paneId: "native-pane", parentId: "parent-intercom", task: "Continue the approved plan" }); + expect(replacement).toMatchObject({ paneId: "native-pane", projectRoot: "/project", parentId: "parent-intercom", task: "Continue the approved plan" }); // retain stock's root, not the supervisor cwd expect(replacement.requestId).not.toBe(oldRequest); const release = f.ctx.sessionManager.getBranch().find((entry: any) => entry.customType === "pi-goals-worker-release"); expect(release?.data).toMatchObject({ plan: f.path, worker: { intercomId: "old-worker", requestId: oldRequest }, task: "Continue the approved plan" }); @@ -1338,15 +1353,23 @@ it("supersedes an inherited worker binding when the supervisor opens a replaceme expect(openProjectPane).toHaveBeenCalledTimes(2); }); -it("preserves the current worker binding when stock reports an existing pane", async () => { +it.each(["default", "alternate"])("preserves the current worker binding when stock reports an existing %s pane", async (mode) => { const f = fixture(); await f.draft(); await f.command("ready"); await f.launch({ id: "old-worker", sessionFile: "/tmp/old-worker.jsonl", task: "Old task" }); const previous = structuredClone(f.entries.at(-1).data.worker); - vi.mocked(openProjectPane).mockResolvedValueOnce({ ok: true, data: { bindingPath: "/existing/binding.json", disposition: "already-open", binding: { paneId: "existing-pane", projectRoot: f.ctx.cwd, command: "pi" } } }); - const opened = await f.tools.get("OpenGoalWorker").execute("existing", { task: "Proposed replacement" }, undefined, undefined, f.ctx); + const cwd = mode === "default" ? undefined : mkdtempSync(join(tmpdir(), "goals-foreign-project-")); + if (cwd) roots.push(cwd); + const projectRoot = realpathSync(cwd ?? f.ctx.cwd); + vi.mocked(openProjectPane).mockResolvedValueOnce({ ok: true, data: { bindingPath: "/existing/binding.json", disposition: "already-open", binding: { paneId: "existing-pane", projectRoot, command: "pi" } } }); + const opened = await f.tools.get("OpenGoalWorker").execute("existing", { task: "Proposed replacement", cwd }, undefined, undefined, f.ctx); + expect(openProjectPane).toHaveBeenLastCalledWith(expect.objectContaining({ cwd: cwd ?? f.ctx.cwd, focus: false })); expect(opened.content[0].text).toContain('"disposition":"already-open"'); + expect(opened.content[0].text).toContain(JSON.stringify(projectRoot)); expect(f.entries.at(-1).data.worker).toEqual(previous); + expect(f.channel.publish).not.toHaveBeenCalled(); expect(f.ctx.sessionManager.getBranch().some((entry: any) => entry.customType === "pi-goals-worker-release")).toBe(false); + f.event({ type: "message", fromSessionId: "old-worker", payload: { type: "stopped", to: previous.parentId, requestId: previous.requestId, plan: f.path, entryId: "after-foreign-open", kind: "blocker", text: "Original worker still reports" } }); + expect(f.ctx.sessionManager.getBranch().some((entry: any) => entry.customType === "pi-goals-worker-event" && entry.data.id === "old-worker:after-foreign-open")).toBe(true); }); it("retains worker history and report routing after definite pre-open failure, not ambiguous partial open", async () => { @@ -1358,8 +1381,10 @@ it("retains worker history and report routing after definite pre-open failure, n 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("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); + vi.mocked(openProjectPane).mockImplementationOnce(stock.openProjectPane); + const reply = await tool.execute("failed", { task: "Proposed replacement", cwd: "missing-directory" }, undefined, undefined, f.ctx); + expect(openProjectPane).toHaveBeenLastCalledWith(expect.objectContaining({ cwd: join(f.ctx.cwd, "missing-directory") })); + expect(existsSync(join(f.ctx.cwd, "missing-directory"))).toBe(false); expect(reply.content[0].text).toContain("INVALID_PROJECT_ROOT"); expect(f.entries.at(-1).data.worker).toEqual(previous); vi.mocked(openProjectPane).mockResolvedValueOnce({ ok: false, error: { code: "HERDR_UNSUPPORTED_VERSION", message: "Stock version preflight rejected" } }); @@ -1470,6 +1495,10 @@ it("blocks concurrent opening and lets stock pane ownership resolve a retry", as const tool = f.tools.get("OpenGoalWorker"); expect(tool.parameters.properties.task.minLength).toBe(1); expect((await tool.execute("empty", { task: "" }, undefined, undefined, f.ctx)).content[0].text).toContain("task"); + const before = structuredClone(f.entries.at(-1).data); + for (const cwd of ["", " "]) await tool.execute("empty-cwd", { task: "first", cwd }, undefined, undefined, f.ctx); + expect(openProjectPane).not.toHaveBeenCalled(); + expect(f.entries.at(-1).data).toEqual(before); let release!: () => void; vi.mocked(openProjectPane).mockImplementationOnce(() => new Promise((_resolve, reject) => { release = () => reject(new Error("connection lost after open")); })); const opening = f.tools.get("OpenGoalWorker").execute("open", { task: "first" }, undefined, undefined, f.ctx); @@ -1491,7 +1520,7 @@ it("leaves an existing stock pane unbound instead of replacing or retasking it", const before = f.messages.length; const reply = await f.tools.get("OpenGoalWorker").execute("open", { task: "proposed work" }, undefined, undefined, f.ctx); expect(reply.content[0].text).toContain("no startup was sent"); - expect(f.entries.at(-1).data.worker).toMatchObject({ paneId: "existing-pane" }); + expect(f.entries.at(-1).data.worker).toMatchObject({ paneId: "existing-pane", projectRoot: f.ctx.cwd }); expect(f.entries.at(-1).data.worker.intercomId).toBeUndefined(); expect(f.messages).toHaveLength(before); expect(f.channel.publish).not.toHaveBeenCalled();