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 () => {
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");

View File

@@ -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 });

View File

@@ -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<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(
input: AlreadyMergedLookupInput,
): Promise<AlreadyMergedLookupResult | null> {
@@ -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=<taskId>` 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<string, string>();
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<string, string>();
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" };
}
}

View File

@@ -1965,6 +1965,38 @@ export class SelfHealingManager {
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: {
task: Pick<Task, "id" | "lineageId">;
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<ReturnType<SelfHealingManager["readCommitTaskOwnership"]>>;
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;

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", () => {
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 }],

View File

@@ -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<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.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<string, string>} packageDirByName pkg name → repo-relative dir
* @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) {
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",
});
}