diff --git a/.changeset/fn-9057-workspace-land-predicate-recycled-id.md b/.changeset/fn-9057-workspace-land-predicate-recycled-id.md new file mode 100644 index 0000000000..8df5ccaca0 --- /dev/null +++ b/.changeset/fn-9057-workspace-land-predicate-recycled-id.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent stale workspace task trailers from falsely proving a repo landed. +category: fix +dev: Bounds findProvenLandedCommit degraded scans using taskCreatedAt and recent evidence limits. diff --git a/packages/engine/src/__tests__/workspace-land-predicate-recycled-id.real-git.test.ts b/packages/engine/src/__tests__/workspace-land-predicate-recycled-id.real-git.test.ts new file mode 100644 index 0000000000..78593c5cbf --- /dev/null +++ b/packages/engine/src/__tests__/workspace-land-predicate-recycled-id.real-git.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findProvenLandedCommit, isRepoLanded } from "../merge/workspace-land-predicate.js"; +import { hasGit } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function git(cwd: string, command: string, env?: NodeJS.ProcessEnv): string { + return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, ...env } }).trim(); +} + +/** Commit a real exact trailer line; explicit dates model recycled ids without relying on wall time. */ +function commit(repo: string, name: string, taskId: string, date?: string, body = false): string { + writeFileSync(join(repo, name), `${name}\n`); + git(repo, `git add ${name}`); + const message = body ? `note\n\nmentions Fusion-Task-Id: ${taskId}` : `land\n\nFusion-Task-Id: ${taskId}`; + git(repo, `git commit -m "${message.split("\n")[0]}" ${body ? `-m "mentions Fusion-Task-Id: ${taskId}"` : `-m "Fusion-Task-Id: ${taskId}"`}`, date ? { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date } : undefined); + return git(repo, "git rev-parse HEAD"); +} + +describeIfGit("workspace land predicate recycled task id (real git)", () => { + let repo = ""; + afterEach(() => repo && rmSync(repo, { recursive: true, force: true })); + + it("rejects ancient branch-gone trailers while preserving recent, skew, legacy-window, and primary proofs", async () => { + repo = mkdtempSync(join(tmpdir(), "fn-9057-land-predicate-")); + git(repo, "git init -b main"); + git(repo, 'git config user.email "test@example.com"'); + git(repo, 'git config user.name "Test"'); + const now = new Date(); + const createdAt = now.toISOString(); + const ancient = commit(repo, "ancient", "FN-2002", "2024-01-01T00:00:00Z"); + expect(await findProvenLandedCommit(repo, "main", undefined, "FN-2002", "fusion/fn-2002", undefined, createdAt)).toBeUndefined(); + expect(await isRepoLanded(repo, "main", undefined, "FN-2002", "fusion/fn-2002", undefined, createdAt)).toBe(false); + + const recent = commit(repo, "recent", "FN-2002"); + expect(await findProvenLandedCommit(repo, "main", undefined, "FN-2002", "fusion/fn-2002", undefined, createdAt)).toBe(recent); + expect(await findProvenLandedCommit(repo, "main", undefined, "FN-2002")).toBe(recent); + expect(await findProvenLandedCommit(repo, "main", recent, "FN-2002", "fusion/fn-2002", undefined, "2999dead")).toBe(recent); + expect(ancient).not.toBe(recent); + }); + + it("leaves merge-base ranges and trailer-line precision unchanged", async () => { + repo = mkdtempSync(join(tmpdir(), "fn-9057-land-predicate-")); + git(repo, "git init -b main"); git(repo, 'git config user.email "test@example.com"'); git(repo, 'git config user.name "Test"'); + commit(repo, "base", "OTHER"); + git(repo, "git branch fusion/fn-2002"); + const oldAfterBase = commit(repo, "old-after-base", "FN-2002", "2024-01-01T00:00:00Z"); + expect(await findProvenLandedCommit(repo, "main", undefined, "FN-2002", "fusion/fn-2002", undefined, new Date().toISOString())).toBe(oldAfterBase); + const mention = commit(repo, "mention", "FN-9999", undefined, true); + expect(await findProvenLandedCommit(repo, "main", undefined, "FN-2002", "fusion/fn-2002")).toBe(oldAfterBase); + expect(mention).not.toBe(oldAfterBase); + expect(await findProvenLandedCommit(repo, "missing", undefined, "FN-2002")).toBeUndefined(); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.slow.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.slow.test.ts index 3c9ed2d340..dae27e4a1f 100644 --- a/packages/engine/src/__tests__/workspace-merger-idempotency.slow.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.slow.test.ts @@ -411,6 +411,25 @@ describeIfGit("landWorkspaceTask — DB-failure resilience (Phase C review A1/A4 expect(second.finalized).toBe(true); }); + it("does not treat an ancient recycled trailer on a gone branch as already landed", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + configureIdentity(fx.repoPath("repo-a")); + writeFileSync(path.join(fx.repoPath("repo-a"), "ancient.txt"), "ancient\n", "utf-8"); + fx.git("repo-a", "git add ancient.txt"); + execSync(`git commit -m "historic land" -m "Fusion-Task-Id: ${TASK_ID}"`, { + cwd: fx.repoPath("repo-a"), stdio: "pipe", + env: { ...process.env, GIT_AUTHOR_DATE: "2024-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2024-01-01T00:00:00Z" }, + }); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + const store = createStore(task); + const result = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), reviewAgent: approveReviewAgent, + }); + const repo = result.repos[0]!; + expect(repo.alreadyLanded).toBeFalsy(); + expect(repo.status).toBe("empty"); + }); + it("A4: WorkspacePartialLandError is a real class (instanceof + retryable + payload)", () => { const err = new WorkspacePartialLandError(2, ["repo-b"], "partial"); expect(err).toBeInstanceOf(WorkspacePartialLandError); diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index 9edada6aa8..8f2ee56fd2 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -2020,6 +2020,11 @@ export async function landWorkspaceTask( // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP // it so a retry never re-advances the ref. This makes a re-run after a partial // land idempotent for the already-landed repos. + /* + FNXC:Workspace 2026-08-15-07:05: + Supply task creation time so a missing workspace branch cannot let a recycled historical + trailer prove this repo landed and skip its current work. + */ const provenLandedSha = await findProvenLandedCommit( repoRootDir, integrationBranch, @@ -2027,6 +2032,7 @@ export async function landWorkspaceTask( taskId, entry.branch, entry.revertBoundarySha, + task.createdAt, ); if (provenLandedSha) { /* diff --git a/packages/engine/src/merge/workspace-land-predicate.ts b/packages/engine/src/merge/workspace-land-predicate.ts index 788cddf868..490420fd65 100644 --- a/packages/engine/src/merge/workspace-land-predicate.ts +++ b/packages/engine/src/merge/workspace-land-predicate.ts @@ -15,6 +15,9 @@ const execFileAsync = promisify(execFile); /** Canonical Fusion task-id trailer key stamped on every land squash commit. */ export const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id"; +export const TRAILER_SCAN_CLOCK_SKEW_MS = 24 * 60 * 60 * 1_000; +export const UNBOUNDED_TRAILER_SCAN_WINDOW_MS = 30 * 24 * 60 * 60 * 1_000; +export const UNBOUNDED_TRAILER_SCAN_MAX_COMMITS = 1_000; async function git(args: string[], cwd: string, opts: { timeout?: number } = {}): Promise { const { stdout } = await execFileAsync("git", args, { @@ -72,8 +75,9 @@ async function gitCapture(args: string[], cwd: string): Promise { const intRef = `refs/heads/${integrationBranch}`; if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { @@ -123,10 +128,24 @@ export async function findProvenLandedCommit( if (taskId) { const branchRef = branch ? `refs/heads/${branch}` : undefined; let range = intRef; + let degradedRange = true; if (branchRef && (await gitOk(["rev-parse", "--verify", branchRef], repoRootDir))) { const base = await gitCapture(["merge-base", branchRef, intRef], repoRootDir); - if (base) range = `${base.trim()}..${intRef}`; + if (base) { + range = `${base.trim()}..${intRef}`; + degradedRange = false; + } } + /* + FNXC:Workspace 2026-08-15-07:05: + A missing branch loses the merge-base boundary, so scanning all integration history could + treat a recycled task id as landed and silently drop current work. Prefer a false-negative + (idempotent re-land or honest recovery classification) by requiring recent trailer evidence. + */ + const parsedCreatedAt = taskCreatedAt === undefined ? Number.NaN : new Date(taskCreatedAt).getTime(); + const minCommitTimeMs = Number.isFinite(parsedCreatedAt) + ? parsedCreatedAt - TRAILER_SCAN_CLOCK_SKEW_MS + : Date.now() - UNBOUNDED_TRAILER_SCAN_WINDOW_MS; const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; /* FNXC:Workspace 2026-07-07-10:50 (Phase C A1 precision — Greptile P1, trailer-line verification): @@ -138,14 +157,23 @@ export async function findProvenLandedCommit( own landing commit. */ const candidates = await gitCapture( - ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], + [ + "log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", + ...(degradedRange ? [`--since=${new Date(minCommitTimeMs).toISOString()}`, `--max-count=${UNBOUNDED_TRAILER_SCAN_MAX_COMMITS}`] : []), + range, + ], repoRootDir, ); if (candidates) { for (const sha of candidates.trim().split("\n")) { if (!sha) continue; + const commitTime = degradedRange + ? await gitCapture(["show", "-s", "--format=%ct", sha], repoRootDir) + : undefined; + const commitTimeMs = commitTime === undefined ? Number.NaN : Number(commitTime) * 1_000; const body = await gitCapture(["show", "-s", "--format=%B", sha], repoRootDir); if ( + (!degradedRange || (Number.isFinite(commitTimeMs) && commitTimeMs >= minCommitTimeMs)) && body && body.split("\n").some((line) => line.trim() === trailer) && !(await isAtOrBehindRevertBoundary(sha)) @@ -165,8 +193,9 @@ export async function isRepoLanded( taskId?: string, branch?: string, revertBoundarySha?: string, + taskCreatedAt?: string | number, ): Promise { return Boolean( - await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch, revertBoundarySha), + await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch, revertBoundarySha, taskCreatedAt), ); } diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index c2d5e19868..a0a3b34724 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -10114,7 +10114,8 @@ const movedTask = await this.store.moveTask(task.id, completeLane); 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)) { + /* FNXC:Workspace 2026-08-15-07:05: Creation time bounds branch-gone trailer recovery against recycled task ids. */ + if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, entry.branch, entry.revertBoundarySha, task.createdAt)) { landedRepos.push(repoRel); continue; } @@ -10613,7 +10614,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, entry.revertBoundarySha); + safe = await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, branch, entry.revertBoundarySha, task.createdAt); } if (!safe && entry.baseCommitSha) { const count = await this.execWorkspaceTeardownGit(`git rev-list --count ${shellQuote(entry.baseCommitSha)}..${shellQuote(branch)}`, { cwd: repoRootDir, timeout: 120_000 });