diff --git a/.changeset/fn-203-workspace-task-reset.md b/.changeset/fn-203-workspace-task-reset.md new file mode 100644 index 0000000000..83164f8bdd --- /dev/null +++ b/.changeset/fn-203-workspace-task-reset.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Reset multi-repository tasks safely back to fresh planning. +category: feature +dev: Adds the core reset target planner, workspace-aware reset route cleanup, and publication-time workspace coordination cleanup. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 42951d4bf0..b854154d76 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -72,11 +72,11 @@ Both actions are irreversible; there is no undo after confirming. The dialog clo ## Task Reset - + -**Reset** is destructive and has no undo. After confirmation, Fusion fences executor and planner work without waiting for a planner that needs the reset-held planning lock. It removes only the task-owned standard worktree and current `.fusion/tasks//PROMPT.md` plan, then atomically returns the same task to its workflow's **Planning/intake** column with pending steps and `needs-replan`. +**Reset** is destructive and has no undo. After confirmation, Fusion fences executor and planner work without waiting for a planner that needs the reset-held planning lock. It removes the task-owned standard worktree, or every task-owned worktree for a workspace task, plus the current `.fusion/tasks//PROMPT.md` plan. For workspace tasks, Reset also removes the now-empty workspace task directory and clears workspace coordination leases and land intents before atomically returning the same task to its workflow's **Planning/intake** column with pending steps and `needs-replan`. -A stale self-owned session registration is reconciled only under the ordinary liveness and idle gates, then removal is retried once. A live planner, executor claim, or foreign holder is reported as an actionable conflict; Reset never forces deletion over live work. Workspace tasks and external/operator-owned, foreign, unsafe, or project-root worktrees remain unsupported. +A stale self-owned session registration is reconciled only under the ordinary liveness and idle gates, then removal is retried once. A live planner, executor claim, foreign holder, unsafe path, or unprovable repository ownership is reported as an actionable per-repository conflict; Reset never forces deletion over live work or publishes partial success. Per-repository task branches are intentionally retained, matching the single-repository Reset behavior. Reset retains the task ID, title, description, dependencies, workflow selection, comments, attachments, attachment-backed artifacts, operator-authored documents and their revisions, spec-lock history, commit associations, logs, and audit history. It clears agent-only documents and run-produced planning, verification, merge, and artifact projections; held symbol locks are released as history. If cancellation, cleanup, or publication fails, Fusion reports incomplete cleanup and does not expose the task to Planning. Once publication commits, a task-file mirror problem is repaired separately and does not turn the successful reset into a false failure. diff --git a/docs/workspaces.md b/docs/workspaces.md index a067142add..36b23aa6d9 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -119,6 +119,12 @@ There is an important route/helper distinction when auto-merge is off. `revertWo Archiving a workspace task synchronously removes every recorded member worktree. Fusion holds a per-repository reservation through disposal and branch cleanup. A failed removal is quarantined so a later acquisition can reconcile the orphan; successful siblings are released. `archiveTask(..., { cleanup: false })` intentionally retains worktrees, while self-healing remains a backstop. For the task lifecycle details, see [Workspace worktree cleanup on archive](./task-management.md#workspace-worktree-cleanup-on-archive). +## Task Reset + +Reset returns a workspace task to fresh planning. It fences active task runtime owners, removes every recorded per-repository worktree, removes the empty workspace task directory when possible, deletes the current plan, and clears the task's workspace coordination leases and land intents. It then publishes the same task back to its intake column with pending steps and `needs-replan`. + +Reset retains the task ID, title, description, dependencies, workflow selection, comments, attachments, operator-authored documents, logs, and per-repository branches. A live, foreign, unsafe, or unprovable repository is reported as an actionable per-repository conflict. Fusion does not publish a partial reset when any repository cannot be cleaned safely. + ## Limitations and known sharp edges - Landing is non-atomic. A later failure does not undo earlier local integration-ref advances; use task logs, per-repository history, and `landedSha` proof before retrying or manually recovering. diff --git a/packages/core/src/__tests__/postgres/task-reset-publication.pg.test.ts b/packages/core/src/__tests__/postgres/task-reset-publication.pg.test.ts index ccf3f100e3..153f073d43 100644 --- a/packages/core/src/__tests__/postgres/task-reset-publication.pg.test.ts +++ b/packages/core/src/__tests__/postgres/task-reset-publication.pg.test.ts @@ -79,6 +79,33 @@ pgDescribe("TaskStore reset publication", () => { expect(await store.hasWorkflowRunStepInstancesForTask(task.id)).toBe(false); }); + it("clears workspace worktrees, acquire leases, and land intents with publication", async () => { + const { store, task } = await seedPopulatedResetState(); + const now = new Date().toISOString(); + await store.updateTask(task.id, { + workspaceWorktrees: { api: { worktreePath: "/tmp/fn-reset/api", branch: "fusion/fn-reset" } }, + }); + const db = h.layer().db; + await db.insert(schema.project.workspaceCoordinationLeases).values({ + leaseKey: `${task.id}:api`, kind: "acquire", ownerTaskId: task.id, ownerNodeId: "execute", + ownerIncarnationId: "test", status: "held", acquiredAt: now, renewedAt: now, + expiresAt: new Date(Date.now() + 60_000).toISOString(), createdAt: now, updatedAt: now, + }); + await db.insert(schema.project.workspaceLandIntents).values({ + taskId: task.id, repoRelPath: "api", remoteUrl: "https://example.invalid/api.git", integrationRef: "main", + intendedSha: "a".repeat(40), expectedTip: "b".repeat(40), fenceRefName: "refs/fusion/test", + fenceRefSha: "c".repeat(40), ownerTaskId: task.id, ownerNodeId: "execute", ownerIncarnationId: "test", + fenceToken: 0n, status: "pending", createdAt: now, updatedAt: now, + }); + + const reset = await store.resetTaskPublication(task.id, "todo"); + + expect(reset).toMatchObject({ column: "todo", status: "needs-replan" }); + expect(reset.workspaceWorktrees).toBeUndefined(); + await expect(db.select().from(schema.project.workspaceCoordinationLeases).where(eq(schema.project.workspaceCoordinationLeases.ownerTaskId, task.id))).resolves.toEqual([]); + await expect(db.select().from(schema.project.workspaceLandIntents).where(eq(schema.project.workspaceLandIntents.taskId, task.id))).resolves.toEqual([]); + }); + it("clears run projections while retaining operator documents, attachments, and released symbol-lock history", async () => { const { store, task } = await seedPopulatedResetState(); const originalTitle = task.title; @@ -125,6 +152,18 @@ pgDescribe("TaskStore reset publication", () => { const now = new Date().toISOString(); await store.upsertTaskDocument(task.id, { key: "agent-only", content: "must survive rollback", author: "agent" }); await h.layer().db.insert(schema.project.artifacts).values({ id: `${task.id}-rollback-artifact`, type: "document", title: "rollback", authorId: "agent", taskId: task.id, createdAt: now, updatedAt: now }); + await store.updateTask(task.id, { workspaceWorktrees: { api: { worktreePath: "/tmp/fn-reset/api", branch: "fusion/fn-reset" } } }); + await h.layer().db.insert(schema.project.workspaceCoordinationLeases).values({ + leaseKey: `${task.id}:rollback`, kind: "acquire", ownerTaskId: task.id, ownerNodeId: "execute", + ownerIncarnationId: "test", status: "held", acquiredAt: now, renewedAt: now, + expiresAt: new Date(Date.now() + 60_000).toISOString(), createdAt: now, updatedAt: now, + }); + await h.layer().db.insert(schema.project.workspaceLandIntents).values({ + taskId: task.id, repoRelPath: "api", remoteUrl: "https://example.invalid/api.git", integrationRef: "main", + intendedSha: "a".repeat(40), expectedTip: "b".repeat(40), fenceRefName: "refs/fusion/test", + fenceRefSha: "c".repeat(40), ownerTaskId: task.id, ownerNodeId: "execute", ownerIncarnationId: "test", + fenceToken: 0n, status: "pending", createdAt: now, updatedAt: now, + }); const [beforeFailure] = await h.layer().db.select({ column: schema.project.tasks.column, status: schema.project.tasks.status, @@ -152,5 +191,7 @@ pgDescribe("TaskStore reset publication", () => { expect(await store.hasWorkflowRunStepInstancesForTask(task.id)).toBe(true); expect(await h.layer().db.select().from(schema.project.taskDocuments).where(eq(schema.project.taskDocuments.taskId, task.id))).toEqual([expect.objectContaining({ key: "agent-only" })]); expect(await h.layer().db.select().from(schema.project.artifacts).where(eq(schema.project.artifacts.taskId, task.id))).toEqual([expect.objectContaining({ id: `${task.id}-rollback-artifact` })]); + expect(await h.layer().db.select().from(schema.project.workspaceCoordinationLeases).where(eq(schema.project.workspaceCoordinationLeases.ownerTaskId, task.id))).toEqual([expect.objectContaining({ leaseKey: `${task.id}:rollback` })]); + expect(await h.layer().db.select().from(schema.project.workspaceLandIntents).where(eq(schema.project.workspaceLandIntents.taskId, task.id))).toEqual([expect.objectContaining({ repoRelPath: "api" })]); }); }); diff --git a/packages/core/src/__tests__/task-reset-targets.test.ts b/packages/core/src/__tests__/task-reset-targets.test.ts new file mode 100644 index 0000000000..66ca43f3f5 --- /dev/null +++ b/packages/core/src/__tests__/task-reset-targets.test.ts @@ -0,0 +1,134 @@ +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Task } from "../types.js"; +import { + buildTaskResetWorktreePlan, + SINGULAR_RESET_WORKTREE_REPO_REL, +} from "../tasks/task-reset-targets.js"; +import { resolveWorktreesDirLayout, resolveWorkspaceTaskWorktreeDir } from "../tasks/worktree-layout.js"; + +const rootDir = "/workspace"; +const taskId = "FN-203"; + +function task(overrides: Partial): Task { + return { + id: taskId, + description: "Reset test task", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + ...overrides, + } as Task; +} + +describe("buildTaskResetWorktreePlan", () => { + it("plans a singular task worktree using the project worktrees root", () => { + const worktreePath = join(rootDir, ".worktrees", "fn-203"); + const plan = buildTaskResetWorktreePlan(task({ worktree: worktreePath, branch: "fusion/fn-203" }), { rootDir, settings: {} }); + + expect(plan).toMatchObject({ kind: "singular", layout: "singular" }); + expect(plan.targets).toEqual([expect.objectContaining({ + repoRel: SINGULAR_RESET_WORKTREE_REPO_REL, + worktreePath, + canonicalPath: resolve(worktreePath), + branch: "fusion/fn-203", + repoRootDir: rootDir, + containmentRoot: join(rootDir, ".worktrees"), + reservationWorktreesDir: join(rootDir, ".worktrees"), + aliasRepoRels: [], + })]); + }); + + it("has no target for a singular task without a worktree", () => { + expect(buildTaskResetWorktreePlan(task({}), { rootDir, settings: {} }).targets).toEqual([]); + }); + + it("treats an empty workspace record as a singular task", () => { + const plan = buildTaskResetWorktreePlan(task({ workspaceWorktrees: {} }), { rootDir, settings: {} }); + expect(plan).toMatchObject({ kind: "singular", layout: "singular", targets: [] }); + }); + + it("plans every new-layout repository under one workspace task directory", () => { + const taskDir = resolveWorkspaceTaskWorktreeDir(rootDir, {}, taskId); + const plan = buildTaskResetWorktreePlan(task({ workspaceWorktrees: { + "apps/api": { worktreePath: join(taskDir, "apps/api"), branch: "fusion/fn-203" }, + "apps/web": { worktreePath: join(taskDir, "apps/web"), branch: "fusion/fn-203" }, + } }), { rootDir, settings: {} }); + + expect(plan).toMatchObject({ kind: "workspace", layout: "workspace-task-dir", workspaceTaskDir: taskDir }); + expect(plan.targets).toEqual([ + expect.objectContaining({ repoRel: "apps/api", repoRootDir: join(rootDir, "apps/api"), containmentRoot: taskDir, reservationWorktreesDir: join(rootDir, "apps/api/.worktrees") }), + expect.objectContaining({ repoRel: "apps/web", repoRootDir: join(rootDir, "apps/web"), containmentRoot: taskDir, reservationWorktreesDir: join(rootDir, "apps/web/.worktrees") }), + ]); + }); + + it("uses each repository worktrees directory as containment for legacy entries", () => { + const plan = buildTaskResetWorktreePlan(task({ workspaceWorktrees: { + api: { worktreePath: join(rootDir, "api/.worktrees/fn-203"), branch: "fusion/fn-203" }, + web: { worktreePath: join(rootDir, "web/.worktrees/fn-203"), branch: "fusion/fn-203" }, + } }), { rootDir, settings: {} }); + + expect(plan).toMatchObject({ kind: "workspace", layout: "workspace-legacy" }); + expect(plan.workspaceTaskDir).toBeUndefined(); + expect(plan.targets.map(({ containmentRoot, reservationWorktreesDir }) => [containmentRoot, reservationWorktreesDir])).toEqual([ + [join(rootDir, "api/.worktrees"), join(rootDir, "api/.worktrees")], + [join(rootDir, "web/.worktrees"), join(rootDir, "web/.worktrees")], + ]); + }); + + it("uses acquisition's configured per-repository reservation root", () => { + const settings = { worktreesDir: "/managed-worktrees" } as const; + const taskDir = resolveWorkspaceTaskWorktreeDir(rootDir, settings, taskId); + const plan = buildTaskResetWorktreePlan(task({ workspaceWorktrees: { + api: { worktreePath: join(taskDir, "api"), branch: "fusion/fn-203" }, + } }), { rootDir, settings }); + const target = plan.targets[0]!; + expect(target.reservationWorktreesDir).toBe(resolveWorktreesDirLayout(join(rootDir, "api"), settings, { workspaceRootDir: rootDir, repoRelPath: "api" })); + }); + + it("deduplicates canonical workspace paths and records aliases", () => { + const taskDir = resolveWorkspaceTaskWorktreeDir(rootDir, {}, taskId); + const sharedPath = join(taskDir, "api"); + const plan = buildTaskResetWorktreePlan(task({ workspaceWorktrees: { + api: { worktreePath: sharedPath, branch: "fusion/fn-203" }, + duplicate: { worktreePath: join(taskDir, "api/..", "api"), branch: "fusion/fn-203" }, + } }), { rootDir, settings: {} }); + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0]).toMatchObject({ repoRel: "api", aliasRepoRels: ["duplicate"] }); + }); + + it("folds a workspace singular pointer into an equal target", () => { + const taskDir = resolveWorkspaceTaskWorktreeDir(rootDir, {}, taskId); + const worktreePath = join(taskDir, "api"); + const plan = buildTaskResetWorktreePlan(task({ worktree: worktreePath, workspaceWorktrees: { + api: { worktreePath, branch: "fusion/fn-203" }, + } }), { rootDir, settings: {} }); + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0]?.aliasRepoRels).toEqual([SINGULAR_RESET_WORKTREE_REPO_REL]); + }); + + it("reports an unmatched workspace singular pointer without making it removable", () => { + const taskDir = resolveWorkspaceTaskWorktreeDir(rootDir, {}, taskId); + const ignored = join(rootDir, ".worktrees/stale-singular"); + const plan = buildTaskResetWorktreePlan(task({ worktree: ignored, workspaceWorktrees: { + api: { worktreePath: join(taskDir, "api"), branch: "fusion/fn-203" }, + } }), { rootDir, settings: {} }); + expect(plan.ignoredSingularWorktree).toBe(ignored); + expect(plan.targets.map((target) => target.canonicalPath)).not.toContain(resolve(ignored)); + }); + + it("preserves nested repository children under the workspace task directory", () => { + const taskDir = resolveWorkspaceTaskWorktreeDir(rootDir, {}, taskId); + const plan = buildTaskResetWorktreePlan(task({ workspaceWorktrees: { + "apps/web": { worktreePath: join(taskDir, "apps/web"), branch: "fusion/fn-203" }, + } }), { rootDir, settings: {} }); + expect(plan.targets[0]).toMatchObject({ repoRootDir: join(rootDir, "apps/web"), canonicalPath: join(taskDir, "apps/web") }); + }); + + it("rejects a repository path that escapes the workspace root", () => { + expect(() => buildTaskResetWorktreePlan(task({ workspaceWorktrees: { + "../escape": { worktreePath: "/escape/.worktrees/fn-203", branch: "fusion/fn-203" }, + } }), { rootDir, settings: {} })).toThrow(/escapes workspace root/); + }); +}); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index f35d4811e9..45c84cac5b 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -2469,6 +2469,8 @@ export { isLegacyWorkspaceWorktreeLayout, } from "./tasks/worktree-layout.js"; export type { WorkspaceWorktreeContext } from "./tasks/worktree-layout.js"; +export { buildTaskResetWorktreePlan, SINGULAR_RESET_WORKTREE_REPO_REL } from "./tasks/task-reset-targets.js"; +export type { BuildTaskResetWorktreePlanOptions, TaskResetWorktreePlan, TaskResetWorktreeTarget } from "./tasks/task-reset-targets.js"; /* FNXC:WorkflowStepResults 2026-07-19-01:00: Keep this gate-safe barrel's workflow-step-results re-exports in SYNC with the main barrel (index.ts). The `engine-core` vitest project builds its @fusion/core from THIS file (scripts/build-engine-core-gate-bundle.mjs), so any lease/step-result export present in index.ts but missing here resolves to `undefined` ONLY under engine-core — which is exactly how U3's `classifyReviewLease` went missing and threw "classifyReviewLease is not a function" on every defaultOn Plan Review run in that project (caught by task-pipeline-smoke). When adding an export to the index.ts workflow-step-results block, add it here too. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1c4d97da57..ff8d19f298 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3044,6 +3044,8 @@ export { isLegacyWorkspaceWorktreeLayout, } from "./tasks/worktree-layout.js"; export type { WorkspaceWorktreeContext } from "./tasks/worktree-layout.js"; +export { buildTaskResetWorktreePlan, SINGULAR_RESET_WORKTREE_REPO_REL } from "./tasks/task-reset-targets.js"; +export type { BuildTaskResetWorktreePlanOptions, TaskResetWorktreePlan, TaskResetWorktreeTarget } from "./tasks/task-reset-targets.js"; export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js"; export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js"; export { appendAgentActivityEvent, queryAgentActivityEvents, getMaxAgentActivitySeq, pruneAgentActivityEvents } from "./task-store/async/async-agent-activity.js"; diff --git a/packages/core/src/task-store/reset-lifecycle.ts b/packages/core/src/task-store/reset-lifecycle.ts index 58c2e93922..698eaa22ad 100644 --- a/packages/core/src/task-store/reset-lifecycle.ts +++ b/packages/core/src/task-store/reset-lifecycle.ts @@ -126,6 +126,7 @@ function assertResetTask(task: Task, intakeColumn: ColumnId): void { task.worktree != null || task.branch != null || task.sessionFile != null || task.checkedOutBy != null || task.workflowIrPin != null || task.workflowStepResults?.length || task.review != null || task.reviewState != null || task.awaitingApprovalReason != null + || Object.keys(task.workspaceWorktrees ?? {}).length > 0 ) { throw new Error("Reset publication returned stale execution or review state"); } @@ -195,6 +196,21 @@ export async function resetTaskPublicationImpl( await tx.delete(schema.project.completionHandoffMarkers).where(and(projectScopeFor(schema.project.completionHandoffMarkers.projectId, projectId), eq(schema.project.completionHandoffMarkers.taskId, taskId))); await tx.delete(schema.project.mergeQueue).where(and(projectScopeFor(schema.project.mergeQueue.projectId, projectId), eq(schema.project.mergeQueue.taskId, taskId))); await tx.delete(schema.project.mergeRequests).where(and(projectScopeFor(schema.project.mergeRequests.projectId, projectId), eq(schema.project.mergeRequests.taskId, taskId))); + /* + FNXC:TaskReset 2026-08-27-22:20: + Reset clears workspace acquire leases and land intents in the same publication transaction. + A retained acquire lease would make the next dispatch raise WorkspaceRepoAcquireBusyError until + TTL expiry, while a retained pending land intent could revive partial-land recovery for worktrees + the reset no longer owns. + */ + await tx.delete(schema.project.workspaceLandIntents).where(and( + projectScopeFor(schema.project.workspaceLandIntents.projectId, projectId), + eq(schema.project.workspaceLandIntents.taskId, taskId), + )); + await tx.delete(schema.project.workspaceCoordinationLeases).where(and( + projectScopeFor(schema.project.workspaceCoordinationLeases.projectId, projectId), + eq(schema.project.workspaceCoordinationLeases.ownerTaskId, taskId), + )); await tx.delete(schema.project.artifacts).where(and( projectScopeFor(schema.project.artifacts.projectId, projectId), eq(schema.project.artifacts.taskId, taskId), sql`coalesce(${schema.project.artifacts.metadata}->>'source', '') <> 'attachment'`, diff --git a/packages/core/src/tasks/task-reset-targets.ts b/packages/core/src/tasks/task-reset-targets.ts new file mode 100644 index 0000000000..45ae79f6ab --- /dev/null +++ b/packages/core/src/tasks/task-reset-targets.ts @@ -0,0 +1,103 @@ +import { join, resolve } from "node:path"; +import { isWorkspaceTask, type Settings, type Task } from "../types.js"; +import { + assertWorkspaceRepoRelPath, + isLegacyWorkspaceWorktreeLayout, + resolveWorktreesDirLayout, + resolveWorkspaceTaskWorktreeDir, +} from "./worktree-layout.js"; + +export const SINGULAR_RESET_WORKTREE_REPO_REL = "__singular_worktree__"; + +export interface TaskResetWorktreeTarget { + repoRel: string; + worktreePath: string; + canonicalPath: string; + branch?: string; + repoRootDir: string; + containmentRoot: string; + reservationWorktreesDir: string; + aliasRepoRels: string[]; +} + +export interface TaskResetWorktreePlan { + kind: "singular" | "workspace"; + layout: "singular" | "workspace-task-dir" | "workspace-legacy"; + targets: TaskResetWorktreeTarget[]; + workspaceTaskDir?: string; + ignoredSingularWorktree?: string; +} + +export interface BuildTaskResetWorktreePlanOptions { + rootDir: string; + settings: Pick | undefined; +} + +/* +FNXC:TaskReset 2026-08-27-22:02: +Reset derives its target directories and reservation roots from the same workspace layout +math as acquisition, so a per-repository reset reservation excludes a concurrent acquire. +A workspace task's singular pointer is ignored unless it aliases a recorded repository child: +the workspace root is a coordinator, not a disposable Git worktree. +*/ +export function buildTaskResetWorktreePlan( + task: Pick, + { rootDir, settings }: BuildTaskResetWorktreePlanOptions, +): TaskResetWorktreePlan { + if (!isWorkspaceTask(task)) { + const worktreesDir = resolveWorktreesDirLayout(rootDir, settings); + const targets = task.worktree ? [{ + repoRel: SINGULAR_RESET_WORKTREE_REPO_REL, + worktreePath: task.worktree, + canonicalPath: resolve(task.worktree), + branch: task.branch, + repoRootDir: rootDir, + containmentRoot: worktreesDir, + reservationWorktreesDir: worktreesDir, + aliasRepoRels: [], + }] : []; + return { kind: "singular", layout: "singular", targets }; + } + + const workspaceTaskDir = resolveWorkspaceTaskWorktreeDir(rootDir, settings, task.id); + const legacy = isLegacyWorkspaceWorktreeLayout(task, workspaceTaskDir); + const targetsByCanonical = new Map(); + for (const [repoRel, entry] of Object.entries(task.workspaceWorktrees ?? {}).sort(([left], [right]) => left.localeCompare(right))) { + assertWorkspaceRepoRelPath(repoRel); + const repoRootDir = join(rootDir, repoRel); + const reservationWorktreesDir = resolveWorktreesDirLayout(repoRootDir, settings, { + workspaceRootDir: rootDir, + repoRelPath: repoRel, + }); + const canonicalPath = resolve(entry.worktreePath); + const existing = targetsByCanonical.get(canonicalPath); + if (existing) { + existing.aliasRepoRels.push(repoRel); + continue; + } + targetsByCanonical.set(canonicalPath, { + repoRel, + worktreePath: entry.worktreePath, + canonicalPath, + branch: entry.branch, + repoRootDir, + containmentRoot: legacy ? reservationWorktreesDir : workspaceTaskDir, + reservationWorktreesDir, + aliasRepoRels: [], + }); + } + + let ignoredSingularWorktree: string | undefined; + if (task.worktree) { + const existing = targetsByCanonical.get(resolve(task.worktree)); + if (existing) existing.aliasRepoRels.push(SINGULAR_RESET_WORKTREE_REPO_REL); + else ignoredSingularWorktree = task.worktree; + } + return { + kind: "workspace", + layout: legacy ? "workspace-legacy" : "workspace-task-dir", + targets: [...targetsByCanonical.values()], + ...(legacy ? {} : { workspaceTaskDir }), + ...(ignoredSingularWorktree ? { ignoredSingularWorktree } : {}), + }; +} diff --git a/packages/dashboard/src/__tests__/task-reset-workspace-lifecycle.test.ts b/packages/dashboard/src/__tests__/task-reset-workspace-lifecycle.test.ts new file mode 100644 index 0000000000..3b28c6396d --- /dev/null +++ b/packages/dashboard/src/__tests__/task-reset-workspace-lifecycle.test.ts @@ -0,0 +1,306 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { Task, TaskStore } from "@fusion/core"; +import { registerTaskResetDisposer } from "@fusion/core"; +import { + ActiveSessionWorktreeRemovalError, + activeSessionRegistry, + getRegisteredWorktreeBranches, + pruneWorktreeAdminEntries, + removeTaskResetWorktree, + ResetWorktreeForeignSessionError, + registerPlanningLivenessProbe, +} from "@fusion/engine"; +import { createApiRoutes } from "../routes.js"; +import { request as performRequest } from "../test-request.js"; + +vi.mock("@fusion/engine", async () => { + const actual = await vi.importActual("@fusion/engine"); + return { + ...actual, + removeTaskResetWorktree: vi.fn(async (input: Parameters[0]) => await actual.removeTaskResetWorktree({ + ...input, + remove: async ({ worktreePath }) => { + await rm(worktreePath, { recursive: true, force: true }); + return { removed: true, classification: "removed" }; + }, + })), + pruneWorktreeAdminEntries: vi.fn().mockResolvedValue(undefined), + getRegisteredWorktreeBranches: vi.fn().mockResolvedValue([]), + }; +}); + +const WORKFLOW_IR = { + version: "v2", + name: "Workspace reset test workflow", + columns: [{ id: "triage", name: "Planning", traits: [{ trait: "intake" }] }], + nodes: [{ id: "start", kind: "start", column: "triage" }], + edges: [], +}; + +function workspaceTask(root: string, legacy = false): Task { + const taskDir = join(root, ".fusion", "worktrees", "fn-401"); + const worktree = (repo: string) => legacy + ? join(root, repo, ".worktrees", "fn-401") + : join(taskDir, repo); + return { + id: "FN-401", title: "Workspace reset", description: "A workspace task", column: "in-progress", status: "failed", + dependencies: [], steps: [{ name: "Implement", status: "done" }], currentStep: 0, + workspaceWorktrees: { + "apps/a": { worktreePath: worktree("apps/a"), branch: "fusion/fn-401" }, + "apps/b": { worktreePath: worktree("apps/b"), branch: "fusion/fn-401" }, + }, + log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } as unknown as Task; +} + +async function createWorkspace(root: string, task: Task): Promise { + for (const entry of Object.values(task.workspaceWorktrees ?? {})) await mkdir(entry.worktreePath, { recursive: true }); + await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true }); + await writeFile(join(root, ".fusion", "tasks", task.id, "PROMPT.md"), "# Discarded plan\n"); +} + +function createApp(store: TaskStore) { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + return app; +} + +function createStore(root: string, initialTask: Task, otherTasks: Task[] = []) { + let currentTask = initialTask; + const publication = vi.fn(async (_id: string, intake: string) => ({ + ...currentTask, + column: intake, + status: "needs-replan", + worktree: undefined, + workspaceWorktrees: undefined, + branch: undefined, + steps: currentTask.steps.map((step) => ({ ...step, status: "pending" as const })), + })); + const store = { + getRootDir: vi.fn().mockReturnValue(root), + getSettings: vi.fn().mockResolvedValue({}), + getTask: vi.fn(async () => currentTask), + listTasks: vi.fn(async () => [currentTask, ...otherTasks]), + withPlanningLifecycleLock: vi.fn(async (_id: string, callback: () => Promise) => await callback()), + getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "wf-reset" }), + getWorkflowDefinition: vi.fn().mockResolvedValue({ id: "wf-reset", name: "Reset", ir: WORKFLOW_IR }), + resetTaskPublication: publication, + logEntry: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), off: vi.fn(), getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]), + } as unknown as TaskStore; + return { + store, + publication, + setTask: (task: Task) => { currentTask = task; }, + }; +} + +function registerWorkspaceBranches(root: string, task: Task) { + vi.mocked(getRegisteredWorktreeBranches).mockImplementation(async (repoRoot) => { + const repoRel = repoRoot.slice(root.length + 1); + const entry = task.workspaceWorktrees?.[repoRel]; + return entry ? [{ branch: entry.branch, worktreePath: entry.worktreePath }] : []; + }); +} + +async function reset(store: TaskStore) { + return performRequest(createApp(store), "POST", "/api/tasks/FN-401/reset", JSON.stringify({ confirm: true }), { "content-type": "application/json" }); +} + +describe("POST /api/tasks/:id/reset workspace lifecycle", () => { + afterEach(() => { + vi.restoreAllMocks(); + activeSessionRegistry.unregisterPath(""); + }); + + it("reproduces the former workspace refusal shape and publishes a clean reset", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + const { store, publication } = createStore(root, task); + + const res = await reset(store); + + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).not.toContain("does not support workspace tasks"); + expect(publication).toHaveBeenCalledOnce(); + expect(publication).toHaveBeenCalledWith("FN-401", "triage"); + for (const entry of Object.values(task.workspaceWorktrees ?? {})) await expect(stat(entry.worktreePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(root, ".fusion", "tasks", task.id, "PROMPT.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("removes an empty new-layout task directory but retains a non-empty one", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-dir-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + const { store } = createStore(root, task); + const taskDir = join(root, ".fusion", "worktrees", "fn-401"); + expect((await reset(store)).status).toBe(200); + expect(existsSync(taskDir)).toBe(false); + + const retainedRoot = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-retained-")); + const retainedTask = workspaceTask(retainedRoot); + await createWorkspace(retainedRoot, retainedTask); + const retainedDir = join(retainedRoot, ".fusion", "worktrees", "fn-401"); + await writeFile(join(retainedDir, "operator-file"), "keep"); + registerWorkspaceBranches(retainedRoot, retainedTask); + const retained = createStore(retainedRoot, retainedTask); + expect((await reset(retained.store)).status).toBe(200); + expect(existsSync(retainedDir)).toBe(true); + }); + + it("removes legacy worktrees using their repository roots", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-legacy-")); + const task = workspaceTask(root, true); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + const { store } = createStore(root, task); + expect((await reset(store)).status).toBe(200); + expect(vi.mocked(removeTaskResetWorktree).mock.calls.slice(-2).map(([input]) => input.rootDir)).toEqual([join(root, "apps/a"), join(root, "apps/b")]); + expect(existsSync(join(root, ".fusion", "worktrees", "fn-401"))).toBe(false); + }); + + it("refuses a live first repository before touching the second repository or prompt", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-live-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + const first = task.workspaceWorktrees!["apps/a"]!.worktreePath; + activeSessionRegistry.registerPath(first, { taskId: task.id, kind: "planning", ownerKey: `planning:${task.id}` }); + (activeSessionRegistry.lookupByPath(first) as { registeredAt: number }).registeredAt = 0; + const unregisterProbe = registerPlanningLivenessProbe((id) => id === task.id); + try { + const { store, publication } = createStore(root, task); + const res = await reset(store); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/apps\/a/); + expect(publication).not.toHaveBeenCalled(); + expect(existsSync(task.workspaceWorktrees!["apps/b"]!.worktreePath)).toBe(true); + await expect(readFile(join(root, ".fusion", "tasks", task.id, "PROMPT.md"), "utf8")).resolves.toContain("Discarded"); + } finally { + unregisterProbe(); + activeSessionRegistry.unregisterPath(first); + } + }); + + it("reports a foreign session holder with its repository", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-foreign-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + vi.mocked(removeTaskResetWorktree).mockRejectedValueOnce(new ResetWorktreeForeignSessionError({ worktreePath: task.workspaceWorktrees!["apps/a"]!.worktreePath, holderTaskId: "FN-OTHER", holderKind: "executor" })); + const { store } = createStore(root, task); + const res = await reset(store); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/active task FN-OTHER.*apps\/a/i); + }); + + it("refuses an unregistered repository before cancellation", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-unregistered-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + vi.mocked(getRegisteredWorktreeBranches).mockResolvedValueOnce([]); + const { store, publication } = createStore(root, task); + const res = await reset(store); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/managed task ownership cannot be proven/); + expect(publication).not.toHaveBeenCalled(); + await expect(readFile(join(root, ".fusion", "tasks", task.id, "PROMPT.md"), "utf8")).resolves.toContain("Discarded"); + }); + + it("refuses external and repository-root workspace targets before cleanup", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-unsafe-")); + const external = workspaceTask(root); + external.workspaceWorktrees!["apps/a"]!.worktreePath = join(root, "outside"); + await createWorkspace(root, external); + registerWorkspaceBranches(root, external); + let scenario = createStore(root, external); + expect((await reset(scenario.store)).status).toBe(400); + + const rootTarget = workspaceTask(root); + rootTarget.workspaceWorktrees!["apps/a"]!.worktreePath = join(root, "apps/a"); + await createWorkspace(root, rootTarget); + registerWorkspaceBranches(root, rootTarget); + scenario = createStore(root, rootTarget); + expect((await reset(scenario.store)).status).toBe(400); + }); + + it("refuses a target claimed by another task's workspace entry", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-owner-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + const owner = { ...task, id: "FN-OTHER", workspaceWorktrees: { "apps/a": task.workspaceWorktrees!["apps/a"] } }; + const { store } = createStore(root, task, [owner]); + const res = await reset(store); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/owned by another task.*apps\/a/i); + }); + + it("refuses publication when the reset disposer changes workspace targets", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-fence-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + const state = createStore(root, task); + const unregister = registerTaskResetDisposer(state.store, async () => state.setTask({ ...task, workspaceWorktrees: {} })); + try { + const res = await reset(state.store); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/target changed while cancellation was settling/); + expect(state.publication).not.toHaveBeenCalled(); + } finally { + unregister(); + } + }); + + it("refuses incomplete removal and reconciles an already-absent repository", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-incomplete-")); + const task = workspaceTask(root); + await createWorkspace(root, task); + registerWorkspaceBranches(root, task); + vi.mocked(removeTaskResetWorktree).mockResolvedValueOnce({ removed: false, classification: "already-absent" }); + const state = createStore(root, task); + expect((await reset(state.store)).status).toBe(409); + expect(state.publication).not.toHaveBeenCalled(); + + vi.mocked(removeTaskResetWorktree).mockReset(); + vi.mocked(removeTaskResetWorktree).mockImplementation(async (input) => { + await rm(input.worktreePath, { recursive: true, force: true }); + return { removed: true, classification: "removed" }; + }); + const absentRoot = await mkdtemp(join(tmpdir(), "fusion-workspace-reset-absent-")); + const absent = workspaceTask(absentRoot); + await createWorkspace(absentRoot, absent); + await rm(absent.workspaceWorktrees!["apps/a"]!.worktreePath, { recursive: true }); + registerWorkspaceBranches(absentRoot, absent); + const absentState = createStore(absentRoot, absent); + expect((await reset(absentState.store)).status).toBe(200); + expect(vi.mocked(pruneWorktreeAdminEntries)).toHaveBeenCalledWith(expect.objectContaining({ rootDir: join(absentRoot, "apps/a") })); + }); + + it("retains singular reset behavior with the project root", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-reset-singular-")); + const worktreePath = join(root, ".worktrees", "fn-401"); + const task = { + ...workspaceTask(root), worktree: worktreePath, branch: "fusion/fn-401", workspaceWorktrees: undefined, + } as Task; + await mkdir(worktreePath, { recursive: true }); + await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true }); + await writeFile(join(root, ".fusion", "tasks", task.id, "PROMPT.md"), "# Discarded plan\n"); + vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([{ branch: task.branch!, worktreePath }]); + const { store } = createStore(root, task); + expect((await reset(store)).status).toBe(200); + expect(vi.mocked(removeTaskResetWorktree)).toHaveBeenCalledWith(expect.objectContaining({ rootDir: root })); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index be831d108e..99254fbd40 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -13,8 +13,8 @@ const AWAITING_PLANNING_ENRICH_LIMIT = 200; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { existsSync } from "node:fs"; -import { readFile, rm, stat, realpath } from "node:fs/promises"; -import { join } from "node:path"; +import { readFile, rm, rmdir, stat, realpath } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { TaskStore, Task, @@ -87,6 +87,8 @@ import { canonicalizeWorktreePath, acquireWorktreePathReservation, disposeTaskBeforeReset, + buildTaskResetWorktreePlan, + SINGULAR_RESET_WORKTREE_REPO_REL, type NearDuplicateCandidate, type ThinkingLevel, } from "@fusion/core"; @@ -121,7 +123,6 @@ import { getRegisteredWorktreeBranches, pruneWorktreeAdminEntries, isInsideConfiguredWorktreesDir, - resolveWorktreesDir, resumeApprovedPlanReviewHandoff, type ApprovedPlanReviewHandoffResult, type AiUndoTaskResult, @@ -3740,46 +3741,69 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork const updated = await scopedStore.withPlanningLifecycleLock(req.params.id, async () => { const task = await scopedStore.getTask(req.params.id); if (!task) throw notFound(`Task ${req.params.id} not found`); - if (task.workspaceWorktrees && Object.keys(task.workspaceWorktrees).length > 0) { - throw conflict("Reset does not support workspace tasks; no cancellation or cleanup was started"); - } const intakeColumn = await resolveIntakeColumnForTask(scopedStore, task.id); const settings = await scopedStore.getSettings(); const rootDir = scopedStore.getRootDir(); - const worktreePath = task.worktree ? await canonicalizeWorktreePath(task.worktree) : undefined; - let reservation: Awaited> | undefined; + const resetPlan = buildTaskResetWorktreePlan(task, { rootDir, settings }); + const reservations: Awaited>[] = []; + const targetSuffix = (repoRel: string) => repoRel === SINGULAR_RESET_WORKTREE_REPO_REL ? "" : ` (${repoRel})`; + const isStrictDescendant = (root: string, candidate: string) => { + const pathRelative = relative(resolve(root), resolve(candidate)); + return pathRelative !== "" && !pathRelative.startsWith("..") && !isAbsolute(pathRelative); + }; + const targetPaths = (plan: ReturnType) => plan.targets + .map((target) => target.canonicalPath) + .sort(); - if (worktreePath) { - const canonicalRoot = await canonicalizeWorktreePath(rootDir); - if ( - worktreePath === canonicalRoot - || !isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath) - ) { - throw badRequest("Reset refuses an external, unsafe, foreign, or project-root worktree path"); - } - if (existsSync(worktreePath)) { - const resolvedPath = await realpath(worktreePath); - if (!isInsideConfiguredWorktreesDir(rootDir, settings, resolvedPath)) { - throw badRequest("Reset refuses an unsafe worktree path outside the configured worktree root"); + try { + // FNXC:TaskReset 2026-08-27-22:20: Validate every target before cancellation so a later repository cannot leave an earlier one half-reset. + for (const target of resetPlan.targets) { + const canonicalRoot = await canonicalizeWorktreePath(rootDir); + const canonicalRepoRoot = await canonicalizeWorktreePath(target.repoRootDir); + const canonicalContainmentRoot = await canonicalizeWorktreePath(target.containmentRoot); + const workspaceContext = resetPlan.layout === "workspace-legacy" + ? { workspaceRootDir: rootDir, repoRelPath: target.repoRel } + : undefined; + const contained = resetPlan.layout === "workspace-task-dir" + ? isStrictDescendant(target.containmentRoot, target.canonicalPath) + : isInsideConfiguredWorktreesDir(target.repoRootDir, settings, target.canonicalPath, workspaceContext); + if ( + target.canonicalPath === canonicalRoot + || target.canonicalPath === canonicalRepoRoot + || target.canonicalPath === canonicalContainmentRoot + || !contained + ) { + throw badRequest("Reset refuses an external, unsafe, foreign, or project-root worktree path"); } - } - /* - FNXC:TaskReset 2026-08-19-07:05: - A path under `.worktrees` is only disposable when Git's managed registration identifies it as the task's stored branch. Directory placement and an absent competing task row are not ownership proof, so a foreign/operator checkout fails closed before cancellation, reservation, or deletion. - */ - const registeredBranches = await getRegisteredWorktreeBranches(rootDir); - const taskBranch = typeof task.branch === "string" ? task.branch.trim() : ""; - let registeredOwner = false; - if (taskBranch.length > 0) { - for (const entry of registeredBranches) { - if (entry.branch === taskBranch && await canonicalizeWorktreePath(entry.worktreePath) === worktreePath) { - registeredOwner = true; - break; + if (existsSync(target.canonicalPath)) { + const resolvedPath = await realpath(target.canonicalPath); + const resolvedContained = resetPlan.layout === "workspace-task-dir" + ? isStrictDescendant(target.containmentRoot, resolvedPath) + : isInsideConfiguredWorktreesDir(target.repoRootDir, settings, resolvedPath, workspaceContext); + if (!resolvedContained) { + throw badRequest("Reset refuses an unsafe worktree path outside the configured worktree root"); } } - } - if (!registeredOwner) { - throw conflict("Reset refuses a worktree whose managed task ownership cannot be proven"); + /* + FNXC:TaskReset 2026-08-27-22:20: + Every workspace child is disposable only when its own repository's Git registration + identifies its stored branch. Placement and absent competing task rows are not ownership + proof, so each repository fails closed before cancellation, reservation, or deletion. + */ + const registeredBranches = await getRegisteredWorktreeBranches(target.repoRootDir); + const targetBranch = typeof target.branch === "string" ? target.branch.trim() : ""; + let registeredOwner = false; + if (targetBranch.length > 0) { + for (const entry of registeredBranches) { + if (entry.branch === targetBranch && await canonicalizeWorktreePath(entry.worktreePath) === target.canonicalPath) { + registeredOwner = true; + break; + } + } + } + if (!registeredOwner) { + throw conflict(`Reset refuses a worktree whose managed task ownership cannot be proven${targetSuffix(target.repoRel)}`); + } } const listTasks = (scopedStore as TaskStore & { @@ -3788,63 +3812,104 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (typeof listTasks === "function") { const otherOwners = await listTasks.call(scopedStore, { includeArchived: true, slim: true }); for (const candidate of otherOwners) { - if (candidate.id === task.id || !candidate.worktree) continue; - if (await canonicalizeWorktreePath(candidate.worktree) === worktreePath) { - throw conflict("Reset refuses a worktree path owned by another task"); + if (candidate.id === task.id) continue; + const candidatePaths = [candidate.worktree, ...Object.values(candidate.workspaceWorktrees ?? {}).map((entry) => entry.worktreePath)] + .filter((path): path is string => typeof path === "string"); + for (const candidatePath of candidatePaths) { + const canonicalCandidatePath = await canonicalizeWorktreePath(candidatePath); + const target = resetPlan.targets.find((entry) => entry.canonicalPath === canonicalCandidatePath); + if (target) throw conflict(`Reset refuses a worktree path owned by another task${targetSuffix(target.repoRel)}`); } } } - const worktreesDir = resolveWorktreesDir(rootDir, settings); - reservation = await acquireWorktreePathReservation({ - canonicalPath: worktreePath, - worktreesDir, - rootDir, - }); - } - try { + for (const target of resetPlan.targets) { + reservations.push(await acquireWorktreePathReservation({ + canonicalPath: target.canonicalPath, + worktreesDir: target.reservationWorktreesDir, + rootDir: target.repoRootDir, + })); + } + /* - FNXC:TaskReset 2026-08-19-06:30: - Reset ordering is deliberately validate/reserve → await the runtime cancellation fence → confirm the stored target → remove the configured worktree or reconcile confirmed absence → delete only PROMPT.md → finalize runtime bindings → atomically publish intake/needs-replan. No durable reset field or success signal is written before both filesystem artifacts are absent. + FNXC:TaskReset 2026-08-27-22:20: + Reset validates and reserves every target, fences runtime work, confirms the target set, + then removes or reconciles every repository before deleting PROMPT.md and publishing. + A target failure aborts the whole reset with a repository-specific conflict; no partial + filesystem cleanup is represented as a durable fresh-planning success. */ await disposeTaskBeforeReset(scopedStore, task); const fencedTask = await scopedStore.getTask(req.params.id); if (!fencedTask) throw notFound(`Task ${req.params.id} disappeared during reset`); - const fencedPath = fencedTask.worktree ? await canonicalizeWorktreePath(fencedTask.worktree) : undefined; - if (fencedPath !== worktreePath) { + const fencedPlan = buildTaskResetWorktreePlan(fencedTask, { rootDir, settings }); + if (JSON.stringify(targetPaths(fencedPlan)) !== JSON.stringify(targetPaths(resetPlan))) { throw conflict("Reset target changed while cancellation was settling; retry Reset"); } - if (worktreePath) { - if (existsSync(worktreePath)) { - /* - FNXC:TaskReset 2026-08-22-04:32: - Reset has fenced planner and executor owners while holding the planning lock. The helper only reconciles proven-stale self-owned registrations under the normal staleness gates; it never forces a live session. - */ + for (const target of resetPlan.targets) { + if (existsSync(target.canonicalPath)) { let removal; try { - removal = await removeTaskResetWorktree({ worktreePath, rootDir, settings, taskId: req.params.id }); + removal = await removeTaskResetWorktree({ + worktreePath: target.canonicalPath, + rootDir: target.repoRootDir, + settings, + taskId: req.params.id, + }); } catch (error) { if (error instanceof ResetWorktreeForeignSessionError || error instanceof ActiveSessionWorktreeRemovalError) { const message = error instanceof ResetWorktreeForeignSessionError ? `Reset is blocked by active task ${error.details.holderTaskId} (${error.details.holderKind}); stop or finish it before retrying Reset` : `Reset is blocked by active task ${error.details.taskId} (${error.details.kind}); stop or finish it before retrying Reset`; - throw conflict(message); + throw conflict(`${message}${targetSuffix(target.repoRel)}`); } throw error; } - if (!removal.removed && existsSync(worktreePath)) { - throw conflict(`Reset incomplete; worktree removal failed for ${req.params.id}`); + if (!removal.removed && existsSync(target.canonicalPath)) { + throw conflict(`Reset incomplete; worktree removal failed for ${req.params.id}${targetSuffix(target.repoRel)}`); } } else { - // The pointer is retained for retry safety, but the path is already absent. - await pruneWorktreeAdminEntries({ rootDir, reason: "task-reset-already-absent", target: worktreePath }); + await pruneWorktreeAdminEntries({ + rootDir: target.repoRootDir, + reason: "task-reset-already-absent", + target: target.canonicalPath, + }); } - if (existsSync(worktreePath)) { - throw conflict(`Reset incomplete; worktree remains for ${req.params.id}`); + if (existsSync(target.canonicalPath)) { + throw conflict(`Reset incomplete; worktree remains for ${req.params.id}${targetSuffix(target.repoRel)}`); } } + if (resetPlan.workspaceTaskDir) { + // FNXC:TaskReset 2026-08-27-22:20: Nested repository paths leave empty parents that Reset removes only one directory at a time before attempting the task directory. + for (const target of resetPlan.targets) { + let emptyParent = dirname(target.canonicalPath); + while (isStrictDescendant(resetPlan.workspaceTaskDir, emptyParent)) { + try { + await rmdir(emptyParent); + } catch { + break; + } + emptyParent = dirname(emptyParent); + } + } + try { + await rmdir(resetPlan.workspaceTaskDir); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOTEMPTY" && code !== "ENOENT") { + severityAuditLog.warn("task-reset workspace task directory removal failed", { + taskId: req.params.id, + workspaceTaskDir: resetPlan.workspaceTaskDir, + error: String(error), + }); + } + } + } + if (resetPlan.ignoredSingularWorktree) { + await scopedStore.logEntry(req.params.id, `Reset ignored unmatched workspace singular worktree pointer: ${resetPlan.ignoredSingularWorktree}`); + } + const promptPath = join(rootDir, ".fusion", "tasks", req.params.id, "PROMPT.md"); try { await rm(promptPath, { force: true }); @@ -3874,11 +3939,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork */ return storeWithPublisher.resetTaskPublication(req.params.id, intakeColumn); } finally { - if (reservation?.state === "held") { + for (const reservation of reservations) { + if (reservation.state !== "held") continue; try { await reservation.release(); } catch (error) { - // FNXC:TaskReset 2026-08-19-06:45: Reservation release is post-cleanup housekeeping; never turn a committed reset into a false failure. + // FNXC:TaskReset 2026-08-27-22:20: Reservation release is post-cleanup housekeeping; never turn a committed reset into a false failure. severityAuditLog.warn("task-reset reservation release failed", { taskId: req.params.id, error: String(error) }); } }