Make reset a reliable description-only fresh start by fencing active planning work and cleaning discarded execution state. - Add reset lifecycle disposal for planning sessions, locks, artifacts, and reset worktrees. - Preserve operator-provided descriptions while clearing stale execution and planning projections. - Expose reset routes and add core, dashboard, and engine regression coverage. Files changed: .changeset/fn-151-task-reset-always-completes.md | 7 + docs/architecture.md | 3 + docs/dashboard-guide.md | 6 +- .../postgres/task-reset-publication.pg.test.ts | 71 +++++++++- .../core/src/__tests__/task-move-disposer.test.ts | 38 ++++++ packages/core/src/index.gate.ts | 2 + packages/core/src/index.ts | 2 + packages/core/src/task-store/reset-lifecycle.ts | 38 +++++- packages/core/src/tasks/task-move-disposer.ts | 36 +++++- .../src/__tests__/task-reset-lifecycle.test.ts | 110 +++++++++++++++- .../src/routes/register-task-workflow-routes.ts | 29 +++-- .../src/__tests__/agent-document-tools.test.ts | 49 +++++++ .../src/__tests__/reset-worktree-removal.test.ts | 77 +++++++++++ .../__tests__/triage-reset-planning-fence.test.ts | 118 +++++++++++++++++ packages/engine/src/agent-tools.ts | 60 +++++++-- packages/engine/src/agents/planning-liveness.ts | 19 +++ packages/engine/src/index.ts | 4 + packages/engine/src/plan-artifact-writeback.ts | 27 +++- packages/engine/src/planning-reset-fence.ts | 26 +++ packages/engine/src/triage.ts | 143 +++++++++++++++++++-- packages/engine/src/worktree/remove-reset-worktree.ts | 65 ++++++++++ 21 files changed, 882 insertions(+), 48 deletions(-) Fusion-Task-Id: FN-151 Fusion-Task-Lineage: 1c25d58e-2d23-4340-a489-51f3e2d8a7f4 Co-authored-by: Fusion <noreply@runfusion.ai>
197 lines
8.8 KiB
TypeScript
197 lines
8.8 KiB
TypeScript
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;
|
|
/**
|
|
* Optional caller-owned authoritative writer. Triage uses this to serialize a recovered
|
|
* worktree artifact with its reset-generation fence before it can recreate PROMPT.md.
|
|
*/
|
|
writeAuthoritativePrompt?: (content: string) => Promise<boolean>;
|
|
/** Optional caller-owned plan-document writer subject to the same publication fence. */
|
|
mirrorAuthoritativePlan?: (content: string, author?: string) => Promise<boolean>;
|
|
}
|
|
|
|
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"
|
|
/** A reset fenced this planning attempt before its worktree copy could be published. */
|
|
| "recovery-fenced";
|
|
|
|
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.
|
|
const persisted = options.writeAuthoritativePrompt
|
|
? await options.writeAuthoritativePrompt(worktreeContent)
|
|
: (await store.updateTask(taskId, { prompt: worktreeContent }), true);
|
|
if (!persisted) return { outcome: "recovery-fenced", content: rootContent ?? undefined };
|
|
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);
|
|
if (result.outcome === "recovery-fenced") return { ...result, mirrored: false };
|
|
const mirrored = result.content
|
|
? options.mirrorAuthoritativePlan
|
|
? await options.mirrorAuthoritativePlan(result.content, options.author)
|
|
: await mirrorPlanToProjectDb(options.store, options.taskId, result.content, {
|
|
author: options.author,
|
|
logger: options.logger,
|
|
})
|
|
: false;
|
|
return { ...result, mirrored };
|
|
}
|