FN-031: add task reset lifecycle handling

Add durable task reset lifecycle support across core, dashboard, and engine integrations.

- Add reset lifecycle publication and disposal primitives with coverage.
- Expose reset lifecycle behavior through task workflow routes and dashboard task details.
- Update workflow intake, engine exports, and worktree integration documentation.

Files changed:
 docs/dashboard-guide.md                            |   8 +
 .../postgres/task-reset-publication.pg.test.ts     |  99 +++++++
 .../core/src/__tests__/task-move-disposer.test.ts  |  52 +++
 packages/core/src/index.gate.ts                    |   6 +
 packages/core/src/index.ts                         |   6 +
 packages/core/src/store.ts                         |   6 +
 packages/core/src/task-store/reset-lifecycle.ts    | 193 ++++++++++++++
 packages/core/src/tasks/task-move-disposer.ts      |  25 ++
 .../dashboard/app/components/TaskDetailModal.tsx   |   8 +-
 .../components/__tests__/TaskDetailModal.test.tsx  |  28 +-
 .../__tests__/plan-approval-intake-column.test.ts  |  80 ++----
 .../src/__tests__/task-reset-lifecycle.test.ts     | 171 +++++++++++++
 .../src/routes/register-task-workflow-routes.ts    | 285 ++++++++++-----------
 packages/engine/src/index.ts                       |  10 +-
 packages/engine/src/worktree/worktree-backend.ts   |   1 +
 15 files changed, 766 insertions(+), 212 deletions(-)

Fusion-Task-Id: FN-031

Fusion-Task-Lineage: 183bb709-485e-4069-bd2a-98f787b2bd73

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 07:18:50 +00:00
parent 21b3baa62d
commit 5a0117a362
15 changed files with 768 additions and 214 deletions

View File

@@ -66,6 +66,14 @@ Both actions are irreversible; there is no undo after confirming. The dialog clo
**Excluded sections.** Some sections are not a simple settings form and are intentionally excluded from **Reset this menu** (the button is disabled with an explanatory tooltip when one of these is the active section), because each already has its own dedicated management flow: **Secrets**, **MCP Servers** (global and project), **Plugins**, **Memory**, **Authentication**, **Prompts**, **CLI Agents**, and the **Hermes**/**OpenClaw**/**Paperclip** runtime sections. Runtime pages appear only when their runtime plugin is installed; an installed but disabled runtime stays visible so it can be inspected or re-enabled. Settings hides runtime pages while its installed-plugin list is loading or unavailable, then refreshes the navigation after plugin lifecycle changes while Settings remains open. **Reset all project settings** is unaffected by this exclusion list since it resets the underlying project settings values directly, not through any of those sections' own flows.
## Task Reset
<!-- FNXC:TaskResetDocs 2026-08-19-06:45: Task Reset is a destructive fresh-planning boundary. The guide must explain the cancellation fence, filesystem cleanup, retained history, atomic intake publication, and retry behavior for incomplete cleanup. -->
**Reset** is destructive and has no undo. After confirmation, Fusion fences active task work, removes only the task-owned standard worktree and the current `.fusion/tasks/<task-id>/PROMPT.md` plan, then atomically returns the same task to its workflow's **Planning/intake** column with pending steps and `needs-replan`. The task ID, title, description, dependencies, workflow selection, attachments, documents, logs/audit history, and branch history remain intact.
Reset does not support workspace tasks or external/operator-owned, foreign, unsafe, or project-root worktrees. If cancellation, worktree removal, plan removal, runtime finalization, or durable publication fails, Fusion reports incomplete cleanup and does not claim success or expose the task to Planning; retry **Reset** after the reported problem is resolved. A plan-removal failure may leave the worktree already removed, but the stored task lifecycle state remains unchanged until a retry completes. Once the atomic publication commits, a task-file mirror problem is repaired separately and does not turn the successful reset into a false failure.
## Keyboard shortcuts
<!--

View File

@@ -0,0 +1,99 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { __setResetPublicationFailureForTesting } from "../../task-store/reset-lifecycle.js";
import {
pgDescribe,
createSharedPgTaskStoreTestHarness,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
/*
FNXC:TaskReset 2026-08-19-06:30:
These PostgreSQL tests pin the reset publication boundary rather than a sequence of facade calls. A failure after continuation retirement must roll back the retired row, foreach instance deletion, and task-row reset together; success must expose intake/needs-replan only with all graph cleanup committed.
*/
pgDescribe("TaskStore reset publication", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_task_reset_publication" });
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
async function seedPopulatedResetState() {
const store = h.store();
const task = await h.createTaskWithSteps();
const populated = await store.updateTask(task.id, {
column: "in-progress",
status: "failed",
worktree: "/tmp/owned-worktree",
branch: "fusion/fn-reset",
checkedOutBy: "agent-reset",
workflowIrPin: "pin-before-reset",
workflowStepResults: [{ workflowStepId: "plan-review", status: "failed" }],
reviewState: { status: "changes-requested" },
awaitingApprovalReason: "plan-review-replan-cap",
} as never);
const continuation = await store.upsertWorkflowWorkItem({
taskId: task.id,
runId: `${task.id}:run:active`,
nodeId: "execute",
kind: "task",
state: "running",
leaseOwner: "executor-reset",
leaseExpiresAt: null,
});
await store.saveWorkflowRunStepInstance({
taskId: task.id,
runId: `${task.id}:run:active`,
foreachNodeId: "steps",
stepIndex: 0,
pinnedStepCount: populated.steps.length,
currentNodeId: "step-execute",
status: "running",
reworkCount: 0,
updatedAt: new Date().toISOString(),
});
return { store, task: populated, continuation };
}
it("publishes task, continuation retirement, and foreach cleanup together", async () => {
const { store, task } = await seedPopulatedResetState();
const reset = await store.resetTaskPublication(task.id, "todo");
expect(reset.column).toBe("todo");
expect(reset.status).toBe("needs-replan");
expect(reset.steps.every((step) => step.status === "pending")).toBe(true);
expect(reset.worktree).toBeUndefined();
expect(reset.branch).toBeUndefined();
expect(reset.checkedOutBy).toBeUndefined();
expect(reset.workflowIrPin).toBeUndefined();
expect(reset.workflowStepResults).toEqual([]);
expect(reset.reviewState).toBeUndefined();
expect(reset.awaitingApprovalReason).toBeUndefined();
expect(await store.listWorkflowWorkItemsForTask(task.id, { kinds: ["task"] })).toEqual([
expect.objectContaining({ state: "cancelled" }),
]);
expect(await store.hasWorkflowRunStepInstancesForTask(task.id)).toBe(false);
});
it("rolls back every publication participant after workflow mutation failure", async () => {
const { store, task, continuation } = await seedPopulatedResetState();
const release = __setResetPublicationFailureForTesting(() => {
throw new Error("injected reset publication failure");
});
try {
await expect(store.resetTaskPublication(task.id, "todo")).rejects.toThrow("injected reset publication failure");
} finally {
release();
}
const durable = await store.getTask(task.id);
expect(durable?.column).toBe("in-progress");
expect(durable?.status).toBe("failed");
expect(durable?.worktree).toBe("/tmp/owned-worktree");
expect(durable?.workflowStepResults).toHaveLength(1);
expect((await store.listWorkflowWorkItemsForTask(task.id, { kinds: ["task"] })).find((item) => item.id === continuation.id)?.state).toBe("running");
expect(await store.hasWorkflowRunStepInstancesForTask(task.id)).toBe(true);
});
});

View File

@@ -3,6 +3,7 @@ import "@fusion/core"; // registers the built-in column traits
import {
__setTaskMoveDisposalTimeoutForTesting,
disposeTaskBeforeMove,
disposeTaskBeforeReset,
registerTaskMoveDisposer,
} from "../tasks/task-move-disposer.js";
@@ -135,6 +136,57 @@ describe("task move disposer", () => {
expect(second).toHaveBeenCalledTimes(2);
});
it("waits for every registered runtime owner before reset regardless of column", async () => {
const store = {} as never;
let releaseFirst: (() => void) | undefined;
let releaseSecond: (() => void) | undefined;
const first = vi.fn(() => new Promise<void>((resolve) => { releaseFirst = resolve; }));
const second = vi.fn(() => new Promise<void>((resolve) => { releaseSecond = resolve; }));
registerTaskMoveDisposer(store, first);
registerTaskMoveDisposer(store, second);
let resetReady = false;
const reset = disposeTaskBeforeReset(store, { id: "FN-RESET-FENCE", column: "done" } as never).then(() => {
resetReady = true;
});
await Promise.resolve();
expect(first).toHaveBeenCalledOnce();
expect(second).toHaveBeenCalledOnce();
expect(resetReady).toBe(false);
releaseFirst?.();
await Promise.resolve();
expect(resetReady).toBe(false);
releaseSecond?.();
await reset;
expect(resetReady).toBe(true);
});
it("is a no-op when reset has no registered runtime owners", async () => {
await expect(disposeTaskBeforeReset({} as never, { id: "FN-RESET-NO-OWNER" } as never)).resolves.toBeUndefined();
});
it("propagates reset disposer rejection without allowing cleanup to continue", async () => {
const store = {} as never;
registerTaskMoveDisposer(store, vi.fn().mockRejectedValue(new Error("runtime still active")));
await expect(disposeTaskBeforeReset(store, { id: "FN-RESET-REJECTED" } as never)).rejects.toThrow("runtime still active");
});
it("fails closed and releases reset when cancellation does not settle", async () => {
__setTaskMoveDisposalTimeoutForTesting(1);
try {
const store = {} as never;
registerTaskMoveDisposer(store, () => new Promise<void>(() => {}));
const preparation = disposeTaskBeforeReset(store, { id: "FN-RESET-WEDGED" } as never);
await expect(preparation).rejects.toThrow(
"Timed out stopping active work for FN-RESET-WEDGED before resetting the task",
);
} finally {
__setTaskMoveDisposalTimeoutForTesting();
}
});
it("fails closed and releases the move when cancellation does not settle", async () => {
__setTaskMoveDisposalTimeoutForTesting(1);
try {

View File

@@ -625,12 +625,18 @@ export {
} from "./db/archive-worktree-disposer.js";
export {
disposeTaskBeforeMove,
disposeTaskBeforeReset,
getTaskMoveDisposer,
registerTaskMoveDisposer,
type TaskMoveDisposer,
type TaskResetDisposer,
type TaskMoveDisposalInput,
type TaskMoveSource,
} from "./tasks/task-move-disposer.js";
export {
__setResetPublicationFailureForTesting,
resetTaskPublicationImpl,
} from "./task-store/reset-lifecycle.js";
export {
acquireWorktreePathReservation,
withWorktreePathReservation,

View File

@@ -755,12 +755,18 @@ export {
} from "./db/archive-worktree-disposer.js";
export {
disposeTaskBeforeMove,
disposeTaskBeforeReset,
getTaskMoveDisposer,
registerTaskMoveDisposer,
type TaskMoveDisposer,
type TaskResetDisposer,
type TaskMoveDisposalInput,
type TaskMoveSource,
} from "./tasks/task-move-disposer.js";
export {
__setResetPublicationFailureForTesting,
resetTaskPublicationImpl,
} from "./task-store/reset-lifecycle.js";
export {
acquireWorktreePathReservation,
withWorktreePathReservation,

View File

@@ -114,6 +114,7 @@ import type { IntakeOwnershipExemption } from "./tasks/task-intake-owner-resolve
import { TASK_JSONB_COLUMNS, type TaskRow, type TaskPersistSerializationContext, type TaskColumnDescriptor } from "./task-store/persistence.js";
import { pgRowToTaskRow as pgRowToTaskRowExternal, rowToTask as rowToTaskExternal, rowToBranchGroup as rowToBranchGroupExternal, generateBranchGroupId as generateBranchGroupIdExternal, computeTimedExecutionMs as computeTimedExecutionMsExternal, archiveEntryToTask as archiveEntryToTaskExternal, summarizeAgentLog as summarizeAgentLogExternal, rowToTaskDocument as rowToTaskDocumentExternal, rowToArtifact as rowToArtifactExternal, rowToTaskDocumentRevision as rowToTaskDocumentRevisionExternal, rowToGoalCitation as rowToGoalCitationExternal } from "./task-store/serialization.js";
import { moveTaskImpl, moveTaskIfImpl, handoffToReviewImpl, moveTaskInternalImpl, TerminalFailureApplyRejected, type MoveTaskIfResult } from "./task-store/moves.js";
import { resetTaskPublicationImpl } from "./task-store/reset-lifecycle.js";
import { recordGoalCitationsImpl, insertTaskWithFtsRecoveryImpl2, assertTaskIdAvailableImpl, atomicWriteTaskJsonImpl2, createTaskWithDistributedReservationImpl, toStoredWorkflowStepImpl, ensureWorkflowStepForTemplateImpl, resolveEnabledWorkflowStepsImpl, setTaskBranchGroupImpl, getTaskColumnsImpl, prepareWorkflowMovePolicyPreflightImpl, updateTaskCustomFieldsImpl, listWorkflowPromptOverridesForProjectImpl, listWorkflowWorkItemsForTaskImpl, listDueWorkflowWorkItemsImpl, rewriteBlockedByResidueDependentsForRemovalImpl, getAllDocumentsImpl, deleteWorkflowStepImpl, toWorkflowDefinitionImpl, materializeDefaultWorkflowStepsImpl, reconcileTaskCustomFieldsForSchemaImpl, getTaskMovedCountsByDayImpl, getGoalStoreImpl, upsertTaskCommitAssociationImpl } from "./task-store/workflow-task-create-ops.js";
import { applyLegacyWorkflowStepOverridesImpl, archiveDbImpl, assertNoDependencyCycleImpl, atomicCreateTaskJsonImpl, buildActiveTaskDependencyLookupImpl, buildArchivedAgentLogFieldsImpl, buildTaskIdIntegrityFallbackReportImpl, createBranchGroupImpl, dbImpl, detectAndCacheTaskIdIntegrityReportImpl, findLiveDependentsImpl, findLiveLineageChildrenImpl, getLegacyWorkflowStepSnapshotImpl, getMalformedTaskMetadataReasonImpl, getMergeQueuedTaskIdsAsyncImpl, insertRunAuditEventRowImpl, insertTaskImpl, invokeTaskCreatedHookImpl, isTaskArchivedAsyncImpl, isTaskArchivedImpl, isTaskIdPresentInArchivedTasksTableAsyncImpl, isTaskIdPresentInArchivedTasksTableImpl, logTaskCreateConflictImpl, maybeResolveTombstonedTaskIdImpl, mergeTaskIdIntegrityReportsImpl, optionalGroupIdSetImpl, patchTaskRowInTransactionImpl, readConfigFastImpl, readConfigImpl, readPromptForArchiveImpl, readTaskFromDbImpl, reconcileDistributedTaskIdStateOnOpenImpl, recordActivityFromListenerImpl, recordDependencyCycleRejectedAuditImpl, refreshTaskIdIntegrityReportImpl, resolveLocalNodeIdForTaskAllocationImpl, runTaskFtsWriteWithRecoveryImpl, scanAndRecordCitationsImpl, taskIdExistsAnywhereImpl, throwSoftDeletedWriteBlockedImpl, toBuiltInWorkflowStepImpl, trackDeferredTaskCreatedWorkImpl, upsertTaskImpl, withConfigLockImpl, withTaskLockImpl, withWorktreeAllocationLockImpl } from "./task-store/task-id-integrity.js";
import { claimNextToolFailureRetryImpl, createTaskVerificationRequestImpl, claimTaskVerificationRequestImpl, finishTaskVerificationRequestImpl, clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesAsyncImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, hasWorkflowRunStepInstancesForTaskImpl, loadWorkflowRunStepInstancesAsyncImpl, loadWorkflowRunStepInstancesImpl, markToolFailureRetryExhaustedAuditImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceAsyncImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/branch-and-pr-entities.js";
@@ -1700,6 +1701,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async moveTask( id: string, toColumn: ColumnId, options?: MoveTaskOptions, ): Promise<Task> {
return moveTaskImpl(this, id, toColumn, options);
}
/** Publish the post-cleanup fresh-planning reset as one project-scoped transaction. */
async resetTaskPublication(id: string, intakeColumn: ColumnId): Promise<Task> {
return resetTaskPublicationImpl(this, id, intakeColumn);
}
async moveTaskIf(
id: string,
toColumn: ColumnId,

View File

@@ -0,0 +1,193 @@
import { and, eq, inArray } from "drizzle-orm";
import type { ColumnId, Task, TaskStep } from "../types.js";
import * as schema from "../postgres/schema/index.js";
import { projectScopeFor } from "../postgres/data-layer.js";
import { acquireTaskAdvisoryXactLock } from "./task-advisory-lock.js";
import { withTaskWorkflowSerialization } from "./async/async-workflow-workitems.js";
import { readTaskRowInTransaction, upsertTaskRowInTransaction } from "./async/async-persistence.js";
import type { TaskStore } from "../store.js";
import { createLogger } from "../process/logger.js";
const resetLog = createLogger("task-store-reset-lifecycle");
const ACTIVE_TASK_CONTINUATION_STATES = ["runnable", "running", "held", "retrying"] as const;
let resetPublicationFailureForTesting: (() => void | Promise<void>) | undefined;
/** @internal Failure injection is test-only and scoped to the next publication attempt. */
export function __setResetPublicationFailureForTesting(
failure?: (() => void | Promise<void>),
): () => void {
resetPublicationFailureForTesting = failure;
return () => {
if (resetPublicationFailureForTesting === failure) resetPublicationFailureForTesting = undefined;
};
}
function pendingSteps(steps: TaskStep[]): TaskStep[] {
return steps.map((step) => ({ ...step, status: "pending" }));
}
/*
FNXC:TaskReset 2026-08-19-06:30:
The reset publisher is the single durable fresh-planning boundary. It re-reads the project-scoped row under the task and workflow locks, retires graph continuations and foreach instances, then writes pending steps, cleared execution/review state, `needs-replan`, and the resolved intake column in one transaction. Filesystem cleanup and runtime cancellation happen before this function; no route-level step, move, or pin writes may be interleaved.
*/
function buildResetTask(task: Task, intakeColumn: ColumnId): Task {
const now = new Date().toISOString();
return {
...task,
column: intakeColumn,
status: "needs-replan",
error: undefined,
currentStep: 0,
steps: pendingSteps(task.steps),
worktree: undefined,
workspaceWorktrees: undefined,
branch: undefined,
executionStartBranch: undefined,
baseCommitSha: undefined,
blockedBy: undefined,
overlapBlockedBy: undefined,
queuedLogEpisodeSignature: undefined,
paused: false,
userPaused: false,
pausedReason: undefined,
pausedByAgentId: undefined,
checkedOutBy: undefined,
checkedOutAt: undefined,
checkoutNodeId: undefined,
checkoutRunId: undefined,
checkoutLeaseRenewedAt: undefined,
sessionFile: undefined,
effectiveNodeId: undefined,
effectiveNodeSource: undefined,
executionStartedAt: undefined,
executionCompletedAt: undefined,
planningStartedAt: undefined,
summary: undefined,
review: undefined,
reviewState: undefined,
workflowStepResults: [],
mergeDetails: undefined,
awaitingApprovalReason: undefined,
approvedPlanFingerprint: undefined,
modifiedFiles: [],
declaredSymbols: [],
scopeAutoWiden: [],
stuckKillCount: 0,
mergeRetries: undefined,
workflowStepRetries: undefined,
resumeLimboCount: 0,
executeRequeueLoopCount: 0,
executeRequeueLoopSignature: undefined,
graphResumeRetryCount: 0,
consecutiveToolFailureRetryCount: 0,
executorEscalationAttempted: false,
toolFailureDetectorLogCursor: 0,
toolFailureRetryExhaustedAuditEmitted: false,
resumeLimboTipSha: undefined,
resumeLimboStepSignature: undefined,
postReviewFixCount: 0,
planReviewReplanCount: 0,
recoveryRetryCount: undefined,
taskDoneRetryCount: 0,
bulkCompletionRefusalAt: undefined,
worktreeSessionRetryCount: 0,
completionHandoffLimboRecoveryCount: 0,
verificationFailureCount: 0,
mergeConflictBounceCount: 0,
mergeAuditBounceCount: 0,
mergeTransientRetryCount: 0,
branchConflictRecoveryCount: 0,
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
nextRecoveryAt: undefined,
workflowIrPin: undefined,
workflowIrPinNodeId: undefined,
workflowIrPinColumnId: undefined,
columnMovedAt: now,
updatedAt: now,
};
}
function assertResetTask(task: Task, intakeColumn: ColumnId): void {
if (task.column !== intakeColumn || task.status !== "needs-replan") {
throw new Error("Reset publication returned a task outside its resolved intake state");
}
if (task.steps.some((step) => step.status !== "pending")) {
throw new Error("Reset publication returned a task with a non-pending step");
}
if (
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
) {
throw new Error("Reset publication returned stale execution or review state");
}
}
export async function resetTaskPublicationImpl(
store: TaskStore,
taskId: string,
intakeColumn: ColumnId,
): Promise<Task> {
const layer = store.asyncLayer;
if (!layer) {
throw new Error("Atomic task reset publication requires the PostgreSQL backend");
}
const projectId = layer.projectId;
let published!: Task;
await layer.transactionImmediate(async (tx) => {
await acquireTaskAdvisoryXactLock(tx, projectId, taskId);
await withTaskWorkflowSerialization(tx, projectId, taskId, async () => {
const currentRow = await readTaskRowInTransaction(tx, taskId, undefined, projectId);
if (!currentRow) throw new Error(`Task ${taskId} not found`);
const current = store.rowToTask(store.pgRowToTaskRow(currentRow));
const scope = projectScopeFor(schema.project.workflowWorkItems.projectId, projectId);
const active = await tx.select({ id: schema.project.workflowWorkItems.id })
.from(schema.project.workflowWorkItems)
.where(and(
scope,
eq(schema.project.workflowWorkItems.taskId, taskId),
eq(schema.project.workflowWorkItems.kind, "task"),
inArray(schema.project.workflowWorkItems.state, [...ACTIVE_TASK_CONTINUATION_STATES]),
));
if (active.length > 0) {
await tx.update(schema.project.workflowWorkItems)
.set({ state: "cancelled", leaseOwner: null, leaseExpiresAt: null, updatedAt: new Date().toISOString() })
.where(and(scope, inArray(schema.project.workflowWorkItems.id, active.map((row) => row.id))));
}
await resetPublicationFailureForTesting?.();
await tx.delete(schema.project.workflowRunStepInstances).where(and(
projectScopeFor(schema.project.workflowRunStepInstances.projectId, projectId),
eq(schema.project.workflowRunStepInstances.taskId, taskId),
));
await tx.delete(schema.project.workflowRunBranches).where(and(
projectScopeFor(schema.project.workflowRunBranches.projectId, projectId),
eq(schema.project.workflowRunBranches.taskId, taskId),
));
const next = buildResetTask(current, intakeColumn);
await upsertTaskRowInTransaction(
tx,
next as unknown as Record<string, unknown>,
store.createTaskPersistSerializationContext(next, currentRow as never),
projectId,
);
const committedRow = await readTaskRowInTransaction(tx, taskId, undefined, projectId);
if (!committedRow) throw new Error(`Task ${taskId} disappeared during reset publication`);
published = store.rowToTask(store.pgRowToTaskRow(committedRow));
assertResetTask(published, intakeColumn);
});
});
// PostgreSQL is authoritative. The compatibility task.json mirror is repaired best-effort after commit.
try {
await store.atomicWriteTaskJson(store.taskDir(taskId), published);
} catch (error) {
resetLog.warn(`[reset] committed PostgreSQL reset but task.json mirroring failed for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
}
if (store.isWatching) store.taskCache.set(taskId, { ...published });
store.emitTaskLifecycleEventSafely("task:updated", [published]);
return published;
}

View File

@@ -5,6 +5,7 @@ import { resolveWorkflowIrForTask } from "../workflows/workflow-ir-resolver.js";
export type TaskMoveSource = "user" | "engine" | "scheduler";
export type TaskMoveDisposer = (task: Task) => Promise<void>;
export type TaskResetDisposer = TaskMoveDisposer;
export interface TaskMoveDisposalInput {
task: Task;
@@ -126,3 +127,27 @@ export async function disposeTaskBeforeMove(store: TaskStore, input: TaskMoveDis
if (timeout) clearTimeout(timeout);
}
}
/*
FNXC:TaskReset 2026-08-19-06:30:
Reset is a destructive fresh-planning boundary, so it fences every registered runtime owner regardless of the task's current column before filesystem cleanup begins. The timeout is fail-closed: worktree and plan deletion never starts while an executor, agent, CLI, or planner still holds the task.
*/
export async function disposeTaskBeforeReset(store: TaskStore, task: Task): Promise<void> {
const disposer = getTaskMoveDisposer(store);
if (!disposer) return;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
disposer(task),
new Promise<void>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Timed out stopping active work for ${task.id} before resetting the task`));
}, taskMoveDisposalTimeoutMs);
timeout.unref?.();
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}

View File

@@ -3365,11 +3365,15 @@ export function TaskDetailContent({
});
}, [task.id, onBypassReview, onTaskUpdated, addToast, t]);
/*
FNXC:TaskReset 2026-08-19-06:45:
Reset is destructive: confirmation explains that the owned worktree and current plan are discarded, while the success toast is emitted only after the server has fenced work, completed cleanup, and committed the fresh Planning state.
*/
const handleReset = useCallback(async () => {
if (!onResetTask) return;
const shouldReset = await confirm({
title: t("taskDetail.reset.btn", "Reset"),
message: t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }),
message: t("taskDetail.reset.confirmMessage", "This permanently discards {{id}}'s task-owned worktree and current plan, then returns it to Planning. Continue?", { id: task.id }),
confirmLabel: t("taskDetail.reset.btn", "Reset"),
cancelLabel: t("common.cancel", "Cancel"),
danger: true,
@@ -3378,7 +3382,7 @@ export function TaskDetailContent({
requestClose();
try {
await onResetTask(task.id);
addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success");
addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — worktree and plan discarded; task returned to Planning", { id: task.id }), "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
}

View File

@@ -119,6 +119,7 @@ function createDeferred<T>() {
describe("TaskDetailModal reset confirmations", () => {
it("routes reset through the centralized confirm seam and proceeds in skip mode", async () => {
const onResetTask = vi.fn(async () => makeTask());
const addToast = vi.fn();
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailModal
@@ -129,16 +130,39 @@ describe("TaskDetailModal reset confirmations", () => {
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onResetTask={onResetTask}
addToast={noop}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Reset" }));
await waitFor(() => expect(onResetTask).toHaveBeenCalledWith("FN-001"));
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true, title: "Reset" }));
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true, title: "Reset", message: expect.stringContaining("worktree") }));
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("worktree and plan discarded"), "success");
expect(document.querySelector(".confirm-dialog-overlay")).toBeNull();
});
it("shows endpoint failure instead of reset success copy", async () => {
const addToast = vi.fn();
const onResetTask = vi.fn().mockRejectedValue(new Error("partial cleanup; retry Reset"));
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailModal
task={makeTask({ id: "FN-002", column: "in-progress" as any })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onResetTask={onResetTask}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Reset" }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("partial cleanup; retry Reset", "error"));
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("worktree and plan discarded"), "success");
});
});
describe("TaskDetailModal planner Chat tab", () => {

View File

@@ -77,6 +77,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
updateTask: vi.fn().mockResolvedValue(PLANNING_TASK),
withPlanningLifecycleLock: vi.fn(async (_id, fn) => await fn()),
moveTask: vi.fn().mockResolvedValue(PLANNING_TASK),
resetTaskPublication: vi.fn(async (id: string, intake: string) => ({ ...PLANNING_TASK, id, column: intake, status: "needs-replan" })),
logEntry: vi.fn().mockResolvedValue(undefined),
// Resolve the merged workflow so the routes see its real intake column.
getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "builtin:stepwise-coding" }),
@@ -144,32 +145,24 @@ describe("plan approval on the merged planning column (post-#2515)", () => {
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
Reset must verify against the column it actually TARGETED.
`resolveReboundColumnForTask` picks the rebound column from the task's workflow, but both
post-reset checks compared against the literal `todo`. On any workflow whose rebound
column is not `todo` — Coding (Ideas), any custom or renamed lineage — a reset that
SUCCEEDED was reported as a "limbo state" conflict: the mover and its own verification
disagreed about where the card was supposed to land.
REVERT CHECK: restore either `updated.column !== "todo"` and this fails with a 409,
because the card lands in `backlog`, which is where its workflow says a reset belongs.
FNXC:TaskReset 2026-08-19-06:45:
Reset resolves the workflow's intake column, not its rebound/hold column. Route-level drift correction is gone; the atomic publisher owns the complete durable reset.
*/
describe("reset verification uses the resolved rebound column", () => {
describe("reset publishes the resolved workflow intake", () => {
const REBOUND_IR = {
version: "v2",
name: "custom",
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }, { trait: "hold" }] },
{ id: "planning", name: "Planning", traits: [{ trait: "intake" }] },
{ id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
nodes: [{ id: "start", kind: "start", column: "backlog" }, { id: "end", kind: "end", column: "shipped" }],
nodes: [{ id: "start", kind: "start", column: "planning" }, { id: "end", kind: "end", column: "shipped" }],
edges: [{ from: "start", to: "end" }],
};
it("does not report a limbo-state conflict when the card lands in its own rebound column", async () => {
it("returns a custom workflow to intake rather than its distinct hold column", async () => {
const resetTask = {
...PLANNING_TASK,
id: "FN-300",
@@ -197,61 +190,24 @@ describe("reset verification uses the resolved rebound column", () => {
JSON.stringify({ confirm: true }),
{ "content-type": "application/json" },
);
/*
Assert SUCCESS, not "not 409" (PR #2582 review — greptile). A negative assertion also
passes on a 404 or 500, so it would stay green while the route failed some other way.
*/
expect(res.status).toBe(200);
expect((store.resetTaskPublication as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]).toBe("planning");
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
expect((store.updateTask as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
});
it("routes drift correction to the resolved rebound column, not `todo`", async () => {
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2582 review — greptile):
The drift-correction path is where fixing the CHECK without fixing the WRITER just
moved the bug: `RESET_DRIFT_CORRECTION_FIELDS` hardcoded `column: "todo"`, so a card
with stale reset metadata was forced to `todo` and the final check — now comparing
against `resetColumn` — raised the very 409 this change removes.
REVERT CHECK: restore `column: "todo"` in the constant and this fails, because the
correction writes `todo` while the workflow's rebound column is `backlog`.
*/
const driftedTask = {
...PLANNING_TASK,
id: "FN-301",
column: "backlog",
// Stale binding: this is what triggers drift correction.
worktree: "/tmp/stale",
branch: null,
checkedOutBy: null,
} as unknown as TaskDetail;
const corrected = { ...driftedTask, worktree: null } as unknown as TaskDetail;
const updateTask = vi.fn().mockResolvedValue(corrected);
let reads = 0;
it("does not publish when a worktree path is unsafe", async () => {
const store = createMockStore({
/*
The route reads the task before the move AND after it; both must still show the
stale worktree for drift correction to trigger. Only reads after the correction
writes see the cleaned task.
*/
getTask: vi.fn().mockImplementation(async () => (reads++ < 2 ? driftedTask : corrected)),
moveTask: vi.fn().mockResolvedValue(driftedTask),
updateTask,
getTask: vi.fn().mockResolvedValue({ ...PLANNING_TASK, id: "FN-301", column: "backlog", worktree: "/tmp/stale" }),
getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "wf-custom" }),
getWorkflowDefinition: vi.fn().mockResolvedValue({ id: "wf-custom", name: "Custom", ir: REBOUND_IR }),
});
await performRequest(
createApp(store),
"POST",
"/api/tasks/FN-301/reset",
JSON.stringify({ confirm: true }),
{ "content-type": "application/json" },
);
const correctionCall = updateTask.mock.calls.find(([, patch]) => patch && "column" in patch);
expect(correctionCall).toBeDefined();
expect((correctionCall![1] as { column: string }).column).toBe("backlog");
const res = await performRequest(createApp(store), "POST", "/api/tasks/FN-301/reset", JSON.stringify({ confirm: true }), { "content-type": "application/json" });
expect(res.status).toBe(400);
expect((store.resetTaskPublication as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
expect((store.updateTask as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,171 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Task, TaskStore } from "@fusion/core";
import { registerTaskMoveDisposer } from "@fusion/core";
import { getRegisteredWorktreeBranches } 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<typeof import("@fusion/engine")>("@fusion/engine");
return {
...actual,
removeWorktree: vi.fn(async (input: { worktreePath: string }) => {
const { rm } = await import("node:fs/promises");
await rm(input.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: "Reset test workflow",
columns: [
{ id: "triage", name: "Planning", traits: [{ trait: "intake" }] },
{ id: "hold", name: "Hold", traits: [{ trait: "hold" }] },
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] },
],
nodes: [{ id: "start", kind: "start", column: "triage" }],
edges: [],
};
function taskFixture(worktree: string): Task {
return {
id: "FN-400",
title: "Reset fixture",
description: "A populated task",
column: "in-progress",
status: "failed",
dependencies: [],
steps: [
{ name: "Implement", status: "done" },
{ name: "Verify", status: "in-progress" },
],
currentStep: 1,
worktree,
branch: "fusion/fn-400",
workflowIrPin: "stale-pin",
workflowStepResults: [{ workflowStepId: "plan-review", status: "failed" }],
reviewState: { status: "changes-requested" } as never,
awaitingApprovalReason: "plan-review-replan-cap",
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
}
function createApp(store: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
function createStore(root: string, task: Task, events: string[], publish: (id: string, intake: string) => Promise<Task>) {
return {
getRootDir: vi.fn().mockReturnValue(root),
getSettings: vi.fn().mockResolvedValue({ worktreesDir: ".worktrees" }),
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([task]),
withPlanningLifecycleLock: vi.fn(async (_id: string, fn: () => Promise<Task>) => await fn()),
getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "wf-reset" }),
getWorkflowDefinition: vi.fn().mockResolvedValue({ id: "wf-reset", name: "Reset", ir: WORKFLOW_IR }),
resetTaskPublication: vi.fn(publish),
logEntry: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]),
events,
} as unknown as TaskStore;
}
describe("POST /tasks/:id/reset", () => {
afterEach(() => vi.restoreAllMocks());
it("fences cancellation, removes worktree and plan, then publishes Planning atomically", async () => {
const root = await mkdtemp(join(tmpdir(), "fusion-reset-route-"));
const worktree = join(root, ".worktrees", "fn-400");
const taskDir = join(root, ".fusion", "tasks", "FN-400");
await mkdir(worktree, { recursive: true });
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Existing plan\n");
const events: string[] = [];
const task = taskFixture(worktree);
vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([{ branch: task.branch!, worktreePath: worktree }]);
const reset = { ...task, column: "triage", status: "needs-replan", worktree: undefined, branch: undefined, steps: task.steps.map((step) => ({ ...step, status: "pending" as const })) };
const store = createStore(root, task, events, async () => {
events.push("published");
return reset;
});
const unregister = registerTaskMoveDisposer(store, async () => {
events.push("cancelled");
});
try {
const res = await performRequest(createApp(store), "POST", "/api/tasks/FN-400/reset", JSON.stringify({ confirm: true }), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect(events).toEqual(["cancelled", "published"]);
await expect(readFile(join(taskDir, "PROMPT.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(worktree, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
expect(res.body).toMatchObject({ id: "FN-400", column: "triage", status: "needs-replan" });
expect(res.body.steps.every((step: { status: string }) => step.status === "pending")).toBe(true);
} finally {
unregister();
}
});
it("keeps durable state non-replannable when prompt removal fails after worktree cleanup", async () => {
const root = await mkdtemp(join(tmpdir(), "fusion-reset-route-failure-"));
const worktree = join(root, ".worktrees", "fn-400");
const taskDir = join(root, ".fusion", "tasks", "FN-400");
await mkdir(worktree, { recursive: true });
await mkdir(taskDir, { recursive: true });
await mkdir(join(taskDir, "PROMPT.md"));
const task = taskFixture(worktree);
vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([{ branch: task.branch!, worktreePath: worktree }]);
const publication = vi.fn().mockResolvedValue({ ...task, column: "triage", status: "needs-replan" });
const store = createStore(root, task, [], publication);
const res = await performRequest(createApp(store), "POST", "/api/tasks/FN-400/reset", JSON.stringify({ confirm: true }), { "content-type": "application/json" });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/partial cleanup; retry Reset/i);
expect(publication).not.toHaveBeenCalled();
await expect(readFile(join(taskDir, "PROMPT.md"), "utf8")).rejects.toMatchObject({ code: "EISDIR" });
expect(store.updateTask).toBeUndefined();
});
it("rejects a registered foreign checkout before cancellation or deletion", async () => {
const root = await mkdtemp(join(tmpdir(), "fusion-reset-route-foreign-"));
const worktree = join(root, ".worktrees", "operator-checkout");
const taskDir = join(root, ".fusion", "tasks", "FN-400");
await mkdir(worktree, { recursive: true });
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Keep this plan\n");
const task = taskFixture(worktree);
vi.mocked(getRegisteredWorktreeBranches).mockResolvedValue([{ branch: "operator/checkout", worktreePath: worktree }]);
const events: string[] = [];
const publication = vi.fn().mockResolvedValue({ ...task, column: "triage", status: "needs-replan" });
const store = createStore(root, task, events, publication);
const unregister = registerTaskMoveDisposer(store, async () => {
events.push("cancelled");
});
try {
const res = await performRequest(createApp(store), "POST", "/api/tasks/FN-400/reset", JSON.stringify({ confirm: true }), { "content-type": "application/json" });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/ownership cannot be proven/i);
expect(events).toEqual([]);
expect(publication).not.toHaveBeenCalled();
await expect(readFile(join(taskDir, "PROMPT.md"), "utf8")).resolves.toBe("# Keep this plan\n");
expect((await stat(worktree)).isDirectory()).toBe(true);
} finally {
unregister();
}
});
});

View File

@@ -12,7 +12,8 @@ const severityAuditLog = createLogger("dashboard-register-task-workflow-routes")
const AWAITING_PLANNING_ENRICH_LIMIT = 200;
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { readFile, rm, stat, realpath } from "node:fs/promises";
import { join } from "node:path";
import type {
TaskStore,
@@ -82,6 +83,9 @@ import {
getPlannerInterventionTimeline,
isBuiltinWorkflowId,
resolveProjectColumnsForRoles,
canonicalizeWorktreePath,
acquireWorktreePathReservation,
disposeTaskBeforeReset,
type NearDuplicateCandidate,
type ThinkingLevel,
} from "@fusion/core";
@@ -109,6 +113,12 @@ import {
// FN-8004 follow-up: shared with SelfHealingManager.recoverStaleMergingStatus so the manual
// Retry gate and the automatic sweep agree on when a merge-active stamp is orphaned.
isStaleMergeActiveStatus,
removeWorktree,
RemovalReason,
getRegisteredWorktreeBranches,
pruneWorktreeAdminEntries,
isInsideConfiguredWorktreesDir,
resolveWorktreesDir,
resumeApprovedPlanReviewHandoff,
type ApprovedPlanReviewHandoffResult,
type AiUndoTaskResult,
@@ -652,71 +662,6 @@ function extractMergeAdvanceEvent(event: RunAuditEvent): Omit<MergeAdvanceEvent,
export const __fingerprintCreateLocksForTests = deterministicGuardLocks;
const RESET_TASK_FIELDS = {
worktree: null,
branch: null,
currentStep: 0,
status: null,
error: null,
stuckKillCount: 0,
taskDoneRetryCount: null,
worktreeSessionRetryCount: null,
workflowStepRetries: undefined,
recoveryRetryCount: null,
nextRecoveryAt: null,
postReviewFixCount: 0,
verificationFailureCount: 0,
mergeConflictBounceCount: 0,
checkedOutBy: null,
executionStartedAt: null,
sessionFile: null,
} as const;
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — PR #2582 review, greptile):
COLUMN REMOVED from the shared constant. It hardcoded `todo`, so drift correction forced
the card there regardless of the workflow's actual rebound column — and then the final
verification (which now compares against `resetColumn`) saw the mismatch and raised the
very 409 "limbo" conflict this change exists to remove. Fixing the check without fixing
the writer just moved the bug.
The column is supplied per call from the resolved rebound column; everything else here is
genuinely column-independent cleanup.
*/
const RESET_DRIFT_CORRECTION_FIELDS = {
worktree: null,
branch: null,
status: null,
error: null,
checkedOutBy: null,
executionStartedAt: null,
taskDoneRetryCount: null,
worktreeSessionRetryCount: null,
sessionFile: null,
} as const;
async function emitResetDriftAudit(
scopedStore: TaskStore,
taskId: string,
metadata: Record<string, unknown>,
): Promise<void> {
const recordRunAuditEvent = (scopedStore as TaskStore & {
recordRunAuditEvent?: (input: RunAuditEventInput) => Promise<void>;
}).recordRunAuditEvent;
if (typeof recordRunAuditEvent !== "function") {
return;
}
await recordRunAuditEvent({
taskId,
agentId: "system",
runId: `synthetic-dashboard-reset-${taskId}-${Date.now()}`,
domain: "database",
mutationType: "task:auto-recover-reset-drift",
target: taskId,
metadata,
});
}
async function releaseExecutionAgentBindings(
engine: { getAgentStore?: () => { listAgents: (input: { includeEphemeral?: boolean }) => Promise<Array<{ id: string; taskId?: string }>>; syncExecutionTaskLink: (agentId: string, taskId: string | undefined) => Promise<unknown>; deleteAgent: (agentId: string) => Promise<unknown>; getAgent?: (agentId: string) => Promise<unknown>; } | undefined } | undefined,
taskId: string,
@@ -3683,104 +3628,154 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// Nuclear reset — erase all progress and allocate a fresh worktree+branch on next run
// Reset fences runtime work, removes disposable artifacts, then publishes one fresh planning state.
router.post("/tasks/:id/reset", async (req, res) => {
try {
const { store: scopedStore, engine } = await getProjectContext(req);
const { confirm: confirmed } = (req.body ?? {}) as { confirm?: boolean };
if (!confirmed) {
throw badRequest(
"This operation is destructive and will erase all task progress. Pass { \"confirm\": true } in the request body to proceed.",
"This operation is destructive and discards the task-owned worktree and current plan. Pass { \"confirm\": true } in the request body to proceed.",
);
}
const task = await scopedStore.getTask(req.params.id);
engine?.clearTaskPauseAbortState?.(req.params.id);
await releaseExecutionAgentBindings(engine, req.params.id);
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
// Reset all steps to pending
for (let i = 0; i < task.steps.length; i++) {
if (task.steps[i].status !== "pending") {
await scopedStore.updateStep(req.params.id, i, "pending");
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<ReturnType<typeof acquireWorktreePathReservation>> | undefined;
await scopedStore.updateTask(req.params.id, RESET_TASK_FIELDS);
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");
}
}
/*
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 (!registeredOwner) {
throw conflict("Reset refuses a worktree whose managed task ownership cannot be proven");
}
await scopedStore.logEntry(
req.params.id,
"Task reset by user — all progress cleared, fresh worktree and branch will be allocated",
);
const resetColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
await scopedStore.moveTask(req.params.id, resetColumn);
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
let updated = await scopedStore.getTask(req.params.id);
if (!updated) {
throw notFound(`Task ${req.params.id} not found after reset`);
}
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
Verify against the column the reset actually TARGETED. The mover two lines up already
resolves `resetColumn` from the task's workflow, but both post-reset checks compared
against the literal `todo` — so on any workflow whose rebound column is not `todo`
(Coding (Ideas), any custom or renamed lineage) a reset that SUCCEEDED was reported
as a "limbo state" conflict. The mover and its own verification disagreed about
where the card was supposed to land.
*/
const needsDriftCorrection = updated.column !== resetColumn
|| (updated.worktree ?? null) !== null
|| (updated.branch ?? null) !== null
|| (updated.checkedOutBy ?? null) !== null
|| (updated.executionStartedAt ?? null) !== null;
if (needsDriftCorrection) {
const offendingSnapshot = {
column: updated.column,
worktree: updated.worktree ?? null,
branch: updated.branch ?? null,
checkedOutBy: updated.checkedOutBy ?? null,
executionStartedAt: updated.executionStartedAt ?? null,
taskDoneRetryCount: updated.taskDoneRetryCount ?? null,
worktreeSessionRetryCount: updated.worktreeSessionRetryCount ?? null,
sessionFile: updated.sessionFile ?? null,
};
/*
Built as a named const, not an inline literal: `updateTask`'s patch type does not
declare `column`, and the original code only compiled because a variable reference
skips excess-property checking. Keeping that shape preserves the existing runtime
behaviour exactly while making the column follow the resolved rebound target.
*/
const driftCorrection = { ...RESET_DRIFT_CORRECTION_FIELDS, column: resetColumn };
await scopedStore.updateTask(req.params.id, driftCorrection);
await scopedStore.logEntry(
req.params.id,
`Auto-corrected reset drift after moveTask — normalized task back to ${resetColumn} with cleared worktree/branch bindings`,
JSON.stringify(offendingSnapshot),
);
await emitResetDriftAudit(scopedStore, req.params.id, offendingSnapshot);
updated = await scopedStore.getTask(req.params.id);
if (!updated) {
throw notFound(`Task ${req.params.id} not found after reset drift correction`);
const listTasks = (scopedStore as TaskStore & {
listTasks?: (options?: { includeArchived?: boolean; slim?: boolean }) => Promise<Task[]>;
}).listTasks;
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");
}
}
}
const worktreesDir = resolveWorktreesDir(rootDir, settings);
reservation = await acquireWorktreePathReservation({
canonicalPath: worktreePath,
worktreesDir,
rootDir,
});
}
}
// Same target as the drift check above: the resolved rebound column, not `todo`.
if (updated.column !== resetColumn || (updated.worktree ?? null) !== null || (updated.branch ?? null) !== null) {
throw conflict(
`Reset refused to return task ${req.params.id} in limbo state (${updated.column}, branch=${updated.branch ?? "null"}, worktree=${updated.worktree ?? "null"})`,
);
}
try {
/*
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.
*/
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) {
throw conflict("Reset target changed while cancellation was settling; retry Reset");
}
if (worktreePath) {
if (existsSync(worktreePath)) {
const removal = await removeWorktree({
worktreePath,
rootDir,
settings,
reason: RemovalReason.TaskReset,
taskId: req.params.id,
expectedOwnerTaskId: req.params.id,
});
if (!removal.removed && existsSync(worktreePath)) {
throw conflict(`Reset incomplete; worktree removal failed for ${req.params.id}`);
}
} else {
// The pointer is retained for retry safety, but the path is already absent.
await pruneWorktreeAdminEntries({ rootDir, reason: "task-reset-already-absent", target: worktreePath });
}
if (existsSync(worktreePath)) {
throw conflict(`Reset incomplete; worktree remains for ${req.params.id}`);
}
}
const promptPath = join(rootDir, ".fusion", "tasks", req.params.id, "PROMPT.md");
try {
await rm(promptPath, { force: true });
if (existsSync(promptPath)) throw new Error("PROMPT.md still exists after removal");
} catch (error) {
throw conflict(`partial cleanup; retry Reset (PROMPT.md could not be removed: ${error instanceof Error ? error.message : String(error)})`);
}
try {
await Promise.resolve(engine?.clearTaskPauseAbortState?.(req.params.id));
await releaseExecutionAgentBindings(engine, req.params.id);
} catch (error) {
throw conflict(`Reset incomplete; runtime finalization failed: ${error instanceof Error ? error.message : String(error)}`);
}
const publish = (scopedStore as TaskStore & {
resetTaskPublication?: (taskId: string, intake: string) => Promise<Task>;
}).resetTaskPublication;
if (typeof publish !== "function") {
throw new Error("Atomic task reset publication is unavailable");
}
return publish(req.params.id, intakeColumn);
} finally {
if (reservation?.state === "held") {
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.
severityAuditLog.warn("task-reset reservation release failed", { taskId: req.params.id, error: String(error) });
}
}
}
});
res.json(updated);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof ApiError) throw err;
rethrowTaskApiError(err, req.params.id);
}
});

View File

@@ -649,7 +649,15 @@ export {
type MockScriptContext,
} from "./providers/index.js";
export { activeSessionRegistry } from "./agents/active-session-registry.js";
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree/worktree-pool.js";
export {
WorktreePool,
scanIdleWorktrees,
cleanupOrphanedWorktrees,
reapOrphanWorktrees,
getRegisteredWorktreeBranches,
} from "./worktree/worktree-pool.js";
export { removeWorktree, RemovalReason, type RemovalReason as WorktreeRemovalReason, type WorktreeRemoveOutcome } from "./worktree/worktree-backend.js";
export { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree/worktree-paths.js";
export {
pruneWorktreeAdminEntries,
pruneWorktreeAdminEntriesSync,

View File

@@ -1032,6 +1032,7 @@ export const RemovalReason = {
SelfHealingBranchConflict: "self-healing-branch-conflict",
SelfHealingIdleSweep: "self-healing-idle-sweep",
PoolPrune: "pool-prune",
TaskReset: "task-reset",
} as const;
export type RemovalReason = typeof RemovalReason[keyof typeof RemovalReason];