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:
7
.changeset/fn-7524-ai-undo-revert.md
Normal file
7
.changeset/fn-7524-ai-undo-revert.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
114
packages/engine/src/__tests__/task-revert-ai-undo.test.ts
Normal file
114
packages/engine/src/__tests__/task-revert-ai-undo.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user