diff --git a/.changeset/plan-artifact-writeback.md b/.changeset/plan-artifact-writeback.md new file mode 100644 index 0000000000..ec1476537d --- /dev/null +++ b/.changeset/plan-artifact-writeback.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Plans written inside a task worktree are now saved to the main project and stored in the database. +category: fix +dev: New `packages/engine/src/plan-artifact-writeback.ts` exposes `reconcileWorktreePlanArtifact`, `mirrorPlanToProjectDb`, and `persistPlanArtifact`. Planning sessions run in the task worktree with the coding tool surface, so a planner using the generic write tool resolved the relative `.fusion/tasks//PROMPT.md` against the worktree; triage finalization reads `/` and saw nothing. Triage now reconciles the worktree copy through `store.updateTask({ prompt })` before the finalize read. `project.tasks` has no `prompt` column, so the authoritative plan is also mirrored into the `plan` task document from triage finalization and from `fn_task_prompt_write`. diff --git a/packages/engine/src/__tests__/plan-artifact-writeback.test.ts b/packages/engine/src/__tests__/plan-artifact-writeback.test.ts new file mode 100644 index 0000000000..0fdff18826 --- /dev/null +++ b/packages/engine/src/__tests__/plan-artifact-writeback.test.ts @@ -0,0 +1,211 @@ +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PLAN_DOCUMENT_KEY, + mirrorPlanToProjectDb, + persistPlanArtifact, + reconcileWorktreePlanArtifact, + relativePromptPath, +} from "../plan-artifact-writeback.js"; + +/* +FNXC:PlanArtifactPersistence 2026-07-26-03:55: +Invariant under test (not just the reported repro): a plan written anywhere a planning session can write it +ends up (a) in the MAIN project `.fusion/` folder and (b) in the project database. Surfaces covered here: +worktree-stranded write, root write, empty/absent worktree file, identical content, and persistence failure. +*/ + +const TASK_ID = "FN-9001"; +const REAL_SPEC = "# Task: FN-9001 - Real spec\n\n## Mission\n\nDo the thing.\n"; + +interface FakeStore { + updateTask: ReturnType; + upsertTaskDocument: ReturnType; + getTaskDocument: ReturnType; + documents: Map; +} + +function createFakeStore(rootDir: string): FakeStore { + const documents = new Map(); + return { + documents, + // Mirrors the real `updateTask({ prompt })` contract: the project-root PROMPT.md is the artifact it writes. + updateTask: vi.fn(async (id: string, updates: { prompt?: string }) => { + if (updates.prompt !== undefined) { + const dir = join(rootDir, ".fusion", "tasks", id); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "PROMPT.md"), updates.prompt); + } + }), + upsertTaskDocument: vi.fn(async (id: string, input: { key: string; content: string }) => { + documents.set(`${id}:${input.key}`, input.content); + return { key: input.key, content: input.content }; + }), + getTaskDocument: vi.fn(async (id: string, key: string) => { + const content = documents.get(`${id}:${key}`); + return content === undefined ? null : { key, content }; + }), + }; +} + +async function writeSpec(baseDir: string, taskId: string, content: string): Promise { + const path = join(baseDir, relativePromptPath(taskId)); + await mkdir(join(baseDir, ".fusion", "tasks", taskId), { recursive: true }); + await writeFile(path, content); +} + +describe("plan artifact write-back", () => { + let rootDir: string; + let worktreeDir: string; + let store: FakeStore; + + beforeEach(async () => { + rootDir = await mkdtemp(join(tmpdir(), "fusion-plan-root-")); + worktreeDir = await mkdtemp(join(tmpdir(), "fusion-plan-worktree-")); + store = createFakeStore(rootDir); + }); + + it("copies a worktree-stranded plan back into the main project .fusion folder", async () => { + await writeSpec(worktreeDir, TASK_ID, REAL_SPEC); + + const result = await reconcileWorktreePlanArtifact({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: store as any, + taskId: TASK_ID, + rootDir, + planningCwd: worktreeDir, + }); + + expect(result.outcome).toBe("recovered"); + expect(result.content).toBe(REAL_SPEC); + // Persisted through the single validated path, not a raw copy. + expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, { prompt: REAL_SPEC }); + await expect(readFile(join(rootDir, relativePromptPath(TASK_ID)), "utf-8")).resolves.toBe(REAL_SPEC); + }); + + it("stores the plan in the project database as the plan task document", async () => { + await writeSpec(worktreeDir, TASK_ID, REAL_SPEC); + + const result = await persistPlanArtifact({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: store as any, + taskId: TASK_ID, + rootDir, + planningCwd: worktreeDir, + author: "triage", + }); + + expect(result.outcome).toBe("recovered"); + expect(result.mirrored).toBe(true); + expect(store.documents.get(`${TASK_ID}:${PLAN_DOCUMENT_KEY}`)).toBe(REAL_SPEC); + }); + + it("mirrors a root-written plan into the database even when planning never used a worktree", async () => { + await writeSpec(rootDir, TASK_ID, REAL_SPEC); + + const result = await persistPlanArtifact({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: store as any, + taskId: TASK_ID, + rootDir, + planningCwd: rootDir, + }); + + expect(result.outcome).toBe("not-worktree"); + expect(result.mirrored).toBe(true); + expect(store.documents.get(`${TASK_ID}:${PLAN_DOCUMENT_KEY}`)).toBe(REAL_SPEC); + // Nothing to copy back — the root copy is already authoritative. + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("never overwrites an authoritative root plan with an empty worktree file", async () => { + await writeSpec(rootDir, TASK_ID, REAL_SPEC); + await writeSpec(worktreeDir, TASK_ID, " \n"); + + const result = await reconcileWorktreePlanArtifact({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: store as any, + taskId: TASK_ID, + rootDir, + planningCwd: worktreeDir, + }); + + expect(result.outcome).toBe("worktree-artifact-empty"); + expect(store.updateTask).not.toHaveBeenCalled(); + await expect(readFile(join(rootDir, relativePromptPath(TASK_ID)), "utf-8")).resolves.toBe(REAL_SPEC); + }); + + it("is a no-op when the planner used the durable writer and left nothing in the worktree", async () => { + await writeSpec(rootDir, TASK_ID, REAL_SPEC); + + const result = await reconcileWorktreePlanArtifact({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: store as any, + taskId: TASK_ID, + rootDir, + planningCwd: worktreeDir, + }); + + expect(result.outcome).toBe("no-worktree-artifact"); + expect(result.content).toBe(REAL_SPEC); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("does not rewrite when the worktree copy already matches the root copy", async () => { + await writeSpec(rootDir, TASK_ID, REAL_SPEC); + await writeSpec(worktreeDir, TASK_ID, REAL_SPEC); + + const result = await reconcileWorktreePlanArtifact({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: store as any, + taskId: TASK_ID, + rootDir, + planningCwd: worktreeDir, + }); + + expect(result.outcome).toBe("already-authoritative"); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("leaves the root copy untouched and stays non-fatal when persistence fails", async () => { + await writeSpec(rootDir, TASK_ID, REAL_SPEC); + await writeSpec(worktreeDir, TASK_ID, "# Task: FN-9001 - Rewritten\n"); + store.updateTask.mockRejectedValueOnce(new Error("invalid file scope")); + const warn = vi.fn(); + + const result = await reconcileWorktreePlanArtifact({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store: store as any, + taskId: TASK_ID, + rootDir, + planningCwd: worktreeDir, + logger: { warn }, + }); + + expect(result.outcome).toBe("recovery-failed"); + expect(warn).toHaveBeenCalled(); + await expect(readFile(join(rootDir, relativePromptPath(TASK_ID)), "utf-8")).resolves.toBe(REAL_SPEC); + }); + + it("skips redundant document revisions when the mirrored plan is unchanged", async () => { + await expect(mirrorPlanToProjectDb(store as never, TASK_ID, REAL_SPEC)).resolves.toBe(true); + await expect(mirrorPlanToProjectDb(store as never, TASK_ID, REAL_SPEC)).resolves.toBe(false); + expect(store.upsertTaskDocument).toHaveBeenCalledTimes(1); + }); + + it("never mirrors empty plan content", async () => { + await expect(mirrorPlanToProjectDb(store as never, TASK_ID, " \n ")).resolves.toBe(false); + expect(store.upsertTaskDocument).not.toHaveBeenCalled(); + }); + + it("stays non-fatal when the database mirror fails", async () => { + store.upsertTaskDocument.mockRejectedValueOnce(new Error("db down")); + const warn = vi.fn(); + await expect(mirrorPlanToProjectDb(store as never, TASK_ID, REAL_SPEC, { logger: { warn } })).resolves.toBe(false); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index b808f98cd7..79119c4b1a 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -24,6 +24,8 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@earendil-works/pi-ai"; import type { AgentReflectionService } from "./agent-reflection.js"; import { createLogger } from "./logger.js"; +// FNXC:PlanArtifactPersistence 2026-07-26-03:55: PROMPT.md is filesystem-only; mirror plan writes into the DB. +import { mirrorPlanToProjectDb } from "./plan-artifact-writeback.js"; import { fetchWebContent, WebFetchError } from "./web-fetch.js"; import type { RunAuditor } from "./run-audit.js"; import { computeApprovalDedupeKey } from "./agent-action-gate.js"; @@ -1913,6 +1915,16 @@ export function createTaskPromptWriteTool(store: TaskStore, taskId: string, runC if (persisted?.prompt !== params.content) { throw new Error("authoritative PROMPT.md read-back did not match the requested content; persistence could not be verified"); } + /* + FNXC:PlanArtifactPersistence 2026-07-26-03:55: + `updateTask({ prompt })` writes the project-root PROMPT.md and task.json, but `project.tasks` has + no `prompt` column — the spec would live only as a file in the project checkout. Mirror it into the + `plan` task document so the plan is durable in the project database too. Best-effort: a mirror + failure must not fail a write whose authoritative persistence was just verified above. + */ + await mirrorPlanToProjectDb(store, taskId, params.content, { + author: runContext?.agentId ?? "agent", + }); return { content: [{ type: "text" as const, text: `Updated PROMPT.md for ${taskId}.` }], details: {}, diff --git a/packages/engine/src/plan-artifact-writeback.ts b/packages/engine/src/plan-artifact-writeback.ts new file mode 100644 index 0000000000..3b09c4d79e --- /dev/null +++ b/packages/engine/src/plan-artifact-writeback.ts @@ -0,0 +1,181 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { TaskStore } from "@fusion/core"; + +/* +FNXC:PlanArtifactPersistence 2026-07-26-03:55: +Planning sessions run in the TASK's own worktree (see FNXC:NodeWorktreeIsolation in triage.ts), and they +carry the full coding tool surface. The system prompt tells the planner to persist through +`fn_task_prompt_write`, but that is a soft instruction: a planner that reaches for the generic write tool +writes the relative spec path (`.fusion/tasks//PROMPT.md`) against its own cwd, so the spec lands +INSIDE the worktree. Triage then finalizes by reading `/`, sees nothing, and fails +deterministic validation — and the worktree copy is destroyed with the worktree. + +Two durability requirements follow, and this module owns both: +1. A plan written inside a worktree is copied back into the main project `.fusion/` folder. The copy goes + through `store.updateTask({ prompt })`, which is the single validated persistence path (File Scope + validation, root PROMPT.md write, and task.json sync stay together and stay atomic). +2. The authoritative plan is mirrored into the project database. `project.tasks` has no `prompt` column — + PROMPT.md is filesystem-only and is hydrated on read — so losing the project checkout loses every spec. + The mirror uses the existing `task_documents` store under the `plan` key, which triage already reads as + a planning-draft fallback (`readNonEmptyPlanningDraft`), so recovery has a DB-backed source of truth. +*/ + +/** Relative, cwd-anchored path handed to the planning agent for a task's spec. */ +export function relativePromptPath(taskId: string): string { + return `.fusion/tasks/${taskId}/PROMPT.md`; +} + +/** The `task_documents` key used to mirror the authoritative plan into the project DB. */ +export const PLAN_DOCUMENT_KEY = "plan"; + +export interface PlanWritebackLogger { + log?: (message: string) => void; + warn?: (message: string) => void; +} + +export interface ReconcileWorktreePlanArtifactOptions { + store: TaskStore; + taskId: string; + /** Project root checkout — the authoritative `.fusion/` location. */ + rootDir: string; + /** cwd the planning session ran in. Equal to `rootDir` when planning did not get a worktree. */ + planningCwd: string; + logger?: PlanWritebackLogger; +} + +export type PlanWritebackOutcome = + /** Planning ran in the project root; there is no separate copy to reconcile. */ + | "not-worktree" + /** No spec file inside the worktree — the planner used the durable writer, as instructed. */ + | "no-worktree-artifact" + /** Worktree copy is empty/whitespace; nothing worth rescuing. */ + | "worktree-artifact-empty" + /** Worktree copy matches the authoritative root copy already. */ + | "already-authoritative" + /** Worktree copy was copied back into the project `.fusion/` folder. */ + | "recovered" + /** A worktree copy existed but persisting it failed; the root copy is untouched. */ + | "recovery-failed"; + +export interface ReconcileWorktreePlanArtifactResult { + outcome: PlanWritebackOutcome; + /** Authoritative plan content after reconciliation, when one could be resolved. */ + content?: string; +} + +async function readIfPresent(path: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch { + return null; + } +} + +/** + * FNXC:PlanArtifactPersistence 2026-07-26-03:55: + * Copy a worktree-local PROMPT.md back into the main project `.fusion/` folder. + * + * Only rescues a STRANDED plan: when the root copy already matches, or the worktree holds nothing, this + * is a no-op. The root copy is never overwritten with an empty or whitespace-only worktree file, so a + * planner that used `fn_task_prompt_write` correctly cannot have its spec clobbered by a stale stub the + * worktree happened to inherit. + * + * Failure is non-fatal: triage's own deterministic validation still owns the "no usable spec" verdict, and + * a failed rescue must not convert a recoverable planning pass into a hard error. + */ +export async function reconcileWorktreePlanArtifact( + options: ReconcileWorktreePlanArtifactOptions, +): Promise { + const { store, taskId, rootDir, planningCwd, logger } = options; + const relPath = relativePromptPath(taskId); + + const rootContent = await readIfPresent(join(rootDir, relPath)); + if (planningCwd === rootDir) { + return { outcome: "not-worktree", content: rootContent ?? undefined }; + } + + const worktreeContent = await readIfPresent(join(planningCwd, relPath)); + if (worktreeContent === null) { + return { outcome: "no-worktree-artifact", content: rootContent ?? undefined }; + } + if (worktreeContent.trim().length === 0) { + return { outcome: "worktree-artifact-empty", content: rootContent ?? undefined }; + } + if (rootContent === worktreeContent) { + return { outcome: "already-authoritative", content: rootContent }; + } + + try { + // The single validated persistence path: File Scope validation + root PROMPT.md write + task.json sync. + await store.updateTask(taskId, { prompt: worktreeContent }); + logger?.log?.( + `${taskId}: recovered a worktree-local PROMPT.md into the project .fusion folder (${planningCwd})`, + ); + return { outcome: "recovered", content: worktreeContent }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger?.warn?.( + `${taskId}: failed to copy the worktree-local PROMPT.md back into the project .fusion folder: ${message}`, + ); + return { outcome: "recovery-failed", content: rootContent ?? undefined }; + } +} + +/** + * FNXC:PlanArtifactPersistence 2026-07-26-03:55: + * Mirror the authoritative plan into the project database under the `plan` task document. + * + * `project.tasks` carries no `prompt` column, so without this the spec exists only as a file in the + * project checkout. The `plan` document is already the draft surface triage falls back to when PROMPT.md + * is absent, so mirroring here makes that recovery path DB-backed instead of filesystem-only. + * + * Best-effort by design: a mirror failure must never fail the planning pass that just produced a good spec. + * Re-mirroring identical content is skipped so repeated prompt writes do not churn document revisions. + */ +export async function mirrorPlanToProjectDb( + store: TaskStore, + taskId: string, + content: string, + options: { author?: string; logger?: PlanWritebackLogger } = {}, +): Promise { + if (content.trim().length === 0) return false; + if (typeof store.upsertTaskDocument !== "function") return false; + + try { + if (typeof store.getTaskDocument === "function") { + const existing = await store.getTaskDocument(taskId, PLAN_DOCUMENT_KEY); + if (typeof existing?.content === "string" && existing.content === content) return false; + } + await store.upsertTaskDocument(taskId, { + key: PLAN_DOCUMENT_KEY, + content, + author: options.author ?? "agent", + }); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options.logger?.warn?.(`${taskId}: failed to mirror the plan into the project database: ${message}`); + return false; + } +} + +/** + * FNXC:PlanArtifactPersistence 2026-07-26-03:55: + * Combined post-planning durability pass: rescue a worktree-stranded spec into the project `.fusion/` + * folder, then mirror whatever is authoritative afterwards into the project database. Callers run this + * BEFORE reading the finalized spec so the read observes the recovered content. + */ +export async function persistPlanArtifact( + options: ReconcileWorktreePlanArtifactOptions & { author?: string }, +): Promise { + const result = await reconcileWorktreePlanArtifact(options); + const mirrored = result.content + ? await mirrorPlanToProjectDb(options.store, options.taskId, result.content, { + author: options.author, + logger: options.logger, + }) + : false; + return { ...result, mirrored }; +} diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 2e8adb1f0b..9d3e5bdafc 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -146,6 +146,9 @@ import { import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { planLog, formatError } from "./logger.js"; +// FNXC:PlanArtifactPersistence 2026-07-26-03:55: worktree-stranded plans are copied back into the project +// .fusion folder and mirrored into the project DB before finalization reads the spec. +import { mirrorPlanToProjectDb, persistPlanArtifact, relativePromptPath } from "./plan-artifact-writeback.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { isUsageLimitError, @@ -1549,7 +1552,9 @@ export class TriageProcessor { // planning-phase reads (requirePlanApproval, planning/validator model lanes) // pick up workflow values. Behavior-inert when nothing is customized. const settings = await mergeEffectiveSettings(this.store, currentTask, await this.store.getSettings()); - const promptPath = `.fusion/tasks/${task.id}/PROMPT.md`; + // FNXC:PlanArtifactPersistence 2026-07-26-03:55: one definition of the cwd-relative spec path, shared + // with the worktree write-back so the rescue reads exactly the path the planner was handed. + const promptPath = relativePromptPath(task.id); /* FNXC:PlanReview 2026-07-19-00:22 (U3): @@ -2087,6 +2092,31 @@ export class TriageProcessor { Workflow Plan Review is the single operator-controlled AI plan gate. Triage must not remind agents to call fn_review_spec or retry planning only because that legacy tool was not approved; after PROMPT.md is written, triage itself runs optional Plan Review before releasing the task to execution. */ + /* + FNXC:PlanArtifactPersistence 2026-07-26-03:55: + Planning ran with the coding tool surface inside the task worktree, so a planner that ignored + `fn_task_prompt_write` and used the generic write tool resolved the relative spec path against + the WORKTREE. Finalization reads `/`, so that spec would read as missing, + fail deterministic validation, and then be destroyed with the worktree. Copy any worktree-local + spec back into the project `.fusion/` folder BEFORE the finalize read, and mirror whatever is + authoritative into the project database (PROMPT.md has no `tasks` column and is otherwise + filesystem-only). Both halves are best-effort — validation below still owns the verdict. + */ + const planPersistence = await persistPlanArtifact({ + store: this.store, + taskId: task.id, + rootDir: this.rootDir, + planningCwd, + author: "triage", + logger: { log: (m: string) => planLog.log(m), warn: (m: string) => planLog.warn(m) }, + }); + if (planPersistence.outcome === "recovered") { + await this.store.logEntry( + task.id, + "Recovered the plan written inside the task worktree into the project .fusion folder", + ).catch(() => undefined); + } + const written = await readFile( join(this.rootDir, promptPath), "utf-8", @@ -3098,6 +3128,18 @@ export class TriageProcessor { } } + /* + FNXC:PlanArtifactPersistence 2026-07-26-03:55: + Finalization is where the ACCEPTED spec content is known (post hygiene rewrite), and it is the last + writer that touches the root PROMPT.md on a planning pass. Mirror it into the project database here so + the DB copy is the finalized plan, not the pre-hygiene draft. Identical content is skipped, so a pass + whose hygiene rewrite was a no-op produces exactly one document revision. + */ + await mirrorPlanToProjectDb(this.store, task.id, written, { + author: "triage", + logger: { warn: (m: string) => planLog.warn(m) }, + }); + let taskIntentSignature: ReturnType = { routePaths: [], filePaths: [],