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:
7
.changeset/fn-7486-merge-recovery-noop-ownership.md
Normal file
7
.changeset/fn-7486-merge-recovery-noop-ownership.md
Normal 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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user