Files
fusion/packages/engine/src/task-revert.ts
gsxdsm 53d7b7edcd FN-7523: add git-revert engine service and task revert API route
Adds an intelligent git-revert service for done/archived tasks plus a single POST /api/tasks/:id/revert route, with tests and a changeset.

- Add packages/engine/src/task-revert.ts exporting resolveTaskRevertCommits, classifyTaskRevert, and performTaskRevert (squash/rebase/lineage attribution precedence, dry-run classification, guaranteed-clean rollback)
- Wire new revert exports into packages/engine/src/index.ts
- Add POST /api/tasks/:id/revert route in register-task-workflow-routes.ts, enforcing done/archived-only and autoMerge-off guard rails; unresolved conflicting results are left for sibling FN-7524 (AI-undo) to act on; workspace tasks return unsupported
- Add engine real-git revert tests (task-revert.real-git.test.ts) and dashboard route tests (task-revert-route.test.ts)
- Document the revert capability in docs/task-management.md
- Add .changeset/fn-7523-task-revert.md (@runfusion/fusion: patch)

Files changed:
 .changeset/fn-7523-task-revert.md                  |   7 +
 docs/task-management.md                            |   8 +
 .../src/__tests__/task-revert-route.test.ts        | 180 +++++++
 .../src/routes/register-task-workflow-routes.ts    |  75 ++-
 .../src/__tests__/task-revert.real-git.test.ts     | 248 ++++++++++
 packages/engine/src/index.ts                       |  14 +
 packages/engine/src/task-revert.ts                 | 524 +++++++++++++++++++++
 7 files changed, 1055 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7523

Fusion-Task-Lineage: ec349d4f-cc05-48d1-9e82-8c14d1470881

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-04 21:28:18 -07:00

525 lines
23 KiB
TypeScript

/**
* FNXC:TaskRevert 2026-07-04-00:00:
* Intelligent git-revert service (FN-7523, foundation for FN-7501). Given a
* done/archived task, this module:
* 1. Resolves the set of commits attributable to that task (squash / rebase
* / lineage-snapshot precedence — see `resolveTaskRevertCommits`).
* 2. Performs a NON-committing dry-run revert to classify the outcome as
* already-reverted / clean / conflicting (see `classifyTaskRevert`).
* 3. When clean, creates the real revert commit(s) with a `Fusion-Task-Id`
* trailer on the resolved base branch (see `performTaskRevert`).
*
* This is the git path ONLY. Conflicting reverts are handed back to the
* caller/UI unresolved — the AI-undo fallback is sibling task FN-7524, and the
* UI affordance is sibling task FN-7525. Multi-repo workspace-task revert is
* out of scope here (see `resolveTaskRevertCommits`'s workspace guard).
*
* Safety invariant (the core contract of this module): the working tree and
* index are NEVER left dirty on any failure path. `classifyTaskRevert` always
* captures `preRevertHead` before touching the tree and guarantees a full
* `git revert --abort` + `git reset --hard <preRevertHead>` rollback in a
* `finally` block, regardless of how the dry-run terminates.
*/
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { Task, TaskCommitAssociation } from "@fusion/core";
const defaultExecAsync = promisify(exec);
type ExecAsyncImpl = typeof defaultExecAsync;
const GIT_TIMEOUT_MS = 30_000;
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
/** Minimal store surface this module depends on — keeps task-revert.ts test-friendly without pulling in the full TaskStore type. */
export interface TaskCommitAssociationSource {
getTaskCommitAssociationsByLineageId(lineageId: string): Promise<TaskCommitAssociation[]>;
}
export class TaskRevertError extends Error {
readonly code: string;
readonly cause?: unknown;
constructor(message: string, code: string, cause?: unknown) {
super(message);
this.name = "TaskRevertError";
this.code = code;
this.cause = cause;
}
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// FNXC:TaskRevert 2026-07-04-00:00:
// Reuse branch-attribution.ts's trailer/subject parsing so revert attribution
// stays consistent with merge-time attribution. Duplicated locally (rather
// than imported) because branch-attribution.ts's helpers are module-private;
// the regex/precedence MUST stay identical to branch-attribution.ts's
// `extractAttributedTaskId` / `extractTaskIdFromSubject` — update both together.
function extractAttributedTaskId(body: string): string | null {
const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim;
let match: RegExpExecArray | null = null;
let last: RegExpExecArray | null = null;
while (true) {
match = trailerPattern.exec(body);
if (!match) break;
last = match;
}
return last?.[1] ?? null;
}
function extractTaskIdFromSubject(subject: string): string | null {
if (!subject) return null;
const conventional =
/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject);
if (conventional?.[1]) return conventional[1].toUpperCase();
const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject);
if (bracketed?.[1]) return bracketed[1].toUpperCase();
const colon = /^\s*([A-Z]+-\d+):/i.exec(subject);
if (colon?.[1]) return colon[1].toUpperCase();
return null;
}
function taskIdsMatch(a: string | null, b: string): boolean {
if (!a) return false;
return a.toUpperCase() === b.toUpperCase();
}
async function runGit(
execImpl: ExecAsyncImpl,
command: string,
cwd: string,
): Promise<{ stdout: string; stderr: string }> {
const result = await execImpl(command, {
cwd,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") };
}
export type TaskRevertCommitSource = "squash" | "rebase" | "lineage" | "none";
export interface ResolvedTaskRevertCommits {
supported: true;
/** Attributable commit SHAs, newest first — reverting in this order applies the oldest change last, avoiding unnecessary self-conflicts among a task's own commits. */
shas: string[];
source: TaskRevertCommitSource;
}
export interface UnsupportedTaskRevert {
supported: false;
reason: string;
}
export interface ResolveTaskRevertCommitsOptions {
worktreePath: string;
execAsyncImpl?: ExecAsyncImpl;
/** Lineage-snapshot fallback source (typically the scoped TaskStore). Optional so callers that already know mergeDetails is present can omit it. */
commitAssociationSource?: TaskCommitAssociationSource;
}
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* Attribution precedence (mirrors merge-time attribution capture):
* 1. Squash strategy — `mergeDetails.commitSha` alone when `rebaseBaseSha`
* is unset (single squash commit landed the task).
* 2. Rebase/cherry-pick strategy — the task-attributable subset of
* `rebaseBaseSha..commitSha`, filtered by `Fusion-Task-Id` trailer or
* conventional-commit subject. Falls back to the full range endpoint
* (`commitSha`) when no per-commit attribution is possible (foreign
* commits interleaved with no trailer/subject match — better to revert
* the endpoint than nothing).
* 3. Lineage snapshot fallback — `TaskCommitAssociation` rows keyed by
* `taskLineageId`, used when `mergeDetails` is absent/incomplete (e.g.
* legacy tasks merged before mergeDetails was captured).
*
* FNXC:TaskRevert 2026-07-04-00:00 (workspace limitation):
* Workspace tasks (`mergeDetails.workspaceLandedShas` present) land commits
* across MULTIPLE sub-repo integration branches with no single coherent
* revert target — reverting one sub-repo's commit without reasoning about the
* others could leave the workspace in a half-reverted, inconsistent state.
* This is explicitly out of scope for FN-7523; the caller should route
* workspace tasks to the AI-undo fallback (FN-7524) instead.
*/
export async function resolveTaskRevertCommits(
task: Pick<Task, "id" | "lineageId" | "mergeDetails">,
opts: ResolveTaskRevertCommitsOptions,
): Promise<ResolvedTaskRevertCommits | UnsupportedTaskRevert> {
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
const mergeDetails = task.mergeDetails;
if (mergeDetails?.workspaceLandedShas && Object.keys(mergeDetails.workspaceLandedShas).length > 0) {
return { supported: false, reason: "workspace-task-revert-unsupported" };
}
if (mergeDetails?.commitSha) {
if (!mergeDetails.rebaseBaseSha) {
// Squash strategy: the single recorded commit is the entire landed change.
return { supported: true, shas: [mergeDetails.commitSha], source: "squash" };
}
// Rebase/cherry-pick strategy: filter the range to this task's own commits.
const rangeRef = `${mergeDetails.rebaseBaseSha}..${mergeDetails.commitSha}`;
let logOutput: string;
try {
const { stdout } = await runGit(
execImpl,
`git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(rangeRef)}`,
opts.worktreePath,
);
logOutput = stdout;
} catch (error) {
throw new TaskRevertError(`git log failed for range ${rangeRef}`, "git-log-failed", error);
}
const ownCommitShas: string[] = [];
const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean);
for (const record of records) {
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
if (!sha) continue;
const body = bodyParts.join("\x00");
const trailerAttributedTaskId = extractAttributedTaskId(body);
const attributedTaskId = trailerAttributedTaskId ?? extractTaskIdFromSubject(subject);
if (taskIdsMatch(attributedTaskId, task.id)) {
ownCommitShas.push(sha);
}
}
if (ownCommitShas.length > 0) {
// `git log` without `--reverse` already yields newest-first order.
return { supported: true, shas: ownCommitShas, source: "rebase" };
}
// No per-commit attribution possible (foreign commits interleaved with no
// trailer/subject match) — fall back to reverting the full range endpoint.
return { supported: true, shas: [mergeDetails.commitSha], source: "rebase" };
}
// mergeDetails absent/incomplete — fall back to the lineage-snapshot association table.
const lineageId = task.lineageId ?? task.id;
if (!opts.commitAssociationSource) {
return { supported: true, shas: [], source: "none" };
}
const associations = await opts.commitAssociationSource.getTaskCommitAssociationsByLineageId(lineageId);
if (associations.length === 0) {
return { supported: true, shas: [], source: "none" };
}
// Rows are already ordered `authoredAt DESC, createdAt DESC` (newest first) by the store query.
return { supported: true, shas: associations.map((a) => a.commitSha), source: "lineage" };
}
export type TaskRevertClassification = "already-reverted" | "clean" | "conflicting";
export interface TaskRevertConflict {
file: string;
/** Raw `git status --porcelain` two-letter status code for the conflicted file (e.g. "UU", "AA"). */
status?: string;
}
export interface ClassifyTaskRevertResult {
classification: TaskRevertClassification;
conflicts?: TaskRevertConflict[];
alreadyReverted?: boolean;
}
export interface ClassifyTaskRevertOptions {
worktreePath: string;
/** Attributable commit SHAs, newest first (see `resolveTaskRevertCommits`). */
commits: string[];
execAsyncImpl?: ExecAsyncImpl;
}
async function getUnmergedFiles(
execImpl: ExecAsyncImpl,
worktreePath: string,
): Promise<TaskRevertConflict[]> {
const { stdout } = await runGit(execImpl, "git status --porcelain", worktreePath);
const conflicts: TaskRevertConflict[] = [];
for (const line of stdout.split("\n")) {
if (!line.trim()) continue;
const status = line.slice(0, 2);
// Unmerged states per `git status --porcelain`: UU, AA, DD, AU, UA, UD, DU.
if (/^(UU|AA|DD|AU|UA|UD|DU)$/.test(status)) {
conflicts.push({ file: line.slice(3).trim(), status });
}
}
return conflicts;
}
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* Rollback safety contract (the core invariant of this service): capture
* `preRevertHead` BEFORE any git mutation. If the working tree is dirty at
* entry, refuse immediately without touching anything. Otherwise, run the
* dry-run revert sequence; regardless of outcome (clean, conflicting, or an
* unexpected error), the `finally` block runs `git revert --abort`
* (best-effort) THEN `git reset --hard <preRevertHead>` so the tree/index are
* byte-identical to the pre-call state. This function NEVER commits and NEVER
* throws without first completing that rollback.
*/
export async function classifyTaskRevert(opts: ClassifyTaskRevertOptions): Promise<ClassifyTaskRevertResult> {
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
const { worktreePath, commits } = opts;
if (commits.length === 0) {
return { classification: "already-reverted", alreadyReverted: true };
}
let preRevertHead: string;
try {
const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
preRevertHead = stdout.trim();
} catch (error) {
throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed", error);
}
if (!preRevertHead) {
throw new TaskRevertError("failed to resolve HEAD before revert dry-run", "head-resolve-failed");
}
const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", worktreePath);
if (statusOut.trim().length > 0) {
throw new TaskRevertError(
"working tree is dirty; refusing to attempt a revert dry-run",
"dirty-working-tree",
);
}
let mutated = false;
let allAlreadyReverted = true;
const conflicts: TaskRevertConflict[] = [];
try {
for (const sha of commits) {
mutated = true;
const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
try {
await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath);
// FNXC:TaskRevert 2026-07-04-00:00: `git revert --no-commit` on an
// already-reverted commit exits 0 with NO staged/working-tree diff
// (no error, no "nothing to commit" text — that message only ever
// appears from a *subsequent* `git commit` attempt). Detect this by
// diffing `git status --porcelain` before/after the call: if nothing
// changed, this sha is a no-op; `--quit` clears the sequencer's
// in-progress marker WITHOUT touching any diff staged by earlier shas
// in this same batch (unlike `--abort`, which would reset everything).
const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
if (statusAfter === statusBefore) {
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
continue;
}
allAlreadyReverted = false;
} catch (error) {
const stderr =
typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string"
? (error as { stderr: string }).stderr
: "";
const stdout =
typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string"
? (error as { stdout: string }).stdout
: "";
const combined = `${stdout}\n${stderr}`;
const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath);
if (unmergedFiles.length > 0) {
conflicts.push(...unmergedFiles);
allAlreadyReverted = false;
break;
}
// "nothing to commit" / empty-revert signal: this commit's change is
// already reflected as reverted at HEAD — treat as a no-op and continue.
if (/nothing to commit|no changes|empty commit/i.test(combined)) {
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
continue;
}
throw new TaskRevertError(`git revert --no-commit failed unexpectedly for ${sha}`, "revert-dry-run-failed", error);
}
}
} finally {
if (mutated) {
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
}
}
if (conflicts.length > 0) {
return { classification: "conflicting", conflicts };
}
if (allAlreadyReverted) {
return { classification: "already-reverted", alreadyReverted: true };
}
return { classification: "clean" };
}
export type TaskRevertResult =
| { mode: "git"; clean: true; revertCommitSha: string }
| { mode: "git"; clean: true; alreadyReverted: true }
| { mode: "git"; clean: false; conflicts: TaskRevertConflict[] }
| { mode: "git"; unsupported: true; reason: string }
| { mode: "git"; needsHuman: true; reason: string };
export interface PerformTaskRevertOptions {
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "autoMerge" | "userPaused" | "paused">;
worktreePath: string;
baseBranch: string;
execAsyncImpl?: ExecAsyncImpl;
commitAssociationSource?: TaskCommitAssociationSource;
/** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */
effectiveAutoMerge?: boolean;
}
// FNXC:TaskRevert 2026-07-04-00:00 (guard rails, enforced in BOTH the service
// and the route per PROMPT Step 3): only done/archived tasks are revertable.
const REVERTABLE_COLUMNS = new Set(["done", "archived"]);
/**
* FNXC:TaskRevert 2026-07-04-00:00 (commit message/trailer contract):
* The revert commit's subject is `revert(FN-xxxx): <short summary>` (the
* original task's id + a short summary derived from the reverted commit's
* subject), with a body carrying `Fusion-Task-Id: FN-xxxx` (the ORIGINAL
* task id, so `extractAttributedTaskId` continues to resolve attribution back
* to the reverted task) and a `Reverts work landed by task FN-xxxx (<sha>).`
* line for human audit. This mirrors the commit-message conventions in
* AGENTS.md (task-id-prefixed subjects, `Fusion-Task-Id` trailer).
*
* FNXC:TaskRevert 2026-07-04-00:00 (guard rails):
* - Only `done`/`archived` tasks may be reverted (checked here AND at the API
* route layer — defense in depth).
* - When `autoMerge` is effectively off for this task, this function refuses
* with a `needsHuman` result instead of force-writing a revert commit onto
* a branch the project has opted out of automated writes to.
* - This function NEVER mutates the source task's store row/column — reverting
* is a forward-only git operation on the base branch, not a lifecycle move.
*/
export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise<TaskRevertResult> {
// FNXC:TaskRevert 2026-07-04-00:00: `baseBranch` is part of the stable
// caller-facing contract (the route resolves it via mergeTargetBranch /
// the integration-branch resolver) but is not read here directly — the
// caller is responsible for ensuring `worktreePath` is checked out at that
// branch's HEAD before invoking this function; kept as a named, documented
// parameter (not silently dropped) for FN-7524/FN-7525 call-site clarity.
const { task, worktreePath, baseBranch: _baseBranch } = opts;
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
if (!REVERTABLE_COLUMNS.has(task.column)) {
return { mode: "git", needsHuman: true, reason: `task is in column "${task.column}"; only done/archived tasks are revertable` };
}
const effectiveAutoMerge = task.autoMerge ?? opts.effectiveAutoMerge ?? true;
if (effectiveAutoMerge === false) {
return { mode: "git", needsHuman: true, reason: "autoMerge is disabled for this task/project; refusing to force-write a revert commit" };
}
const resolved = await resolveTaskRevertCommits(task, {
worktreePath,
execAsyncImpl: execImpl,
commitAssociationSource: opts.commitAssociationSource,
});
if (!resolved.supported) {
return { mode: "git", unsupported: true, reason: resolved.reason };
}
const classification = await classifyTaskRevert({
worktreePath,
commits: resolved.shas,
execAsyncImpl: execImpl,
});
if (classification.classification === "already-reverted") {
return { mode: "git", clean: true, alreadyReverted: true };
}
if (classification.classification === "conflicting") {
return { mode: "git", clean: false, conflicts: classification.conflicts ?? [] };
}
// classification === "clean" — perform the real (committing) revert.
let preRevertHead: string;
try {
const { stdout } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
preRevertHead = stdout.trim();
} catch (error) {
throw new TaskRevertError("failed to resolve HEAD before applying revert", "head-resolve-failed", error);
}
let mutated = false;
let anyStaged = false;
try {
for (const sha of resolved.shas) {
mutated = true;
const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
try {
await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath);
// FNXC:TaskRevert 2026-07-04-00:00: mirror classifyTaskRevert's
// status-diff no-op detection here — `git revert --no-commit` on an
// already-reverted commit exits 0 with no staged diff (no thrown
// error, no "nothing to commit" text on this call). `--quit` clears
// the sequencer marker without disturbing diff staged by earlier
// shas in this batch.
const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout;
if (statusAfter === statusBefore) {
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
continue;
}
anyStaged = true;
} catch (error) {
const stderr =
typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string"
? (error as { stderr: string }).stderr
: "";
const stdout =
typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string"
? (error as { stdout: string }).stdout
: "";
if (/nothing to commit|no changes|empty commit/i.test(`${stdout}\n${stderr}`)) {
await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined);
continue;
}
// The dry-run already proved this is clean; a live conflict here means
// the branch moved between classify and apply. Roll back and report conflicting.
const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath);
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
return { mode: "git", clean: false, conflicts: unmergedFiles };
}
}
if (!anyStaged) {
// Defensive: every sha in this batch turned out to be a no-op during the
// apply pass even though classify saw at least one real change (branch
// moved between classify and apply, or a race). Nothing to commit —
// report already-reverted rather than attempting an empty commit.
return { mode: "git", clean: true, alreadyReverted: true };
}
let originalSubject = "";
try {
const { stdout } = await runGit(execImpl, `git log -1 --format=%s ${quoteShellArg(resolved.shas[0] ?? "HEAD")}`, worktreePath);
originalSubject = stdout.trim();
} catch {
originalSubject = "";
}
const shortSummary = originalSubject.replace(/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style)\([^)]*\):\s*/i, "").slice(0, 72) || "revert landed changes";
const subject = `revert(${task.id}): ${shortSummary}`;
const referencedSha = resolved.shas[0] ?? "unknown";
const body1 = `Fusion-Task-Id: ${task.id}`;
const body2 = `Reverts work landed by task ${task.id} (${originalSubject || referencedSha} @ ${referencedSha.slice(0, 8)}).`;
await runGit(
execImpl,
`git commit -m ${quoteShellArg(subject)} -m ${quoteShellArg(body1)} -m ${quoteShellArg(body2)}`,
worktreePath,
);
const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath);
return { mode: "git", clean: true, revertCommitSha: newHead.trim() };
} catch (error) {
if (mutated) {
await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined);
await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined);
}
throw error instanceof TaskRevertError ? error : new TaskRevertError("failed to apply revert commit", "revert-apply-failed", error);
}
}