FN-7524: add AI-undo fallback task for reverting done/archived tasks

Adds an AI-undo fallback to the revert route: when a git-based revert conflicts or is unsupported, an ordinary board task is created to perform the undo via AI instead of a forced/failed git write.

- POST /tasks/:id/revert now accepts an optional `{ mode?: "git" | "ai" | "auto" }` body (default "auto"); unknown values reject with 400.
- "git" preserves the FN-7523 git-only contract unchanged; "ai" always creates the AI-undo task; "auto" tries git first and falls back to AI only on a conflicting or unsupported (e.g. workspace) result — needsHuman (autoMerge:false) never triggers the fallback.
- New engine helpers in task-revert.ts: `createAiUndoTask`, `buildAiUndoTaskDescription`, `REVERT_OF_METADATA_KEY`, plus `AiUndoTaskResult`/`CreateAiUndoTaskDeps` types, exported from packages/engine/src/index.ts.
- The AI-undo task is created via the normal triage-column `store.createTask` path with no dependency on the source task, referencing the source task's mission, id, and landed files, and instructing an undo commit using the `revert(FN-xxxx): ...` convention.
- New core `TaskStore.findOpenRevertTaskForSource` backs an idempotency guard: a repeated call while an AI-undo task is still open returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate.
- Updated docs/task-management.md's revert section to document the git path + AI-undo fallback contract.
- Added a minor changeset for the @runfusion/fusion release notes.
- Added/extended tests: packages/engine/src/__tests__/task-revert-ai-undo.test.ts (new) and packages/dashboard/src/__tests__/task-revert-route.test.ts (extended) covering mode validation, auto-fallback-on-conflict, forced "ai" mode, and the duplicate-open-task guard.

Files changed:
 .changeset/fn-7524-ai-undo-revert.md               |   7 +
 docs/task-management.md                            |  13 +-
 packages/core/src/store.ts                         |  31 +++++
 packages/dashboard/src/__tests__/task-revert-route.test.ts | 143 ++++++++++++++++++++-
 packages/dashboard/src/routes/register-task-workflow-routes.ts |  75 +++++++++--
 packages/engine/src/__tests__/task-revert-ai-undo.test.ts |  114 ++++++++++++++++
 packages/engine/src/index.ts                       |   5 +
 packages/engine/src/task-revert.ts                 | 117 ++++++++++++++++-
 8 files changed, 487 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-7524

Fusion-Task-Lineage: 64dfedcf-c286-4c46-8cf8-51ec5e668bf7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 21:42:55 -07:00
parent 17c40070db
commit c4d81fe5cc
8 changed files with 487 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add an AI-undo fallback task when reverting a done task via git conflicts or is unsupported.
category: feature
dev: `POST /api/tasks/:id/revert` now accepts `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`). `"auto"` tries the FN-7523 git-revert path first and falls back to creating an AI-undo board task (`{ mode: "ai", createdTaskId, alreadyOpen? }`) on a conflicting or unsupported (e.g. workspace) git result; `needsHuman` (autoMerge-off) never triggers the fallback. `"ai"` always creates the AI-undo task; `"git"` keeps the FN-7523 git-only contract, which is otherwise unchanged. New engine exports: `createAiUndoTask`, `buildAiUndoTaskDescription`, `REVERT_OF_METADATA_KEY`. New core store method `TaskStore.findOpenRevertTaskForSource` backs the idempotency guard (an open undo task suppresses a duplicate; a closed one does not).

View File

@@ -673,13 +673,18 @@ Recovery/backfill guidance:
- If both the active row and archive snapshot were overwritten, Fusion cannot reconstruct lost attachments/comments automatically; recreate them from git history, branch/worktree contents, screenshots, or external issue trackers.
- Record the incident in the replacement task so future audits understand why the task ID and commit history diverge.
## Reverting Done/Archived tasks (git path)
## Reverting Done/Archived tasks (git path + AI-undo fallback)
- `POST /api/tasks/:id/revert` (FN-7523) reverts a **Done** or **Archived** task's landed work via git. Only `done`/`archived` tasks are revertable; the source task's column/status is never mutated as a side effect.
- The engine resolves the task's attributable commit(s) (squash single-commit, rebase/cherry-pick trailer-filtered subset, or lineage-snapshot fallback), performs a non-committing dry-run to classify the outcome, and only writes a real commit when the dry-run is clean.
- Response contract: `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch. A conflicting result creates no commit and leaves the tree/HEAD untouched — this is where a future AI-undo fallback (sibling task) can take over.
- Workspace (multi-repo) tasks and `autoMerge:false` projects are out of scope for the forced git write and return `unsupported`/`needsHuman` results instead.
- This is the git path only; no dashboard UI affordance ships with it (see sibling follow-up tasks for the card action and the AI-undo fallback).
- The route accepts an optional request body `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`; unknown values reject with 400):
- `"git"` — the FN-7523 git-only behavior. The result (including a conflicting/unsupported result) is returned as-is and never creates a follow-up task.
- `"ai"` — skip git entirely and always create the AI-undo fallback task (FN-7524).
- `"auto"` — try git first. A clean/alreadyReverted/needsHuman result is returned unchanged. A conflicting or unsupported (e.g. workspace-task) result falls back to creating the AI-undo task.
- Git-path response contract (unchanged, additive only): `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch.
- AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one.
- Workspace (multi-repo) tasks return `unsupported` from the git path (routing `auto` to the AI-undo fallback); `autoMerge:false` projects return `needsHuman` and never trigger the AI-undo fallback (a human/future UI decides).
- No dashboard UI affordance ships with this yet (see the Done/Archived card action follow-up task).
## GitHub Issue Import and PR Creation

View File

@@ -6839,6 +6839,37 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return rows.map((row) => this.rowToTask(row));
}
/**
* FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 idempotency guard):
* Reverse lookup for `sourceMetadata.revertOf === sourceTaskId`, restricted
* to OPEN (non `done`/`archived`) tasks — mirrors the `nearDuplicateOf`
* reverse-lookup pattern above. Only an OPEN AI-undo task suppresses
* creating a new one: a prior undo task that itself reached `done`/`archived`
* must NOT block a fresh undo request (the work may need undoing again,
* e.g. redone then relanded). Returns the most recently created open match,
* or `null` when none exists.
*/
async findOpenRevertTaskForSource(sourceTaskId: string): Promise<Task | null> {
const trimmedId = sourceTaskId.trim();
if (trimmedId.length === 0) {
return null;
}
const selectClause = this.getTaskSelectClause(false, "t");
const row = this.db.prepare(`
SELECT ${selectClause}
FROM tasks t
WHERE t."deletedAt" IS NULL
AND t."column" != 'archived'
AND t."column" != 'done'
AND json_extract(t.sourceMetadata, '$.revertOf') = ?
ORDER BY t.createdAt DESC
LIMIT 1
`).get(trimmedId) as TaskRow | undefined;
return row ? this.rowToTask(row) : null;
}
/**
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
* FN-6439 requires the store to reconcile persisted duplicate flags after a canonical becomes inactive.

View File

@@ -44,6 +44,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
};
});
// FNXC:TaskRevert 2026-07-04-00:00 (FN-7524): `createAiUndoTask` is NOT mocked —
// these route tests exercise the real engine helper against a fake store
// (`createTask`/`findOpenRevertTaskForSource`), proving the route wires the
// AI-undo fallback correctly rather than merely asserting it was "called".
function makeTask(overrides: Partial<Task>): Task {
return {
id: "FN-100",
@@ -59,13 +64,34 @@ function makeTask(overrides: Partial<Task>): Task {
} as Task;
}
function createMockStore(task: Task): TaskStore {
function createMockStore(
task: Task,
opts?: { openUndoTask?: Task | null; createdUndoTask?: Task },
): TaskStore {
let nextId = 800;
const createTask = vi.fn().mockImplementation(async (input: { description: string; source?: { sourceParentTaskId?: string; sourceMetadata?: Record<string, unknown> } }) => {
const created = opts?.createdUndoTask ?? ({
id: `FN-${nextId++}`,
lineageId: `FN-${nextId}`,
description: input.description,
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata,
} as unknown as Task);
return created;
});
const findOpenRevertTaskForSource = vi.fn().mockResolvedValue(opts?.openUndoTask ?? null);
return {
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({ autoMerge: true }),
getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()),
getTask: vi.fn().mockResolvedValue(task),
getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]),
createTask,
findOpenRevertTaskForSource,
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
@@ -82,6 +108,10 @@ async function REQUEST(app: express.Express, method: string, path: string) {
return performRequest(app, method, path);
}
async function POST_JSON(app: express.Express, path: string, body: Record<string, unknown>) {
return performRequest(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
}
describe("POST /tasks/:id/revert", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -108,7 +138,7 @@ describe("POST /tasks/:id/revert", () => {
expect(res.body).toMatchObject({ mode: "git", clean: true, alreadyReverted: true });
});
it("returns a conflicting result without creating an AI-undo follow-up task", async () => {
it("mode:'git' returns a conflicting result without creating an AI-undo follow-up task (FN-7524: default mode is now 'auto', which DOES fall back to AI on conflict — explicit 'git' is required to preserve the FN-7523 git-only contract)", async () => {
const task = makeTask({ column: "done" });
const store = createMockStore(task);
performTaskRevertMock.mockResolvedValue({
@@ -117,7 +147,7 @@ describe("POST /tasks/:id/revert", () => {
conflicts: [{ file: "foo.ts", status: "UU" }],
});
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "git" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
mode: "git",
@@ -178,3 +208,110 @@ describe("POST /tasks/:id/revert", () => {
expect(performTaskRevertMock).not.toHaveBeenCalled();
});
});
// FN-7524 Symptom Verification: `{ mode }` request handling + the AI-undo fallback.
describe("POST /tasks/:id/revert — FN-7524 mode + AI-undo fallback", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("rejects an invalid mode value with 400 before invoking the engine service", async () => {
const task = makeTask({ column: "done" });
const store = createMockStore(task);
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "bogus" });
expect(res.status).toBe(400);
expect(performTaskRevertMock).not.toHaveBeenCalled();
expect((store.createTask as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
});
it("(a) auto + conflict: creates an AI-undo task and returns { mode: 'ai', createdTaskId }, stamped with the revertOf marker", async () => {
const task = makeTask({ id: "FN-901", column: "done" });
const store = createMockStore(task);
performTaskRevertMock.mockResolvedValue({
mode: "git",
clean: false,
conflicts: [{ file: "foo.ts", status: "UU" }],
});
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "ai" });
expect((res.body as { createdTaskId?: string }).createdTaskId).toBeTruthy();
expect(store.createTask as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
const createInput = (store.createTask as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
source?: { sourceParentTaskId?: string; sourceMetadata?: Record<string, unknown> };
};
expect(createInput.source?.sourceMetadata?.revertOf).toBe("FN-901");
});
it("(b) mode:'ai' forced: creates the AI-undo task without ever invoking the git path", async () => {
const task = makeTask({ id: "FN-902", column: "done" });
const store = createMockStore(task);
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "ai" });
expect(performTaskRevertMock).not.toHaveBeenCalled();
expect(store.createTask as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
});
it("(c) duplicate guard: a second call while an AI-undo task is already open returns the SAME createdTaskId and creates no duplicate", async () => {
const task = makeTask({ id: "FN-903", column: "done" });
const existingUndo = makeTask({ id: "FN-950", column: "triage", sourceParentTaskId: "FN-903", sourceMetadata: { revertOf: "FN-903" } });
const store = createMockStore(task, { openUndoTask: existingUndo });
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "ai", createdTaskId: "FN-950", alreadyOpen: true });
expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
});
it("(d) auto + clean: returns the git result and does NOT create an AI-undo task", async () => {
const task = makeTask({ id: "FN-904", column: "done" });
const store = createMockStore(task);
performTaskRevertMock.mockResolvedValue({ mode: "git", clean: true, revertCommitSha: "abc123" });
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "git", clean: true, revertCommitSha: "abc123" });
expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
});
it("mode:'git' on a conflicting result returns the raw conflict and NEVER creates an AI-undo task", async () => {
const task = makeTask({ id: "FN-905", column: "done" });
const store = createMockStore(task);
performTaskRevertMock.mockResolvedValue({
mode: "git",
clean: false,
conflicts: [{ file: "foo.ts", status: "UU" }],
});
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "git" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "git", clean: false });
expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
});
it("auto + unsupported (workspace) git result falls back to the AI-undo task", async () => {
const task = makeTask({ id: "FN-906", column: "done" });
const store = createMockStore(task);
performTaskRevertMock.mockResolvedValue({ mode: "git", unsupported: true, reason: "workspace-task-revert-unsupported" });
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "ai" });
expect(store.createTask as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
});
it("auto + needsHuman (autoMerge-off) returns the git result and does NOT create an AI-undo task", async () => {
const task = makeTask({ id: "FN-907", column: "done" });
const store = createMockStore(task);
performTaskRevertMock.mockResolvedValue({ mode: "git", needsHuman: true, reason: "autoMerge is disabled" });
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "git", needsHuman: true });
expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
});
});

View File

@@ -49,7 +49,14 @@ import {
import { GitHubClient } from "../github.js";
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
import { parseGitHubBadgeUrl } from "./register-git-github.js";
import { planTaskWorktreePath, promoteHeldTask, performTaskRevert, TaskRevertError } from "@fusion/engine";
import {
planTaskWorktreePath,
promoteHeldTask,
performTaskRevert,
TaskRevertError,
createAiUndoTask,
type AiUndoTaskResult,
} from "@fusion/engine";
import { buildBoardWorkflowsPayload } from "./board-workflows.js";
import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js";
import type { RunAuditEventInput } from "@fusion/core";
@@ -1663,16 +1670,29 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
});
/*
FNXC:TaskRevert 2026-07-04-00:00:
POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523, foundation
for FN-7501). Guard rails (enforced here AND in the engine service):
FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract):
POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523), with an
AI-undo fallback (FN-7524, foundation for FN-7501). Guard rails (enforced here AND in the
engine service):
- only done/archived tasks are revertable (400/409 otherwise);
- autoMerge-off is a needsHuman result, not a forced write;
- the source task's column/status is NEVER mutated as a side effect of a revert.
Response contract: `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`.
On conflict, this route does NOT create the AI-undo follow-up task — that is sibling FN-7524's
job; the UI/caller decides what to do with the conflict result. This route also never moves the
source task backward through its lifecycle.
- autoMerge-off is a needsHuman result, not a forced write, and NEVER triggers the AI fallback
(leave that for a human / sibling FN-7525 to decide);
- the source task's column/status is NEVER mutated as a side effect of a revert (git OR AI path).
Optional request body: `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`; unknown values reject
with 400). Semantics:
- `"git"` — FN-7523 behavior only; the git result (incl. a conflict/unsupported result) is
returned as-is and the AI-undo path is NEVER invoked.
- `"ai"` — skip git entirely; always take the AI-undo fallback.
- `"auto"` — attempt git first. A clean/alreadyReverted/needsHuman git result is returned
unchanged (NO AI task created). A conflicting or unsupported (e.g. workspace-task) git result
falls through to the AI-undo fallback.
Response contract is ADDITIVE over FN-7523: `{ mode: "git", ... }` (unchanged shape) OR
`{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via
`createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency
guard — a second call while an undo task is still open returns the SAME `createdTaskId` with
`alreadyOpen: true` rather than creating a duplicate.
*/
router.post("/tasks/:id/revert", async (req, res) => {
try {
@@ -1685,6 +1705,24 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw conflict(`Task ${task.id} is in column "${task.column}"; only done/archived tasks can be reverted`);
}
const requestedMode = (req.body as { mode?: unknown } | undefined)?.mode;
if (requestedMode !== undefined && requestedMode !== "git" && requestedMode !== "ai" && requestedMode !== "auto") {
throw badRequest(`Invalid revert mode "${String(requestedMode)}"; expected "git", "ai", or "auto"`);
}
const mode: "git" | "ai" | "auto" = (requestedMode as "git" | "ai" | "auto" | undefined) ?? "auto";
const createAiUndoResult = async (): Promise<AiUndoTaskResult> =>
createAiUndoTask({
createTask: (input) => scopedStore.createTask(input),
findOpenRevertTaskForSource: (id) => scopedStore.findOpenRevertTaskForSource(id),
sourceTask: task,
});
if (mode === "ai") {
res.json(await createAiUndoResult());
return;
}
const rootDir = scopedStore.getRootDir();
const settings = await scopedStore.getSettingsFast();
const baseBranch = task.mergeDetails?.mergeTargetBranch || await resolveIntegrationBranch(rootDir, settings);
@@ -1722,6 +1760,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
effectiveAutoMerge: settings.autoMerge,
});
if (mode === "git") {
res.json(result);
return;
}
// mode === "auto": fall back to the AI-undo task ONLY on conflict or an
// unsupported (e.g. workspace) git result. Clean/alreadyReverted/needsHuman
// results are returned as-is — needsHuman (autoMerge-off) NEVER triggers AI.
const shouldFallBackToAi =
(result.mode === "git" && "clean" in result && result.clean === false) ||
(result.mode === "git" && "unsupported" in result && result.unsupported === true);
if (shouldFallBackToAi) {
res.json(await createAiUndoResult());
return;
}
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { createAiUndoTask, buildAiUndoTaskDescription, REVERT_OF_METADATA_KEY } from "../task-revert.js";
import type { CreateAiUndoTaskDeps } from "../task-revert.js";
import type { Task, TaskCreateInput } from "@fusion/core";
function makeSourceTask(overrides: Partial<Task> = {}): CreateAiUndoTaskDeps["sourceTask"] {
return {
id: "FN-901",
title: "Add feature a",
description: "Add feature a to the widget renderer.",
prompt: undefined,
mergeDetails: { commitSha: "abc123", landedFiles: ["foo.ts", "bar.ts"] },
priority: "normal",
...overrides,
} as CreateAiUndoTaskDeps["sourceTask"];
}
function makeExistingTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-950",
lineageId: "FN-950",
description: "existing undo task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
...overrides,
} as Task;
}
describe("buildAiUndoTaskDescription (FN-7524)", () => {
it("references the source id, its mission, landed files, the diff pointer, preserve-unrelated instruction, and the revert() commit convention", () => {
const description = buildAiUndoTaskDescription({
task: {
id: "FN-901",
title: "Add feature a",
description: "Add feature a to the widget renderer.",
prompt: undefined,
mergeDetails: { commitSha: "abc", landedFiles: ["foo.ts", "bar.ts"] },
},
});
expect(description).toContain("FN-901");
expect(description).toContain("Add feature a to the widget renderer.");
expect(description).toContain("foo.ts");
expect(description).toContain("bar.ts");
expect(description).toContain("/api/tasks/FN-901/diff");
expect(description).toMatch(/preserv/i);
expect(description).toContain("revert(FN-901):");
expect(description).toContain("Fusion-Task-Id: FN-901");
});
it("prefers task.prompt over task.description for the mission text when present", () => {
const description = buildAiUndoTaskDescription({
task: {
id: "FN-902",
title: "t",
description: "short description",
prompt: "## Full generated mission\nDetailed spec content.",
mergeDetails: undefined,
},
});
expect(description).toContain("Detailed spec content.");
expect(description).not.toContain("short description");
});
it("handles a task with no recorded landed files", () => {
const description = buildAiUndoTaskDescription({
task: { id: "FN-903", title: "t", description: "d", prompt: undefined, mergeDetails: undefined },
});
expect(description).toMatch(/no landed-files list recorded/i);
});
});
describe("createAiUndoTask (FN-7524)", () => {
it("creates a dependency-free board task with the revertOf marker and returns { mode: 'ai', createdTaskId }", async () => {
const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({
id: "FN-960",
description: input.description,
dependencies: input.dependencies ?? [],
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata,
}));
const findOpenRevertTaskForSource = vi.fn(async () => null);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-960" });
expect(createTask).toHaveBeenCalledTimes(1);
const input = createTask.mock.calls[0][0] as TaskCreateInput;
expect(input.dependencies).toEqual([]);
expect(input.source?.sourceMetadata?.[REVERT_OF_METADATA_KEY]).toBe("FN-901");
expect(input.description).toContain("FN-901");
});
it("does not create a duplicate when an open AI-undo task already exists for the source (idempotency)", async () => {
const createTask = vi.fn();
const existing = makeExistingTask({ id: "FN-955", column: "triage" });
const findOpenRevertTaskForSource = vi.fn(async () => existing);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-955", alreadyOpen: true });
expect(createTask).not.toHaveBeenCalled();
});
});

View File

@@ -279,6 +279,11 @@ export {
type ClassifyTaskRevertResult,
type TaskRevertResult,
type TaskCommitAssociationSource,
createAiUndoTask,
buildAiUndoTaskDescription,
REVERT_OF_METADATA_KEY,
type AiUndoTaskResult,
type CreateAiUndoTaskDeps,
} from "./task-revert.js";
export {
resolveBranchGroupMergeRouting,

View File

@@ -22,7 +22,7 @@
*/
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { Task, TaskCommitAssociation } from "@fusion/core";
import type { Task, TaskCommitAssociation, TaskCreateInput } from "@fusion/core";
const defaultExecAsync = promisify(exec);
type ExecAsyncImpl = typeof defaultExecAsync;
@@ -522,3 +522,118 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise
throw error instanceof TaskRevertError ? error : new TaskRevertError("failed to apply revert commit", "revert-apply-failed", error);
}
}
// ────────────────────────────────────────────────────────────────────────
// FN-7524: AI-undo fallback
// ────────────────────────────────────────────────────────────────────────
/**
* FNXC:TaskRevert 2026-07-04-00:00 (AI-undo marker contract):
* `REVERT_OF_METADATA_KEY` is the idempotency key stamped onto an AI-undo
* board task's `source.sourceMetadata`. The route's dedup guard
* (`TaskStore.findOpenRevertTaskForSource`, core) scans OPEN (non
* done/archived) tasks for `sourceMetadata.revertOf === sourceTaskId` before
* creating a new one — a second `mode:"ai"`/conflict-fallback call for the
* same source task while an undo task is still open MUST return the existing
* task's id (`alreadyOpen: true`) instead of creating a duplicate. A prior
* undo task that has itself reached `done`/`archived` does NOT suppress a
* fresh one — the work may need undoing again (e.g. redone, then relanded).
* NEVER repurpose this key for another meaning.
*/
export const REVERT_OF_METADATA_KEY = "revertOf" as const;
export type AiUndoTaskResult = { mode: "ai"; createdTaskId: string; alreadyOpen?: boolean };
function formatLandedFiles(landedFiles: string[] | undefined): string {
if (!landedFiles || landedFiles.length === 0) {
return "(no landed-files list recorded on this task; inspect its merge commit(s) directly)";
}
return landedFiles.map((file) => `- ${file}`).join("\n");
}
/**
* FNXC:TaskRevert 2026-07-04-00:00 (AI-undo mission contract):
* Builds the triage-ready description for the AI-undo board task. References
* the source task's id, its mission (`task.prompt` when present, else
* `task.description` — `prompt` carries the fuller generated spec when
* available), its landed files (`mergeDetails.landedFiles`) plus a pointer to
* `GET /api/tasks/<id>/diff` for the full landed diff (reused, not
* recomputed), an explicit instruction to undo the BEHAVIOR/FILES the source
* task introduced while PRESERVING unrelated changes later tasks made to the
* same files, and the `revert(FN-xxxx): …` commit convention with a
* `Fusion-Task-Id: FN-xxxx` trailer referencing the ORIGINAL task (consistent
* with the git-path commit convention above `performTaskRevert`).
*/
export function buildAiUndoTaskDescription(params: {
task: Pick<Task, "id" | "title" | "description" | "prompt" | "mergeDetails">;
}): string {
const { task } = params;
const mission = task.prompt?.trim() ? task.prompt : task.description;
const landedFiles = task.mergeDetails?.landedFiles;
return [
`Undo the work landed by task ${task.id}${task.title ? ` — "${task.title}"` : ""}.`,
"",
"## Why this task exists",
`A direct \`git revert\` of ${task.id} could not be applied automatically (later commits conflict with it, the task's revert is unsupported, or AI-undo mode was explicitly requested). This task undoes the BEHAVIOR/FILES ${task.id} introduced WHILE PRESERVING unrelated changes made by later tasks that also touched the same files — do not blindly restore the pre-${task.id} version of any shared file.`,
"",
`## Original mission (${task.id})`,
mission,
"",
`## Files landed by ${task.id}`,
formatLandedFiles(landedFiles),
`See \`GET /api/tasks/${task.id}/diff\` for the full landed diff.`,
"",
"## What to do",
`1. Read ${task.id}'s original mission above and its landed diff.`,
`2. For each file ${task.id} touched, remove or reverse ONLY the behavior/changes it introduced. If a later task also modified the same file, preserve that later task's unrelated changes.`,
`3. Commit the undo work using the \`revert(${task.id}): <short summary>\` commit-message convention with a \`Fusion-Task-Id: ${task.id}\` trailer, so the commit stays attributable back to ${task.id} (mirrors the direct git-revert commit convention).`,
"4. Verify the original behavior is gone (tests/build) and that later, unrelated changes to the same files still work as intended.",
].join("\n");
}
/**
* FNXC:TaskRevert 2026-07-04-00:00 (dependency-free creation rule):
* The AI-undo task is created via the store's normal `createTask` path
* (lands in `triage`, gets its own generated PROMPT.md) with `dependencies: []`
* — it must NEVER depend on the source task. The source task is already
* done/archived; a dependency on it would be a permanently-satisfied no-op
* that misrepresents the relationship in dependency UIs.
*/
export interface CreateAiUndoTaskDeps {
createTask(input: TaskCreateInput): Promise<Task>;
/** Idempotency lookup — see `REVERT_OF_METADATA_KEY`. Implemented by `TaskStore.findOpenRevertTaskForSource` (core). */
findOpenRevertTaskForSource(sourceTaskId: string): Promise<Task | null>;
sourceTask: Pick<Task, "id" | "title" | "description" | "prompt" | "mergeDetails" | "priority">;
}
/**
* FNXC:TaskRevert 2026-07-04-00:00 (Step 1 entry point):
* Creates (or, if an open one already exists for this source task, returns
* the existing) AI-undo board task. This is the fallback the route uses when
* the git-revert path cannot apply cleanly / is unsupported, or when the
* caller explicitly requests `mode:"ai"`.
*/
export async function createAiUndoTask(deps: CreateAiUndoTaskDeps): Promise<AiUndoTaskResult> {
const { sourceTask } = deps;
// Idempotency FIRST — never create a duplicate while one is still open.
const existing = await deps.findOpenRevertTaskForSource(sourceTask.id);
if (existing) {
return { mode: "ai", createdTaskId: existing.id, alreadyOpen: true };
}
const description = buildAiUndoTaskDescription({ task: sourceTask });
const created = await deps.createTask({
title: `Undo ${sourceTask.id}: ${sourceTask.title ?? sourceTask.description.slice(0, 80)}`,
description,
dependencies: [],
priority: sourceTask.priority,
source: {
sourceType: "recovery",
sourceMetadata: { [REVERT_OF_METADATA_KEY]: sourceTask.id },
},
});
return { mode: "ai", createdTaskId: created.id };
}