FN-9047: invalidate stale workspace landing proof after revert

Preserve workspace landing attribution while allowing cleanly reverted work to land again.

- Record a per-repository revert boundary after clean git-mode workspace reverts.
- Reject recorded and trailer landing proof at or behind that boundary, then clear it on a fresh landing.
- Persist boundaries through the revert route and cover re-land behavior with git tests.

Files changed:
 .changeset/fn-9047-workspace-revert-landed-sha.md  |  7 +++
 docs/architecture.md                               |  2 +
 packages/core/src/types/task/task-core.ts          |  7 ++-
 .../src/routes/register-session-diff-routes.ts     |  2 +-
 .../src/routes/register-task-workflow-routes.ts    | 11 ++++
 .../task-revert.workspace.real-git.test.ts         | 27 ++++++++++
 ...orkspace-land-predicate.revert-boundary.test.ts | 60 ++++++++++++++++++++++
 packages/engine/src/execution/task-revert.ts       | 42 +++++++++++++--
 packages/engine/src/index.ts                       |  1 +
 packages/engine/src/merge/merger-ai.ts             |  5 +-
 .../engine/src/merge/workspace-land-predicate.ts   | 25 +++++++--
 packages/engine/src/self-healing.ts                |  9 +++-
 12 files changed, 185 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-9047

Fusion-Task-Lineage: 8ac41347-3c76-4f54-8d69-f16f82084305

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 00:02:52 -07:00
parent 4bc0a32d60
commit 1372218b1f
12 changed files with 185 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Re-land workspace task work after a clean revert instead of silently skipping it.
category: fix
dev: Records per-repository `revertBoundarySha` and invalidates stale workspace landing proof.

View File

@@ -2417,3 +2417,5 @@ Writers use the `AsyncDataLayer` outbox seam because AgentStore and approval sto
Paused-safe housekeeping retains at most 30 days and 50,000 rows per project. The [Agent activity API contract](agent-activity-contract.md) defines the inspectable `GET /api/agent-activity` wire, cursor, and continuation behavior. SSE tails durable rows in ascending seq pages, advances only after sending, serializes reentrant drains, and emits a bounded truncation marker for oversized backlogs; in-process events are latency nudges only, preserving reconnect correctness across processes.
Scheduler and autopilot mission reconciliation persist the evaluated alignment on linked mission features, including no-delivery-transition outcomes, so roadmap readers consume a durable projection rather than recomputing task reports in the browser.
**Workspace revert land idempotency (FN-9047).** A clean git-mode workspace revert preserves each sub-repository `landedSha` for commit attribution and session-diff ranges, but records `workspaceWorktrees[repo].revertBoundarySha` at the integration commit created by the revert (or the pre-revert integration HEAD when the repo was already reverted). The canonical workspace landed predicate rejects recorded-SHA and `Fusion-Task-Id` trailer proof at or behind that boundary; a subsequent successful land writes its fresh `landedSha` and clears the boundary. PR-mode, conflicting, unsupported, and human-required reverts do not set boundaries because they do not advance an integration ref.

View File

@@ -717,8 +717,13 @@ export interface Task {
* present AND whose recorded value is an ancestor of (or equals) the repo's
* integration tip, so an interrupted multi-repo land retries only the un-landed
* repos and never re-advances an already-landed ref (idempotent retry).
*
* FNXC:Workspace 2026-08-15-06:45:
* `revertBoundarySha` is the integration-branch commit after a completed git-mode revert
* (the revert commit, or pre-revert HEAD when already reverted). A proven landing at or behind
* it is stale and must re-land; `landedSha` remains for attribution and diff consumers.
*/
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string; landedSha?: string }>;
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string; landedSha?: string; revertBoundarySha?: string }>;
steps: TaskStep[];
currentStep: number;
/**

View File

@@ -531,7 +531,7 @@ async function computeWorkspaceTaskFiles(
task: {
id: string;
baseBranch?: string;
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string; landedSha?: string }>;
workspaceWorktrees?: Record<string, { worktreePath: string; branch: string; baseCommitSha?: string; landedSha?: string; revertBoundarySha?: string }>;
},
rootDir: string,
timeoutMs: number,

View File

@@ -98,6 +98,7 @@ import {
evaluateTaskReleaseGate,
performTaskRevert,
revertWorkspaceTask,
applyWorkspaceRevertBoundaries,
TaskRevertError,
createAiUndoTask,
prepareRevertPrBranch,
@@ -3037,6 +3038,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
});
if (workspaceResult.mode === "git" && "clean" in workspaceResult && workspaceResult.clean === true) {
// FNXC:Workspace 2026-08-15-06:45:
// The store-free revert service reports boundaries; persist them from a fresh task read so
// trailer/landedSha proof at or behind a git-mode revert cannot skip re-done sub-repo work.
const latest = await scopedStore.getTask(task.id);
if (!latest) throw new TaskRevertError("task disappeared while persisting workspace revert boundaries", "task-not-found");
const workspaceWorktrees = applyWorkspaceRevertBoundaries(
latest.workspaceWorktrees,
workspaceResult.workspace.repos,
);
await scopedStore.updateTask(task.id, { workspaceWorktrees });
await stampReverted();
}

View File

@@ -4,10 +4,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
applyWorkspaceRevertBoundaries,
resolveWorkspaceTaskRevertCommits,
revertWorkspaceTask,
} from "../execution/task-revert.js";
import type { Task } from "@fusion/core";
import { findProvenLandedCommit, isRepoLanded } from "../merge/workspace-land-predicate.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
@@ -205,6 +207,9 @@ describeIfGit("task-revert workspace real-git scenarios", { timeout: 30_000 }, (
expect(byRepo["repo-a"].revertCommitSha).toBeTruthy();
expect(byRepo["repo-b"].classification).toBe("clean");
expect(byRepo["repo-b"].revertCommitSha).toBeTruthy();
const nextWorktrees = applyWorkspaceRevertBoundaries(task.workspaceWorktrees, result.workspace.repos);
expect(nextWorktrees?.["repo-a"]?.revertBoundarySha).toBe(byRepo["repo-a"].revertCommitSha);
expect(nextWorktrees?.["repo-b"]?.revertBoundarySha).toBe(byRepo["repo-b"].revertCommitSha);
}
for (const repoRootDir of [repoA, repoB]) {
@@ -218,6 +223,28 @@ describeIfGit("task-revert workspace real-git scenarios", { timeout: 30_000 }, (
expect(git(repoB, "git show HEAD:b.ts")).toBe("line1");
});
it("revert then rerun invalidates both recorded and trailer landing proof across sub-repos", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "land\n\nFusion-Task-Id: FN-A");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-b\n", "land\n\nFusion-Task-Id: FN-A");
const task = makeWorkspaceTask(shaA, shaB);
const reverted = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} });
expect(reverted).toMatchObject({ mode: "git", clean: true });
if (reverted.mode !== "git" || !reverted.clean) throw new Error("expected clean revert");
const worktrees = applyWorkspaceRevertBoundaries(task.workspaceWorktrees, reverted.workspace.repos)!;
for (const [repoRel, repoRootDir] of [["repo-a", repoA], ["repo-b", repoB]] as const) {
const entry = worktrees[repoRel]!;
expect(await isRepoLanded(repoRootDir, "main", entry.landedSha, "FN-A", undefined, entry.revertBoundarySha)).toBe(false);
expect(await findProvenLandedCommit(repoRootDir, "main", undefined, "FN-A", undefined, entry.revertBoundarySha)).toBeUndefined();
}
const reLandedA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a-redone\n", "reland\n\nFusion-Task-Id: FN-A");
const reLandedB = landTaskCommit(repoB, "b.ts", "line1\nfeature-b-redone\n", "reland\n\nFusion-Task-Id: FN-A");
expect(await findProvenLandedCommit(repoA, "main", reLandedA, "FN-A", undefined, worktrees["repo-a"]!.revertBoundarySha)).toBe(reLandedA);
expect(await findProvenLandedCommit(repoB, "main", reLandedB, "FN-A", undefined, worktrees["repo-b"]!.revertBoundarySha)).toBe(reLandedB);
});
it("partial-conflict rollback: a later task touching repo-b only leaves BOTH repos byte-identical to pre-call (Symptom Verification)", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");

View File

@@ -0,0 +1,60 @@
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { findProvenLandedCommit, isRepoLanded } from "../merge/workspace-land-predicate.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
/*
FNXC:Workspace 2026-08-15-06:45:
A git-mode revert keeps historical landedSha for attribution, but its integration-tip boundary
invalidates both recorded-SHA and task-trailer proof until a newer task landing is created.
*/
describeIfGit("workspace land predicate revert boundary", () => {
const dirs: string[] = [];
afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })));
function fixture(): string {
const repo = mkdtempSync(join(tmpdir(), "fn-9047-predicate-"));
dirs.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
writeFileSync(join(repo, "file.txt"), "base\n");
git(repo, "git add file.txt && git commit -m init");
return repo;
}
function commit(repo: string, content: string, subject: string, trailer = false): string {
writeFileSync(join(repo, "file.txt"), content);
git(repo, "git add file.txt");
git(repo, `git commit -m ${JSON.stringify(subject)}${trailer ? " -m 'Fusion-Task-Id: FN-A'" : ""}`);
return git(repo, "git rev-parse HEAD");
}
it("invalidates recorded and trailer landing proof at the revert boundary, then accepts a new landing", async () => {
const repo = fixture();
const landed = commit(repo, "landed\n", "land", true);
const boundary = commit(repo, "reverted\n", "revert", true);
await expect(isRepoLanded(repo, "main", landed, "FN-A", undefined, boundary)).resolves.toBe(false);
await expect(findProvenLandedCommit(repo, "main", undefined, "FN-A", undefined, boundary)).resolves.toBeUndefined();
await expect(isRepoLanded(repo, "main", landed, "FN-A")).resolves.toBe(true);
const relanded = commit(repo, "relanded\n", "reland", true);
await expect(findProvenLandedCommit(repo, "main", relanded, "FN-A", undefined, boundary)).resolves.toBe(relanded);
});
it("keeps legacy behavior for an unresolvable boundary", async () => {
const repo = fixture();
const landed = commit(repo, "landed\n", "land", true);
await expect(isRepoLanded(repo, "main", landed, "FN-A", undefined, "not-a-sha")).resolves.toBe(true);
});
});

View File

@@ -1058,10 +1058,30 @@ export interface WorkspaceRepoRevertResult {
repo: string;
classification: TaskRevertClassification;
revertCommitSha?: string;
/** The integration tip that invalidates prior landing proof after a git-mode revert. */
revertBoundarySha?: string;
/** Lets callers leave repos with no attributable task work untouched. */
attributedCommitCount: number;
conflicts?: TaskRevertConflict[];
alreadyReverted?: boolean;
}
/** Apply only successful git-mode revert boundaries; this service remains store-free. */
export function applyWorkspaceRevertBoundaries(
currentWorktrees: Task["workspaceWorktrees"] | undefined,
repoResults: readonly WorkspaceRepoRevertResult[],
): Task["workspaceWorktrees"] | undefined {
if (!currentWorktrees) return currentWorktrees;
const next = { ...currentWorktrees };
for (const result of repoResults) {
const entry = next[result.repo];
if (entry && result.attributedCommitCount > 0 && result.revertBoundarySha) {
next[result.repo] = { ...entry, revertBoundarySha: result.revertBoundarySha };
}
}
return next;
}
export type WorkspaceTaskRevertResult =
| { mode: "git"; clean: true; workspace: { repos: WorkspaceRepoRevertResult[] } }
| { mode: "git"; clean: false; workspace: { repos: WorkspaceRepoRevertResult[] }; conflicts: (TaskRevertConflict & { repo: string })[] }
@@ -1215,6 +1235,7 @@ export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Pro
classification: ctx.classification.classification,
conflicts: ctx.classification.conflicts,
alreadyReverted: ctx.classification.alreadyReverted,
attributedCommitCount: ctx.commits.length,
}));
const conflicts = contexts.flatMap((ctx) =>
(ctx.classification.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo })),
@@ -1231,7 +1252,11 @@ export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Pro
try {
for (const ctx of contexts) {
if (ctx.classification.classification === "already-reverted" || ctx.commits.length === 0) {
repos.push({ repo: ctx.repo, classification: "already-reverted", alreadyReverted: true });
repos.push({
repo: ctx.repo, classification: "already-reverted", alreadyReverted: true,
attributedCommitCount: ctx.commits.length,
revertBoundarySha: ctx.commits.length > 0 ? ctx.preRevertHead : undefined,
});
continue;
}
@@ -1243,13 +1268,20 @@ export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Pro
});
if (applied.applied) {
repos.push({ repo: ctx.repo, classification: "clean", revertCommitSha: applied.revertCommitSha });
repos.push({
repo: ctx.repo, classification: "clean", revertCommitSha: applied.revertCommitSha,
revertBoundarySha: applied.revertCommitSha, attributedCommitCount: ctx.commits.length,
});
committedRepos.push({ repo: ctx.repo, repoRootDir: ctx.repoRootDir, preRevertHead: ctx.preRevertHead });
continue;
}
if ("alreadyReverted" in applied) {
repos.push({ repo: ctx.repo, classification: "already-reverted", alreadyReverted: true });
repos.push({
repo: ctx.repo, classification: "already-reverted", alreadyReverted: true,
attributedCommitCount: ctx.commits.length,
revertBoundarySha: ctx.commits.length > 0 ? ctx.preRevertHead : undefined,
});
continue;
}
@@ -1262,7 +1294,7 @@ export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Pro
}
const conflictRepos: WorkspaceRepoRevertResult[] = [
...repos,
{ repo: ctx.repo, classification: "conflicting", conflicts: applied.conflicts },
{ repo: ctx.repo, classification: "conflicting", conflicts: applied.conflicts, attributedCommitCount: ctx.commits.length },
];
const conflicts = (applied.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo }));
return { mode: "git", clean: false, workspace: { repos: conflictRepos }, conflicts };
@@ -1489,6 +1521,7 @@ export async function prepareWorkspaceRevertPrBranches(
classification: ctx.classification.classification,
conflicts: ctx.classification.conflicts,
alreadyReverted: ctx.classification.alreadyReverted,
attributedCommitCount: ctx.commits.length,
}));
const conflicts = contexts.flatMap((ctx) =>
(ctx.classification.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo })),
@@ -1534,6 +1567,7 @@ export async function prepareWorkspaceRevertPrBranches(
classification: c.repo === ctx.repo ? "conflicting" : c.classification.classification,
conflicts: c.repo === ctx.repo ? outcome.conflicts : c.classification.conflicts,
alreadyReverted: c.classification.alreadyReverted,
attributedCommitCount: c.commits.length,
}));
return { eligible: false, classification: "conflicting", conflicts, repos };
}

View File

@@ -430,6 +430,7 @@ export {
type WorkspaceRepoRevertResult,
type WorkspaceTaskRevertResult,
type RevertWorkspaceTaskOptions,
applyWorkspaceRevertBoundaries,
prepareRevertPrBranch,
type PrepareRevertPrBranchResult,
type PrepareRevertPrBranchOptions,

View File

@@ -2026,6 +2026,7 @@ export async function landWorkspaceTask(
entry.landedSha,
taskId,
entry.branch,
entry.revertBoundarySha,
);
if (provenLandedSha) {
/*
@@ -2250,7 +2251,9 @@ async function persistRepoLandedSha(
const current = latest?.workspaceWorktrees ?? {};
const entry = current[repoRel];
if (!entry) return; // entry vanished — nothing to merge into
const next = { ...current, [repoRel]: { ...entry, landedSha } };
// FNXC:Workspace 2026-08-15-06:45: a new landing is strictly after its revert boundary,
// so clear that invalidation marker while retaining the fresh landedSha as normal proof.
const next = { ...current, [repoRel]: { ...entry, landedSha, revertBoundarySha: undefined } };
await store.updateTask(taskId, { workspaceWorktrees: next });
}

View File

@@ -92,16 +92,28 @@ export async function findProvenLandedCommit(
landedSha: string | undefined,
taskId?: string,
branch?: string,
revertBoundarySha?: string,
): Promise<string | undefined> {
const intRef = `refs/heads/${integrationBranch}`;
if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) {
return undefined;
}
/*
FNXC:Workspace 2026-08-15-06:45:
A revert commit carries this task's Fusion-Task-Id trailer, so clearing landedSha alone would
still make the trailer fallback skip re-done work. Reject any proof at or behind the durable
revert boundary; an unknown boundary deliberately preserves legacy idempotency behavior.
*/
const isAtOrBehindRevertBoundary = async (sha: string): Promise<boolean> => {
if (!revertBoundarySha) return false;
return gitOk(["merge-base", "--is-ancestor", sha, revertBoundarySha], repoRootDir);
};
// Primary: recorded landedSha is an ancestor of (or equals) the integration tip — that SHA
// IS the exact landing commit.
// IS the exact landing commit unless a completed revert invalidated it.
if (
landedSha &&
(await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir))
(await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) &&
!(await isAtOrBehindRevertBoundary(landedSha))
) {
return landedSha;
}
@@ -133,7 +145,11 @@ export async function findProvenLandedCommit(
for (const sha of candidates.trim().split("\n")) {
if (!sha) continue;
const body = await gitCapture(["show", "-s", "--format=%B", sha], repoRootDir);
if (body && body.split("\n").some((line) => line.trim() === trailer)) {
if (
body &&
body.split("\n").some((line) => line.trim() === trailer) &&
!(await isAtOrBehindRevertBoundary(sha))
) {
return sha;
}
}
@@ -148,8 +164,9 @@ export async function isRepoLanded(
landedSha: string | undefined,
taskId?: string,
branch?: string,
revertBoundarySha?: string,
): Promise<boolean> {
return Boolean(
await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch),
await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch, revertBoundarySha),
);
}

View File

@@ -10109,7 +10109,12 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
unlandedRepos.push(repoRel);
continue;
}
if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, entry.branch)) {
/*
FNXC:Workspace 2026-08-15-06:45:
A boundary-invalidated repo is unlanded here and therefore follows the existing
branch-present retry path; FORK-A remains fail-closed when its branch is absent.
*/
if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, entry.branch, entry.revertBoundarySha)) {
landedRepos.push(repoRel);
continue;
}
@@ -10608,7 +10613,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
repoRootDir,
{ ...settings, integrationBranch: undefined, baseBranch: undefined },
);
safe = await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, branch);
safe = await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, branch, entry.revertBoundarySha);
}
if (!safe && entry.baseCommitSha) {
const count = await this.execWorkspaceTeardownGit(`git rev-list --count ${shellQuote(entry.baseCommitSha)}..${shellQuote(branch)}`, { cwd: repoRootDir, timeout: 120_000 });