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 () => {
|
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");
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user