fix(engine): save worktree-written plans to the project and the database

Planning sessions run in the task's own worktree with the coding tool
surface, but the spec path handed to the planner is relative
(.fusion/tasks/<id>/PROMPT.md) while finalization reads it against
rootDir. A planner using the generic write tool instead of
fn_task_prompt_write stranded the spec inside the worktree, where
finalization could not see it and worktree disposal destroyed it.

project.tasks also has no `prompt` column, so PROMPT.md was
filesystem-only and the project checkout was the sole durable copy of
every plan.

Add plan-artifact-writeback.ts:

- reconcileWorktreePlanArtifact copies a worktree-stranded PROMPT.md
  back into the main project .fusion folder through
  store.updateTask({ prompt }), keeping File Scope validation, the root
  write, and the task.json sync atomic. Empty, absent, and identical
  worktree copies are no-ops so a correct spec is never clobbered.
- mirrorPlanToProjectDb mirrors the authoritative plan into the `plan`
  task document, which triage already reads as a planning-draft
  fallback, making that recovery path DB-backed. Identical content is
  skipped so revisions do not churn.

Both are best-effort: a failure never turns a good planning pass into an
error. Wired at the reconcile-before-finalize-read seam, at finalization
with the post-hygiene accepted content, and inside fn_task_prompt_write.

Covers the invariant rather than the repro: tests assert worktree-
stranded, root-only, empty worktree file, absent file, identical
content, persistence failure, redundant-mirror skip, and mirror failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 20:46:07 -07:00
parent 20c5e1ff4e
commit 9f6aaa933d
5 changed files with 454 additions and 1 deletions

View File

@@ -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/<id>/PROMPT.md` against the worktree; triage finalization reads `<rootDir>/<promptPath>` 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`.

View File

@@ -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<typeof vi.fn>;
upsertTaskDocument: ReturnType<typeof vi.fn>;
getTaskDocument: ReturnType<typeof vi.fn>;
documents: Map<string, string>;
}
function createFakeStore(rootDir: string): FakeStore {
const documents = new Map<string, string>();
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<void> {
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();
});
});

View File

@@ -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: {},

View File

@@ -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/<id>/PROMPT.md`) against its own cwd, so the spec lands
INSIDE the worktree. Triage then finalizes by reading `<rootDir>/<promptPath>`, 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<string | null> {
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<ReconcileWorktreePlanArtifactResult> {
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<boolean> {
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<ReconcileWorktreePlanArtifactResult & { mirrored: boolean }> {
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 };
}

View File

@@ -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 `<rootDir>/<promptPath>`, 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<typeof extractIntentSignature> = {
routePaths: [],
filePaths: [],