FN-9057: bound workspace trailer fallback scans
Bound degraded workspace landing checks to recent task evidence. - Pass task creation time through workspace landing and recovery paths - Limit branch-gone trailer scans by time and commit count - Cover recycled task-id fallback behavior with real-git tests Files changed: ...fn-9057-workspace-land-predicate-recycled-id.md | 7 +++ ...ace-land-predicate-recycled-id.real-git.test.ts | 58 ++++++++++++++++++++++ .../workspace-merger-idempotency.slow.test.ts | 19 +++++++ packages/engine/src/merge/merger-ai.ts | 6 +++ .../engine/src/merge/workspace-land-predicate.ts | 39 +++++++++++++-- packages/engine/src/self-healing.ts | 5 +- 6 files changed, 127 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-9057 Fusion-Task-Lineage: e522a33e-0f11-49ad-af88-3fa29129fad2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
/*
|
||||
|
||||
@@ -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<string> {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
@@ -72,8 +75,9 @@ async function gitCapture(args: string[], cwd: string): Promise<string | undefin
|
||||
* `Fusion-Task-Id` trailer (always stamped onto the squash by `taskTrailers` + the
|
||||
* ensureTaskMetadata safety net) is the only reliable "this task's work is already on the ref"
|
||||
* signal that does not depend on the landedSha row, so it is what survives a lost persist. We
|
||||
* bound the scan to commits the integration tip has gained since the branch's merge-base (the
|
||||
* land base) so an unrelated historical reuse of the same trailer cannot false-positive.
|
||||
* normally bound the scan to commits the integration tip has gained since the branch's merge-base
|
||||
* (the land base). When the branch or merge-base is gone, that graph bound degrades; the fallback
|
||||
* is instead bounded by task creation time (or a conservative recent window for legacy callers).
|
||||
*
|
||||
* Exported (A6) so Phase D self-healing reuses THIS canonical predicate instead of
|
||||
* reimplementing the ancestor/trailer check.
|
||||
@@ -93,6 +97,7 @@ export async function findProvenLandedCommit(
|
||||
taskId?: string,
|
||||
branch?: string,
|
||||
revertBoundarySha?: string,
|
||||
taskCreatedAt?: string | number,
|
||||
): Promise<string | undefined> {
|
||||
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<boolean> {
|
||||
return Boolean(
|
||||
await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch, revertBoundarySha),
|
||||
await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch, revertBoundarySha, taskCreatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user