From 1372218b1f9e8ae8096db0c446bf9d9122d7dbcd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 00:02:52 -0700 Subject: [PATCH] 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) --- .../fn-9047-workspace-revert-landed-sha.md | 7 +++ docs/architecture.md | 2 + packages/core/src/types/task/task-core.ts | 7 ++- .../routes/register-session-diff-routes.ts | 2 +- .../routes/register-task-workflow-routes.ts | 11 ++++ .../task-revert.workspace.real-git.test.ts | 27 +++++++++ ...ace-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 +- .../src/merge/workspace-land-predicate.ts | 25 ++++++-- packages/engine/src/self-healing.ts | 9 ++- 12 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-9047-workspace-revert-landed-sha.md create mode 100644 packages/engine/src/__tests__/workspace-land-predicate.revert-boundary.test.ts diff --git a/.changeset/fn-9047-workspace-revert-landed-sha.md b/.changeset/fn-9047-workspace-revert-landed-sha.md new file mode 100644 index 0000000000..e9d5bbea0e --- /dev/null +++ b/.changeset/fn-9047-workspace-revert-landed-sha.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index cc118ab9f9..d94543c6b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/packages/core/src/types/task/task-core.ts b/packages/core/src/types/task/task-core.ts index 38666aa7d0..b126823b86 100644 --- a/packages/core/src/types/task/task-core.ts +++ b/packages/core/src/types/task/task-core.ts @@ -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; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/dashboard/src/routes/register-session-diff-routes.ts b/packages/dashboard/src/routes/register-session-diff-routes.ts index 6e71c9f2c5..70021c3e34 100644 --- a/packages/dashboard/src/routes/register-session-diff-routes.ts +++ b/packages/dashboard/src/routes/register-session-diff-routes.ts @@ -531,7 +531,7 @@ async function computeWorkspaceTaskFiles( task: { id: string; baseBranch?: string; - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; }, rootDir: string, timeoutMs: number, diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index b6b62a4351..d4b6ce3c36 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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(); } diff --git a/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts b/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts index b10f123975..4fd0e3ce8c 100644 --- a/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts +++ b/packages/engine/src/__tests__/task-revert.workspace.real-git.test.ts @@ -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"); diff --git a/packages/engine/src/__tests__/workspace-land-predicate.revert-boundary.test.ts b/packages/engine/src/__tests__/workspace-land-predicate.revert-boundary.test.ts new file mode 100644 index 0000000000..7d00658a77 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-land-predicate.revert-boundary.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/execution/task-revert.ts b/packages/engine/src/execution/task-revert.ts index 0b654b326f..8d729ed5bc 100644 --- a/packages/engine/src/execution/task-revert.ts +++ b/packages/engine/src/execution/task-revert.ts @@ -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 }; } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 8ca5ab6b0e..fb09d7459a 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -430,6 +430,7 @@ export { type WorkspaceRepoRevertResult, type WorkspaceTaskRevertResult, type RevertWorkspaceTaskOptions, + applyWorkspaceRevertBoundaries, prepareRevertPrBranch, type PrepareRevertPrBranchResult, type PrepareRevertPrBranchOptions, diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index 47aecb06dd..9edada6aa8 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -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 }); } diff --git a/packages/engine/src/merge/workspace-land-predicate.ts b/packages/engine/src/merge/workspace-land-predicate.ts index 769e037c7c..788cddf868 100644 --- a/packages/engine/src/merge/workspace-land-predicate.ts +++ b/packages/engine/src/merge/workspace-land-predicate.ts @@ -92,16 +92,28 @@ export async function findProvenLandedCommit( landedSha: string | undefined, taskId?: string, branch?: string, + revertBoundarySha?: string, ): Promise { 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 => { + 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 { return Boolean( - await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch), + await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch, revertBoundarySha), ); } diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index bce7d44c51..c2d5e19868 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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 });