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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 22:04:45 -07:00
parent b42ba9f515
commit 20184acdfd
7 changed files with 303 additions and 41 deletions

View File

@@ -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.

View File

@@ -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 () => { it("rejects a patch-id match when the landed candidate carries a foreign task trailer", async () => {
const repo = setupRepo(); const repo = setupRepo();
git(repo, "git checkout -b fusion/fn-amd-foreign"); git(repo, "git checkout -b fusion/fn-amd-foreign");
@@ -171,6 +215,30 @@ describeIfGit("findAlreadyMergedTaskCommit ownership anchoring (real git)", () =
expect(result).toBeNull(); 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 () => { it("rejects branch-fallback attribution when task metadata points at another task branch", async () => {
const repo = setupRepo(); const repo = setupRepo();
git(repo, "git checkout -b fusion/fn-amd-other-tip"); git(repo, "git checkout -b fusion/fn-amd-other-tip");

View File

@@ -280,16 +280,60 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
); );
}, 20_000); }, 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(); const repo = setupRepo();
mkdirSync(path.join(repo, "src"), { recursive: true }); 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"); 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'"); git(repo, "git add src/foreign-tip.txt && git commit -m 'feat: foreign branch work' -m 'Fusion-Task-Id: FN-7187'");
const foreignSha = git(repo, "git rev-parse HEAD");
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"); const worktreePath = path.join(repo, ".worktrees", "fn-7143");
mkdirSync(path.dirname(worktreePath), { recursive: true }); 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`); git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-7143`);
const tasks: TaskMap = new Map([ const tasks: TaskMap = new Map([
@@ -298,7 +342,7 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
const store = createStore(tasks); const store = createStore(tasks);
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); 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")!; const task = tasks.get("FN-7143")!;
expect(task.column).toBe("in-review"); expect(task.column).toBe("in-review");
@@ -312,6 +356,41 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
})); }));
}, 20_000); }, 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 () => { it("rejects branch-misbound finalization when the misbound tip belongs to a foreign task", async () => {
const repo = setupRepo(); const repo = setupRepo();
mkdirSync(path.join(repo, "src"), { recursive: true }); mkdirSync(path.join(repo, "src"), { recursive: true });

View File

@@ -5,7 +5,7 @@ import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-
const execAsync = promisify(exec); 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 { export interface AlreadyMergedLookupInput {
taskId: string; taskId: string;
@@ -21,7 +21,8 @@ export type AlreadyMergedOwnershipProof =
| "lineage-trailer" | "lineage-trailer"
| "subject-anchor" | "subject-anchor"
| "canonical-branch-patch" | "canonical-branch-patch"
| "canonical-branch-tree"; | "canonical-branch-tree"
| "canonical-branch-no-diff";
export interface AlreadyMergedLookupResult { export interface AlreadyMergedLookupResult {
sha: string; sha: string;
@@ -119,6 +120,26 @@ async function commitHasForeignTaskOwnership(
return ownership.rejectionReason === "foreign-task" || ownership.rejectionReason === "foreign-lineage"; return ownership.rejectionReason === "foreign-task" || ownership.rejectionReason === "foreign-lineage";
} }
async function branchHasNoUniqueDiff(repoDir: string, branchTip: string, baseBranch: string): Promise<boolean> {
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( export async function findAlreadyMergedTaskCommit(
input: AlreadyMergedLookupInput, input: AlreadyMergedLookupInput,
): Promise<AlreadyMergedLookupResult | null> { ): Promise<AlreadyMergedLookupResult | null> {
@@ -177,21 +198,35 @@ export async function findAlreadyMergedTaskCommit(
*/ */
const hasCanonicalBranchIdentity = branchName === canonicalBranchName; const hasCanonicalBranchIdentity = branchName === canonicalBranchName;
let branchTipOwnershipVerified = false; let branchTipOwnershipVerified = false;
let branchTipHasNoUniqueDiff = false;
let branchTipForeignNoDiff = false;
try { try {
branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, { branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {
cwd: repoDir, cwd: repoDir,
encoding: "utf-8", encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}).trim(); }).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; return null;
} }
branchTipForeignNoDiff = branchTipHasNoUniqueDiff && branchTipHasForeignOwnership;
branchTipOwnershipVerified = true; branchTipOwnershipVerified = true;
execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, { execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, {
cwd: repoDir, cwd: repoDir,
stdio: ["pipe", "pipe", "pipe"], 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=<taskId>` is a loose // FN-5441/5446 (2026-05-23 lost-work bug #2): `--grep=<taskId>` is a loose
// match that also hits commits merely mentioning the task ID in prose. // match that also hits commits merely mentioning the task ID in prose.
@@ -237,9 +272,14 @@ export async function findAlreadyMergedTaskCommit(
encoding: "utf-8", encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}).trim(); }).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; return null;
} }
branchTipForeignNoDiff = branchTipHasNoUniqueDiff && branchTipHasForeignOwnership;
branchTipOwnershipVerified = true; branchTipOwnershipVerified = true;
} }
@@ -272,28 +312,26 @@ export async function findAlreadyMergedTaskCommit(
.split("\n") .split("\n")
.find((line) => line.trim().length > 0); .find((line) => line.trim().length > 0);
const branchPatchId = branchPatchIdLine?.trim().split(/\s+/)[0]; const branchPatchId = branchPatchIdLine?.trim().split(/\s+/)[0];
if (!branchPatchId) { if (branchPatchId) {
return null; 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 basePatchMap = new Map<string, string>();
const { stdout: basePatchIdsOut } = await execAsync(basePatchMapCommand, { for (const line of basePatchIdsOut.split("\n")) {
cwd: repoDir, const [patchId, sha] = line.trim().split(/\s+/);
shell: "/bin/sh", if (!patchId || !sha) continue;
timeout: 60_000, basePatchMap.set(patchId, sha);
maxBuffer: 32 * 1024 * 1024, }
});
const basePatchMap = new Map<string, string>(); const matchedSha = basePatchMap.get(branchPatchId);
for (const line of basePatchIdsOut.split("\n")) { if (matchedSha && !await commitHasForeignTaskOwnership(repoDir, matchedSha, taskId, lineageId)) {
const [patchId, sha] = line.trim().split(/\s+/); return { sha: matchedSha, strategy: "patch-id", ownershipProof: "canonical-branch-patch" };
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" };
} }
} catch { } catch {
// Fall through to null when patch-id detection fails. // Fall through to null when patch-id detection fails.
@@ -330,7 +368,7 @@ export async function findAlreadyMergedTaskCommit(
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
}); });
const baseHead = baseHeadStdout.trim(); 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" }; return { sha: baseHead, strategy: "tree-equal", ownershipProof: "canonical-branch-tree" };
} }
} }

View File

@@ -1965,6 +1965,38 @@ export class SelfHealingManager {
return getCommitTaskOwnership(taskId, lineageId, subject, body); return getCommitTaskOwnership(taskId, lineageId, subject, body);
} }
private async branchHasNoUniqueDiff(branchTip: string, baseBranch: string): Promise<boolean> {
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<boolean> {
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: { private async rejectForeignAlreadyMergedCandidate(input: {
task: Pick<Task, "id" | "lineageId">; task: Pick<Task, "id" | "lineageId">;
candidateSha: string; candidateSha: string;
@@ -2012,8 +2044,9 @@ export class SelfHealingManager {
taskId: string; taskId: string;
lineageId?: string; lineageId?: string;
branch: string; branch: string;
baseBranch: string;
}): Promise<{ sha: string; owner?: string; reason: "foreign-task-tip" | "foreign-lineage-tip" | "ownership-unverifiable" } | null> { }): 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 = ""; let stdout = "";
try { try {
({ stdout } = await execAsync(`git rev-parse ${shellQuote(branch)}`, { ({ stdout } = await execAsync(`git rev-parse ${shellQuote(branch)}`, {
@@ -2026,16 +2059,24 @@ export class SelfHealingManager {
} }
const sha = stdout.trim(); const sha = stdout.trim();
if (!sha) return null; if (!sha) return null;
const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(sha, baseBranch).catch(() => false);
let ownership: Awaited<ReturnType<SelfHealingManager["readCommitTaskOwnership"]>>; let ownership: Awaited<ReturnType<SelfHealingManager["readCommitTaskOwnership"]>>;
try { try {
ownership = await this.readCommitTaskOwnership(sha, taskId, lineageId); ownership = await this.readCommitTaskOwnership(sha, taskId, lineageId);
} catch { } catch {
return { sha, reason: "ownership-unverifiable" }; 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" }; 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 { sha, owner: ownership.ownerLineageId, reason: "foreign-lineage-tip" };
} }
return null; return null;
@@ -8549,7 +8590,7 @@ export class SelfHealingManager {
const baseBranch = mergeTarget.branch; const baseBranch = mergeTarget.branch;
if (!baseBranch) continue; if (!baseBranch) continue;
if (task.branch) { 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) { if (foreignTip) {
await this.rejectForeignAlreadyMergedCandidate({ await this.rejectForeignAlreadyMergedCandidate({
task, task,
@@ -8875,11 +8916,19 @@ export class SelfHealingManager {
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
}); });
const branchTip = tipOut.trim(); const branchTip = tipOut.trim();
const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(branchTip, baseBranch).catch(() => false);
const ownership = await this.readCommitTaskOwnership(branchTip, taskId, lineageId); 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 } }; 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 } }; return { misbound: false, branchTip, landed: null, rejection: { reason: "foreign-lineage-tip", owner: ownership.ownerLineageId } };
} }
const hasTaskId = ownership.ownerTaskId === taskId; const hasTaskId = ownership.ownerTaskId === taskId;

View File

@@ -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", () => { test("buildVerifyPlan: a package without a build script gets a typecheck step but no build step", () => {
const packageMeta = new Map([ const packageMeta = new Map([
["@fusion/engine", { hasTypecheck: true, hasBuild: true }], ["@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 }); const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/test-only"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE });
assert.deepEqual(stepIds(plan), [ 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", "."]); 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", () => { test("buildVerifyPlan: desktop/mobile are excluded from scoped steps but boot smoke still runs", () => {
const packageMeta = new Map([ const packageMeta = new Map([
["@fusion/engine", { hasTypecheck: true, hasBuild: true }], ["@fusion/engine", { hasTypecheck: true, hasBuild: true }],

View File

@@ -32,7 +32,7 @@ of blocking forever, and we exit nonzero on the first failing step.
*/ */
import path from "node:path"; import path from "node:path";
import { readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -135,7 +135,7 @@ export function buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin = proces
* *
* @param {object} opts * @param {object} opts
* @param {string[]} [opts.packages] affected package names * @param {string[]} [opts.packages] affected package names
* @param {Map<string, { dir?: string, hasTypecheck?: boolean, hasBuild?: boolean }>} [opts.packageMeta] * @param {Map<string, { dir?: string, hasTypecheck?: boolean, hasTsconfig?: boolean, hasBuild?: boolean }>} [opts.packageMeta]
* @param {string} opts.bootSmokeScriptPath * @param {string} opts.bootSmokeScriptPath
* @param {string} [opts.artifactBootstrapScriptPath] * @param {string} [opts.artifactBootstrapScriptPath]
* @param {string} [opts.nodeBin] * @param {string} [opts.nodeBin]
@@ -147,7 +147,13 @@ export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSm
const steps = [buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin)]; const steps = [buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin)];
for (const pkg of eligiblePackages) { 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(); const builtPackages = new Set();
@@ -181,7 +187,7 @@ export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSm
* @param {string[]} packages * @param {string[]} packages
* @param {Map<string, string>} packageDirByName pkg name → repo-relative dir * @param {Map<string, string>} packageDirByName pkg name → repo-relative dir
* @param {string} [root] * @param {string} [root]
* @returns {Map<string, { dir: string, hasTypecheck: boolean, hasBuild: boolean }>} * @returns {Map<string, { dir: string, hasTypecheck: boolean, hasTsconfig: boolean, hasBuild: boolean }>}
*/ */
export function readPackageMeta(packages, packageDirByName, root = repoRoot) { export function readPackageMeta(packages, packageDirByName, root = repoRoot) {
const meta = new Map(); const meta = new Map();
@@ -199,6 +205,7 @@ export function readPackageMeta(packages, packageDirByName, root = repoRoot) {
meta.set(pkg, { meta.set(pkg, {
dir: dir ?? null, dir: dir ?? null,
hasTypecheck: typeof scripts.typecheck === "string", hasTypecheck: typeof scripts.typecheck === "string",
hasTsconfig: dir ? existsSync(path.join(root, dir, "tsconfig.json")) : true,
hasBuild: typeof scripts.build === "string", hasBuild: typeof scripts.build === "string",
}); });
} }