From 20184acdfd49f4ca6c26de6c2d3eb5fe365d405b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 3 Jul 2026 22:04:45 -0700 Subject: [PATCH] FN-7486: fix no-diff merge recovery ownership checks Fix no-op task branch recovery by recognizing canonical branches with no unique diff before rejecting inherited foreign trailers. - Add no-diff ownership classification for already-merged detection when canonical task branches inherit another task's landed commit. - Teach self-healing and branch-misbound recovery to ignore foreign branch-tip trailers only for branches proven to have no unique task diff. - Skip synthetic verify:fast typechecks for JavaScript alias packages without tsconfig files and cover the behavior with tests. - Add regression coverage and a patch changeset for the recovery fix. Files changed: .changeset/fn-7486-merge-recovery-noop-ownership.md | 7 ++ .../already-merged-detector.real-git.test.ts | 68 +++++++++++++++++ .../self-healing-already-merged.real-git.test.ts | 89 ++++++++++++++++++++-- packages/engine/src/already-merged-detector.ts | 88 +++++++++++++++------ packages/engine/src/self-healing.ts | 61 +++++++++++++-- scripts/__tests__/verify-fast.test.mjs | 16 +++- scripts/verify-fast.mjs | 15 +++- 7 files changed, 303 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-7486 Fusion-Task-Lineage: 4185c6ed-9731-4ffc-b033-34bf0c3a83ad Co-authored-by: Fusion (runfusion.ai) --- .../fn-7486-merge-recovery-noop-ownership.md | 7 ++ .../already-merged-detector.real-git.test.ts | 68 ++++++++++++++ ...lf-healing-already-merged.real-git.test.ts | 89 +++++++++++++++++-- .../engine/src/already-merged-detector.ts | 88 ++++++++++++------ packages/engine/src/self-healing.ts | 61 +++++++++++-- scripts/__tests__/verify-fast.test.mjs | 16 +++- scripts/verify-fast.mjs | 15 +++- 7 files changed, 303 insertions(+), 41 deletions(-) create mode 100644 .changeset/fn-7486-merge-recovery-noop-ownership.md diff --git a/.changeset/fn-7486-merge-recovery-noop-ownership.md b/.changeset/fn-7486-merge-recovery-noop-ownership.md new file mode 100644 index 0000000000..eff2f7557e --- /dev/null +++ b/.changeset/fn-7486-merge-recovery-noop-ownership.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix no-op task branch recovery after a previously landed task. +category: fix +dev: Merge/recovery ownership classification now checks no-diff branches before foreign trailer rejection. diff --git a/packages/engine/src/__tests__/already-merged-detector.real-git.test.ts b/packages/engine/src/__tests__/already-merged-detector.real-git.test.ts index 57057e487e..44c3e50f77 100644 --- a/packages/engine/src/__tests__/already-merged-detector.real-git.test.ts +++ b/packages/engine/src/__tests__/already-merged-detector.real-git.test.ts @@ -148,6 +148,50 @@ describeIfGit("findAlreadyMergedTaskCommit ownership anchoring (real git)", () = } }); + it("treats a canonical no-op branch at a previous task trailer tip as no-diff", async () => { + const repo = setupRepo(); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "previous.txt"), "previous landed task\n", "utf-8"); + git(repo, "git add src/previous.txt && git commit -m 'feat: previous landed' -m 'Fusion-Task-Id: FN-AMD-PREVIOUS'"); + const previousLandedSha = git(repo, "git rev-parse HEAD"); + git(repo, "git branch fusion/fn-amd-noop"); + + const result = await findAlreadyMergedTaskCommit({ + taskId: "FN-AMD-NOOP", + repoDir: repo, + baseBranch: "main", + taskBranch: "fusion/fn-amd-noop", + }); + + expect(result).not.toBeNull(); + expect(result!.sha).toBe(previousLandedSha); + expect(result!.strategy).toBe("no-diff"); + expect(result!.ownershipProof).toBe("canonical-branch-no-diff"); + }); + + it("treats a canonical no-op branch behind main as no-diff despite inherited foreign trailer", async () => { + const repo = setupRepo(); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "previous-advanced.txt"), "previous landed task\n", "utf-8"); + git(repo, "git add src/previous-advanced.txt && git commit -m 'feat: previous landed' -m 'Fusion-Task-Id: FN-AMD-PREVIOUS-ADVANCED'"); + const previousLandedSha = git(repo, "git rev-parse HEAD"); + git(repo, "git branch fusion/fn-amd-noop-advanced"); + writeFileSync(path.join(repo, "src", "unrelated-after-noop.txt"), "unrelated after branch\n", "utf-8"); + git(repo, "git add src/unrelated-after-noop.txt && git commit -m 'feat: unrelated after noop branch'"); + + const result = await findAlreadyMergedTaskCommit({ + taskId: "FN-AMD-NOOP-ADVANCED", + repoDir: repo, + baseBranch: "main", + taskBranch: "fusion/fn-amd-noop-advanced", + }); + + expect(result).not.toBeNull(); + expect(result!.sha).toBe(previousLandedSha); + expect(result!.strategy).toBe("no-diff"); + expect(result!.ownershipProof).toBe("canonical-branch-no-diff"); + }); + it("rejects a patch-id match when the landed candidate carries a foreign task trailer", async () => { const repo = setupRepo(); git(repo, "git checkout -b fusion/fn-amd-foreign"); @@ -171,6 +215,30 @@ describeIfGit("findAlreadyMergedTaskCommit ownership anchoring (real git)", () = expect(result).toBeNull(); }); + it("rejects a patch-id match when the landed candidate carries a foreign lineage trailer", async () => { + const repo = setupRepo(); + git(repo, "git checkout -b fusion/fn-amd-foreign-lineage"); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "foreign-lineage-patch.txt"), "same-lineage-content\n", "utf-8"); + git(repo, "git add src/foreign-lineage-patch.txt && git commit -m 'work without owner'"); + const branchBase = git(repo, "git merge-base main fusion/fn-amd-foreign-lineage"); + git(repo, "git checkout main"); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "foreign-lineage-patch.txt"), "same-lineage-content\n", "utf-8"); + git(repo, "git add src/foreign-lineage-patch.txt && git commit -m 'feat: foreign lineage landed' -m 'Fusion-Task-Lineage: LINEAGE-OTHER'"); + + const result = await findAlreadyMergedTaskCommit({ + taskId: "FN-AMD-FOREIGN-LINEAGE", + lineageId: "LINEAGE-OWN", + repoDir: repo, + baseBranch: "main", + taskBranch: "fusion/fn-amd-foreign-lineage", + baseCommitSha: branchBase, + }); + + expect(result).toBeNull(); + }); + it("rejects branch-fallback attribution when task metadata points at another task branch", async () => { const repo = setupRepo(); git(repo, "git checkout -b fusion/fn-amd-other-tip"); diff --git a/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts b/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts index 2bbd0baa3a..e2adf0f508 100644 --- a/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts +++ b/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts @@ -280,16 +280,60 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( ); }, 20_000); - it("rejects already-merged recovery when the task branch tip belongs to a foreign task", async () => { + it("recovers a no-op branch behind main from a previous task trailer tip without foreign-tip rejection", async () => { const repo = setupRepo(); mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "previous-tip.txt"), "previous landed task\n", "utf-8"); + git(repo, "git add src/previous-tip.txt && git commit -m 'feat: previous landed' -m 'Fusion-Task-Id: FN-7477'"); + const previousLandedSha = git(repo, "git rev-parse HEAD"); + + const worktreePath = path.join(repo, ".worktrees", "fn-7486-noop"); + mkdirSync(path.dirname(worktreePath), { recursive: true }); + git(repo, `git branch fusion/fn-7486-noop ${previousLandedSha}`); + git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-7486-noop`); + writeFileSync(path.join(repo, "src", "unrelated-after-noop.txt"), "unrelated after no-op branch\n", "utf-8"); + git(repo, "git add src/unrelated-after-noop.txt && git commit -m 'feat: unrelated after noop branch'"); + + const tasks: TaskMap = new Map([ + ["FN-7486-NOOP", makeTask({ id: "FN-7486-NOOP", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-7486-noop", worktree: worktreePath })], + ]); + const store = createStore(tasks); + const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); + + await (manager as any).recoverAlreadyMergedReviewTasks(); + + const task = tasks.get("FN-7486-NOOP")!; + expect(task.column).toBe("done"); + expect(task.status).toBeNull(); + expect(task.mergeDetails?.commitSha).toBe(previousLandedSha); + expect(task.mergeDetails?.mergeConfirmed).toBe(true); + expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:auto-recover-finalize-already-on-main", + target: "FN-7486-NOOP", + metadata: expect.objectContaining({ mergeStrategy: "no-diff" }), + })); + expect((store.logEntry as any).mock.calls.some((call: unknown[]) => String(call[1]).includes("already-merged rejected FN-7486-NOOP"))).toBe(false); + expect((store as any).recordRunAuditEvent).not.toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:auto-recover-already-merged-rejected", + target: "FN-7486-NOOP", + metadata: expect.objectContaining({ reason: "foreign-task-tip", candidateOwner: "FN-7477" }), + })); + }, 20_000); + + it("rejects already-merged recovery when the task branch tip has branch-only foreign work", async () => { + const repo = setupRepo(); + mkdirSync(path.join(repo, "src"), { recursive: true }); + git(repo, "git checkout -b fusion/fn-7143"); writeFileSync(path.join(repo, "src", "foreign-tip.txt"), "foreign\n", "utf-8"); - git(repo, "git add src/foreign-tip.txt && git commit -m 'feat: foreign landed' -m 'Fusion-Task-Id: FN-7187'"); - const foreignSha = git(repo, "git rev-parse HEAD"); + git(repo, "git add src/foreign-tip.txt && git commit -m 'feat: foreign branch work' -m 'Fusion-Task-Id: FN-7187'"); + + git(repo, "git checkout main"); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "owned-landed.txt"), "owned landed\n", "utf-8"); + git(repo, "git add src/owned-landed.txt && git commit -m 'feat: owned landed' -m 'Fusion-Task-Id: FN-7143'"); const worktreePath = path.join(repo, ".worktrees", "fn-7143"); mkdirSync(path.dirname(worktreePath), { recursive: true }); - git(repo, `git branch fusion/fn-7143 ${foreignSha}`); git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-7143`); const tasks: TaskMap = new Map([ @@ -298,7 +342,7 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( const store = createStore(tasks); const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); - await (manager as any).runMaintenance(); + await (manager as any).recoverAlreadyMergedReviewTasks(); const task = tasks.get("FN-7143")!; expect(task.column).toBe("in-review"); @@ -312,6 +356,41 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( })); }, 20_000); + it("rejects already-merged recovery when the task branch tip carries a foreign lineage", async () => { + const repo = setupRepo(); + mkdirSync(path.join(repo, "src"), { recursive: true }); + git(repo, "git checkout -b fusion/fn-7143-lineage"); + writeFileSync(path.join(repo, "src", "foreign-lineage-tip.txt"), "foreign lineage\n", "utf-8"); + git(repo, "git add src/foreign-lineage-tip.txt && git commit -m 'feat: foreign lineage branch work' -m 'Fusion-Task-Lineage: LINEAGE-OTHER'"); + + git(repo, "git checkout main"); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "owned-lineage-landed.txt"), "owned lineage landed\n", "utf-8"); + git(repo, "git add src/owned-lineage-landed.txt && git commit -m 'feat: owned lineage landed' -m 'Fusion-Task-Id: FN-7143-LINEAGE' -m 'Fusion-Task-Lineage: LINEAGE-OWN'"); + + const worktreePath = path.join(repo, ".worktrees", "fn-7143-lineage"); + mkdirSync(path.dirname(worktreePath), { recursive: true }); + git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-7143-lineage`); + + const tasks: TaskMap = new Map([ + ["FN-7143-LINEAGE", makeTask({ id: "FN-7143-LINEAGE", lineageId: "LINEAGE-OWN", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-7143-lineage", worktree: worktreePath })], + ]); + const store = createStore(tasks); + const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); + + await (manager as any).recoverAlreadyMergedReviewTasks(); + + const task = tasks.get("FN-7143-LINEAGE")!; + expect(task.column).toBe("in-review"); + expect(task.mergeDetails?.mergeConfirmed).not.toBe(true); + expect((store as any).moveTask).not.toHaveBeenCalledWith("FN-7143-LINEAGE", "done"); + expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:auto-recover-already-merged-rejected", + target: "FN-7143-LINEAGE", + metadata: expect.objectContaining({ reason: "foreign-lineage-tip", candidateOwner: "LINEAGE-OTHER" }), + })); + }, 20_000); + it("rejects branch-misbound finalization when the misbound tip belongs to a foreign task", async () => { const repo = setupRepo(); mkdirSync(path.join(repo, "src"), { recursive: true }); diff --git a/packages/engine/src/already-merged-detector.ts b/packages/engine/src/already-merged-detector.ts index f7b2f355bf..068796b584 100644 --- a/packages/engine/src/already-merged-detector.ts +++ b/packages/engine/src/already-merged-detector.ts @@ -5,7 +5,7 @@ import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree- const execAsync = promisify(exec); -export type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal"; +export type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal" | "no-diff"; export interface AlreadyMergedLookupInput { taskId: string; @@ -21,7 +21,8 @@ export type AlreadyMergedOwnershipProof = | "lineage-trailer" | "subject-anchor" | "canonical-branch-patch" - | "canonical-branch-tree"; + | "canonical-branch-tree" + | "canonical-branch-no-diff"; export interface AlreadyMergedLookupResult { sha: string; @@ -119,6 +120,26 @@ async function commitHasForeignTaskOwnership( return ownership.rejectionReason === "foreign-task" || ownership.rejectionReason === "foreign-lineage"; } +async function branchHasNoUniqueDiff(repoDir: string, branchTip: string, baseBranch: string): Promise { + const { stdout: mergeBaseStdout } = await execAsync( + `git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, + { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }, + ); + const mergeBase = mergeBaseStdout.trim(); + if (!mergeBase) return false; + + await execAsync(`git diff --quiet ${shellQuote(mergeBase)}..${shellQuote(branchTip)}`, { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + return true; +} + export async function findAlreadyMergedTaskCommit( input: AlreadyMergedLookupInput, ): Promise { @@ -177,21 +198,35 @@ export async function findAlreadyMergedTaskCommit( */ const hasCanonicalBranchIdentity = branchName === canonicalBranchName; let branchTipOwnershipVerified = false; + let branchTipHasNoUniqueDiff = false; + let branchTipForeignNoDiff = false; try { branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, { cwd: repoDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); - if (await commitHasForeignTaskOwnership(repoDir, branchTip, taskId, lineageId)) { + if (hasCanonicalBranchIdentity) { + branchTipHasNoUniqueDiff = await branchHasNoUniqueDiff(repoDir, branchTip, baseBranch).catch(() => false); + } + /* + FNXC:WorkflowRecovery 2026-07-03-21:31: + A new no-op task branch can inherit the current main tip and therefore a previous task's Fusion trailer. Prove the branch has no unique diff from its merge-base before applying the FN-7143/FN-7187 foreign-tip guard; the base branch may have advanced since the no-op branch was created, so current base-tree equality is not part of ownership classification. + */ + const branchTipHasForeignOwnership = await commitHasForeignTaskOwnership(repoDir, branchTip, taskId, lineageId); + if (!branchTipHasNoUniqueDiff && branchTipHasForeignOwnership) { return null; } + branchTipForeignNoDiff = branchTipHasNoUniqueDiff && branchTipHasForeignOwnership; branchTipOwnershipVerified = true; execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, { cwd: repoDir, stdio: ["pipe", "pipe", "pipe"], }); + if (branchTipForeignNoDiff) { + return { sha: branchTip, strategy: "no-diff", ownershipProof: "canonical-branch-no-diff" }; + } // FN-5441/5446 (2026-05-23 lost-work bug #2): `--grep=` is a loose // match that also hits commits merely mentioning the task ID in prose. @@ -237,9 +272,14 @@ export async function findAlreadyMergedTaskCommit( encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); - if (await commitHasForeignTaskOwnership(repoDir, branchTip, taskId, lineageId)) { + if (hasCanonicalBranchIdentity) { + branchTipHasNoUniqueDiff = await branchHasNoUniqueDiff(repoDir, branchTip, baseBranch).catch(() => false); + } + const branchTipHasForeignOwnership = await commitHasForeignTaskOwnership(repoDir, branchTip, taskId, lineageId); + if (!branchTipHasNoUniqueDiff && branchTipHasForeignOwnership) { return null; } + branchTipForeignNoDiff = branchTipHasNoUniqueDiff && branchTipHasForeignOwnership; branchTipOwnershipVerified = true; } @@ -272,28 +312,26 @@ export async function findAlreadyMergedTaskCommit( .split("\n") .find((line) => line.trim().length > 0); const branchPatchId = branchPatchIdLine?.trim().split(/\s+/)[0]; - if (!branchPatchId) { - return null; - } + if (branchPatchId) { + const basePatchMapCommand = `git log -n 200 -p --format='%H' ${shellQuote(baseBranch)} | git patch-id`; + const { stdout: basePatchIdsOut } = await execAsync(basePatchMapCommand, { + cwd: repoDir, + shell: "/bin/sh", + timeout: 60_000, + maxBuffer: 32 * 1024 * 1024, + }); - const basePatchMapCommand = `git log -n 200 -p --format='%H' ${shellQuote(baseBranch)} | git patch-id`; - const { stdout: basePatchIdsOut } = await execAsync(basePatchMapCommand, { - cwd: repoDir, - shell: "/bin/sh", - timeout: 60_000, - maxBuffer: 32 * 1024 * 1024, - }); + const basePatchMap = new Map(); + for (const line of basePatchIdsOut.split("\n")) { + const [patchId, sha] = line.trim().split(/\s+/); + if (!patchId || !sha) continue; + basePatchMap.set(patchId, sha); + } - const basePatchMap = new Map(); - for (const line of basePatchIdsOut.split("\n")) { - const [patchId, sha] = line.trim().split(/\s+/); - if (!patchId || !sha) continue; - basePatchMap.set(patchId, sha); - } - - const matchedSha = basePatchMap.get(branchPatchId); - if (matchedSha && !await commitHasForeignTaskOwnership(repoDir, matchedSha, taskId, lineageId)) { - return { sha: matchedSha, strategy: "patch-id", ownershipProof: "canonical-branch-patch" }; + const matchedSha = basePatchMap.get(branchPatchId); + if (matchedSha && !await commitHasForeignTaskOwnership(repoDir, matchedSha, taskId, lineageId)) { + return { sha: matchedSha, strategy: "patch-id", ownershipProof: "canonical-branch-patch" }; + } } } catch { // Fall through to null when patch-id detection fails. @@ -330,7 +368,7 @@ export async function findAlreadyMergedTaskCommit( maxBuffer: 1024 * 1024, }); const baseHead = baseHeadStdout.trim(); - if (baseHead && !await commitHasForeignTaskOwnership(repoDir, baseHead, taskId, lineageId)) { + if (baseHead && (branchTipForeignNoDiff || !await commitHasForeignTaskOwnership(repoDir, baseHead, taskId, lineageId))) { return { sha: baseHead, strategy: "tree-equal", ownershipProof: "canonical-branch-tree" }; } } diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index da8f7d4779..2545357c80 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -1965,6 +1965,38 @@ export class SelfHealingManager { return getCommitTaskOwnership(taskId, lineageId, subject, body); } + private async branchHasNoUniqueDiff(branchTip: string, baseBranch: string): Promise { + const { stdout: mergeBaseStdout } = await execAsync(`git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, { + cwd: this.options.rootDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const mergeBase = mergeBaseStdout.trim(); + if (!mergeBase) return false; + + await execAsync(`git diff --quiet ${shellQuote(mergeBase)}..${shellQuote(branchTip)}`, { + cwd: this.options.rootDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + return true; + } + + private async baseHasExplicitTaskOwnership(taskId: string, lineageId: string | undefined, baseBranch: string): Promise { + const patterns = lineageId + ? [`^Fusion-Task-Lineage: ${escapeRegex(lineageId)}$`, `^Fusion-Task-Id: ${escapeRegex(taskId)}$`] + : [`^Fusion-Task-Id: ${escapeRegex(taskId)}$`]; + for (const pattern of patterns) { + const { stdout } = await execAsync(`git log --grep=${shellQuote(pattern)} -E --max-count=1 --format=%H ${shellQuote(baseBranch)}`, { + cwd: this.options.rootDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + if (stdout.trim()) return true; + } + return false; + } + private async rejectForeignAlreadyMergedCandidate(input: { task: Pick; candidateSha: string; @@ -2012,8 +2044,9 @@ export class SelfHealingManager { taskId: string; lineageId?: string; branch: string; + baseBranch: string; }): Promise<{ sha: string; owner?: string; reason: "foreign-task-tip" | "foreign-lineage-tip" | "ownership-unverifiable" } | null> { - const { taskId, lineageId, branch } = input; + const { taskId, lineageId, branch, baseBranch } = input; let stdout = ""; try { ({ stdout } = await execAsync(`git rev-parse ${shellQuote(branch)}`, { @@ -2026,16 +2059,24 @@ export class SelfHealingManager { } const sha = stdout.trim(); if (!sha) return null; + const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(sha, baseBranch).catch(() => false); let ownership: Awaited>; try { ownership = await this.readCommitTaskOwnership(sha, taskId, lineageId); } catch { return { sha, reason: "ownership-unverifiable" }; } - if (ownership.rejectionReason === "foreign-task") { + /* + FNXC:WorkflowRecovery 2026-07-03-21:35: + Already-merged recovery must classify no-diff task branches before enforcing branch-tip trailers. A branch created from main can point at a previous task's landed commit and later sit behind main after unrelated commits; reject foreign trailers only when merge-base-to-tip diff proof shows the branch contains real task-branch content. + */ + const baseAlreadyHasCurrentTask = hasNoUniqueDiff + ? await this.baseHasExplicitTaskOwnership(taskId, lineageId, baseBranch).catch(() => false) + : false; + if ((!hasNoUniqueDiff || baseAlreadyHasCurrentTask) && ownership.rejectionReason === "foreign-task") { return { sha, owner: ownership.ownerTaskId, reason: "foreign-task-tip" }; } - if (ownership.rejectionReason === "foreign-lineage") { + if ((!hasNoUniqueDiff || baseAlreadyHasCurrentTask) && ownership.rejectionReason === "foreign-lineage") { return { sha, owner: ownership.ownerLineageId, reason: "foreign-lineage-tip" }; } return null; @@ -8549,7 +8590,7 @@ export class SelfHealingManager { const baseBranch = mergeTarget.branch; if (!baseBranch) continue; if (task.branch) { - const foreignTip = await this.branchTipForeignOwnership({ taskId: task.id, lineageId: task.lineageId, branch: task.branch }).catch(() => null); + const foreignTip = await this.branchTipForeignOwnership({ taskId: task.id, lineageId: task.lineageId, branch: task.branch, baseBranch }).catch(() => null); if (foreignTip) { await this.rejectForeignAlreadyMergedCandidate({ task, @@ -8875,11 +8916,19 @@ export class SelfHealingManager { maxBuffer: 1024 * 1024, }); const branchTip = tipOut.trim(); + const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(branchTip, baseBranch).catch(() => false); const ownership = await this.readCommitTaskOwnership(branchTip, taskId, lineageId); - if (ownership.rejectionReason === "foreign-task") { + /* + FNXC:WorkflowRecovery 2026-07-03-21:39: + Branch-misbound recovery shares the no-op inheritance edge case with already-merged recovery. Check merge-base-to-tip diff state first so a branch with no unique task content is not mislabeled misbound solely because its inherited tip belongs to the previously landed task, even after base advances. + */ + const baseAlreadyHasCurrentTask = hasNoUniqueDiff + ? await this.baseHasExplicitTaskOwnership(taskId, lineageId, baseBranch).catch(() => false) + : false; + if ((!hasNoUniqueDiff || baseAlreadyHasCurrentTask) && ownership.rejectionReason === "foreign-task") { return { misbound: false, branchTip, landed: null, rejection: { reason: "foreign-task-tip", owner: ownership.ownerTaskId } }; } - if (ownership.rejectionReason === "foreign-lineage") { + if ((!hasNoUniqueDiff || baseAlreadyHasCurrentTask) && ownership.rejectionReason === "foreign-lineage") { return { misbound: false, branchTip, landed: null, rejection: { reason: "foreign-lineage-tip", owner: ownership.ownerLineageId } }; } const hasTaskId = ownership.ownerTaskId === taskId; diff --git a/scripts/__tests__/verify-fast.test.mjs b/scripts/__tests__/verify-fast.test.mjs index f0e3a3f55f..69b80e3ed5 100644 --- a/scripts/__tests__/verify-fast.test.mjs +++ b/scripts/__tests__/verify-fast.test.mjs @@ -110,7 +110,7 @@ test("buildVerifyPlan: typecheck for all eligible, then builds, then boot smoke test("buildVerifyPlan: a package without a build script gets a typecheck step but no build step", () => { const packageMeta = new Map([ ["@fusion/engine", { hasTypecheck: true, hasBuild: true }], - ["@fusion/test-only", { hasTypecheck: false, hasBuild: false }], + ["@fusion/test-only", { hasTypecheck: false, hasTsconfig: true, hasBuild: false }], ]); const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/test-only"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), [ @@ -126,6 +126,20 @@ test("buildVerifyPlan: a package without a build script gets a typecheck step bu assert.deepEqual(tc.args, ["--filter", "@fusion/test-only", "exec", "tsc", "--noEmit", "-p", "."]); }); +test("buildVerifyPlan: skips synthetic typecheck for JavaScript alias packages with no tsconfig", () => { + const packageMeta = new Map([ + ["runfusion.ai", { hasTypecheck: false, hasTsconfig: false, hasBuild: false }], + ["@runfusion/fusion", { hasTypecheck: true, hasTsconfig: true, hasBuild: true }], + ]); + const plan = buildVerifyPlan({ packages: ["runfusion.ai", "@runfusion/fusion"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + assert.deepEqual(stepIds(plan), [ + "bootstrap-artifacts", + "typecheck:@runfusion/fusion", + "build:@runfusion/fusion", + "boot-smoke", + ]); +}); + test("buildVerifyPlan: desktop/mobile are excluded from scoped steps but boot smoke still runs", () => { const packageMeta = new Map([ ["@fusion/engine", { hasTypecheck: true, hasBuild: true }], diff --git a/scripts/verify-fast.mjs b/scripts/verify-fast.mjs index b96410456e..1835ac2d80 100644 --- a/scripts/verify-fast.mjs +++ b/scripts/verify-fast.mjs @@ -32,7 +32,7 @@ of blocking forever, and we exit nonzero on the first failing step. */ import path from "node:path"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -135,7 +135,7 @@ export function buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin = proces * * @param {object} opts * @param {string[]} [opts.packages] affected package names - * @param {Map} [opts.packageMeta] + * @param {Map} [opts.packageMeta] * @param {string} opts.bootSmokeScriptPath * @param {string} [opts.artifactBootstrapScriptPath] * @param {string} [opts.nodeBin] @@ -147,7 +147,13 @@ export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSm const steps = [buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin)]; for (const pkg of eligiblePackages) { - steps.push(buildTypecheckStep(pkg, packageMeta.get(pkg) ?? {})); + const meta = packageMeta.get(pkg) ?? {}; + /* + FNXC:TestInfrastructure 2026-07-03-21:54: + Workspace alias packages such as `runfusion.ai` are publishable JavaScript shims with no tsconfig. verify:fast should not synthesize a `tsc -p .` fallback for those packages; their executable behavior is covered by the required CLI build and boot smoke. + */ + if (meta.hasTypecheck === false && meta.hasTsconfig === false) continue; + steps.push(buildTypecheckStep(pkg, meta)); } const builtPackages = new Set(); @@ -181,7 +187,7 @@ export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSm * @param {string[]} packages * @param {Map} packageDirByName pkg name → repo-relative dir * @param {string} [root] - * @returns {Map} + * @returns {Map} */ export function readPackageMeta(packages, packageDirByName, root = repoRoot) { const meta = new Map(); @@ -199,6 +205,7 @@ export function readPackageMeta(packages, packageDirByName, root = repoRoot) { meta.set(pkg, { dir: dir ?? null, hasTypecheck: typeof scripts.typecheck === "string", + hasTsconfig: dir ? existsSync(path.join(root, dir, "tsconfig.json")) : true, hasBuild: typeof scripts.build === "string", }); }