From c4d81fe5cc5f4e65fcbaf870f3657513cddc4957 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 21:42:55 -0700 Subject: [PATCH] FN-7524: add AI-undo fallback task for reverting done/archived tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/fn-7524-ai-undo-revert.md | 7 + docs/task-management.md | 13 +- packages/core/src/store.ts | 31 ++++ .../src/__tests__/task-revert-route.test.ts | 143 +++++++++++++++++- .../routes/register-task-workflow-routes.ts | 75 +++++++-- .../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(-) create mode 100644 .changeset/fn-7524-ai-undo-revert.md create mode 100644 packages/engine/src/__tests__/task-revert-ai-undo.test.ts diff --git a/.changeset/fn-7524-ai-undo-revert.md b/.changeset/fn-7524-ai-undo-revert.md new file mode 100644 index 0000000000..3a78674fac --- /dev/null +++ b/.changeset/fn-7524-ai-undo-revert.md @@ -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). diff --git a/docs/task-management.md b/docs/task-management.md index b6dd2dc253..fbe810479a 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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 diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 03bf74d46d..540e641f62 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { + 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. diff --git a/packages/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index 78241e5481..7c90e1a470 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -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 { return { id: "FN-100", @@ -59,13 +64,34 @@ function makeTask(overrides: Partial): 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 } }) => { + 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) { + 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)).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).toHaveBeenCalledTimes(1); + const createInput = (store.createTask as ReturnType).mock.calls[0][0] as { + source?: { sourceParentTaskId?: string; sourceMetadata?: Record }; + }; + 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).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).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).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).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).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).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 2e5596762c..feeae9fc52 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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 => + 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) { diff --git a/packages/engine/src/__tests__/task-revert-ai-undo.test.ts b/packages/engine/src/__tests__/task-revert-ai-undo.test.ts new file mode 100644 index 0000000000..d4eadf5048 --- /dev/null +++ b/packages/engine/src/__tests__/task-revert-ai-undo.test.ts @@ -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 = {}): 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 { + 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(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 44eead5ee3..9f95db5771 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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, diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts index 74edee2bbb..1a36e101ce 100644 --- a/packages/engine/src/task-revert.ts +++ b/packages/engine/src/task-revert.ts @@ -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//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; +}): 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}): \` 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; + /** Idempotency lookup — see `REVERT_OF_METADATA_KEY`. Implemented by `TaskStore.findOpenRevertTaskForSource` (core). */ + findOpenRevertTaskForSource(sourceTaskId: string): Promise; + sourceTask: Pick; +} + +/** + * 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 { + 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 }; +}