diff --git a/.changeset/inherited-foreign-tip-reclaim.md b/.changeset/inherited-foreign-tip-reclaim.md new file mode 100644 index 0000000000..49b78ea272 --- /dev/null +++ b/.changeset/inherited-foreign-tip-reclaim.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop leaving cards stuck with stale worktree metadata when their branch inherited another task's commit. +category: fix +dev: The reclaim sweep's `tip-already-merged` arm vetoed on the branch tip's foreign `Fusion-Task-Id` trailer alone, so a task branch cut from the base that never committed anything (planning aborted, moved back to `todo`) was rejected as foreign contamination and re-logged `already-merged rejected ... reason=foreign-task-tip` every sweep. The merge-base diff-proof classification used by already-merged and branch-misbound recovery is now a shared `SelfHealingManager.foreignTipRejection` helper used by all three callers; rejection still fires when the branch has unique content or the base already carries the task's own commit. diff --git a/packages/engine/src/__tests__/self-healing-ghost-branch-recovery.test.ts b/packages/engine/src/__tests__/self-healing-ghost-branch-recovery.test.ts index b5f6f008d2..3e6bea2beb 100644 --- a/packages/engine/src/__tests__/self-healing-ghost-branch-recovery.test.ts +++ b/packages/engine/src/__tests__/self-healing-ghost-branch-recovery.test.ts @@ -130,4 +130,104 @@ describe("self-healing ghost branch reclaim", () => { expect(nullingCalls).toHaveLength(0); expect(store.logEntry).toHaveBeenCalledWith("FN-9001", expect.stringContaining("tip-already-merged cleanup failed")); }); + + /* + FNXC:SelfHealingReclaim 2026-07-25-09:40: + Regression contract for the inherited-tip invariant (FN-1406): the reclaim sweep's `tip-already-merged` arm must + classify a foreign `Fusion-Task-Id` trailer with merge-base diff proof, not on the trailer alone, so it shares one + decision (`foreignTipRejection`) with already-merged recovery and branch-misbound recovery. + + Original symptom: FN-1406's branch `fusion/fn-1406` was cut from `main` at FN-1401's landed commit and planning + ended before any commit. Every sweep logged `[recovery] already-merged rejected FN-1406 ... owner=FN-1401 + reason=foreign-task-tip` and left stale worktree/branch/baseCommitSha metadata on the card instead of reclaiming it. + + Surfaces covered here: pristine inherited tip (reclaim), inherited tip where the base already carries THIS task's + own commit (still rejected — genuine misbinding), and foreign lineage trailers. The other two callers keep their + existing real-git rejection coverage in self-healing-already-merged.real-git.test.ts. + */ + describe("inherited foreign tip on a branch with no unique content", () => { + const TIP = "9758daadff68aaaabbbbccccddddeeeeffff0000"; + + /** Drives the git seam the ownership + diff-proof classification reads: foreign trailer, empty merge-base diff. */ + function mockInheritedForeignTip(options: { trailer: string; baseHasCurrentTask?: boolean }) { + execMock.mockImplementation(async (command: string) => { + if (command.includes("git show -s")) return `feat: previous task landed${options.trailer}\n`; + if (command.includes("git merge-base")) return `${TIP}\n`; + // `git diff --quiet ..` exits 0 → no unique task content on the branch. + if (command.includes("git diff --quiet")) return ""; + if (command.includes("git log --grep")) return options.baseHasCurrentTask ? "deadbeefdeadbeef\n" : ""; + return ""; + }); + } + + function mockTodoSweepTask(task: any) { + (store.listTasks as any) + .mockResolvedValueOnce([task]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + } + + it("reclaims a todo task whose zero-commit branch inherited a foreign task's landed tip", async () => { + mockInheritedForeignTip({ trailer: "Fusion-Task-Id: FN-1401" }); + mockTodoSweepTask({ id: "FN-1406", column: "todo", checkedOutBy: null, branch: "fusion/fn-1406", worktree: "/tmp/fn-1406", baseCommitSha: TIP }); + vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({ + kind: "tip-already-merged", + livePath: null, + tipSha: TIP, + integrationRef: "main", + } as any); + + const recovered = await manager.reclaimSelfOwnedBranchConflicts(); + + expect(recovered).toBe(1); + expect(store.updateTask).toHaveBeenCalledWith("FN-1406", expect.objectContaining({ worktree: null, branch: null, baseCommitSha: null })); + expect(store.logEntry).toHaveBeenCalledWith("FN-1406", expect.stringContaining("[recovery] tip-already-merged FN-1406")); + // The symptom line must be gone entirely. + expect((store.logEntry as any).mock.calls.some((c: any[]) => String(c[1]).includes("already-merged rejected"))).toBe(false); + expect((store as any).recordRunAuditEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ mutationType: "task:auto-recover-already-merged-rejected" }), + ); + }); + + it("reclaims a zero-commit branch that inherited a foreign lineage tip", async () => { + mockInheritedForeignTip({ trailer: "Fusion-Task-Lineage: lin-other" }); + mockTodoSweepTask({ id: "FN-1406", column: "todo", checkedOutBy: null, branch: "fusion/fn-1406", worktree: "/tmp/fn-1406", baseCommitSha: TIP, lineageId: "lin-1406" }); + vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({ + kind: "tip-already-merged", + livePath: null, + tipSha: TIP, + integrationRef: "main", + } as any); + + await manager.reclaimSelfOwnedBranchConflicts(); + + expect(store.updateTask).toHaveBeenCalledWith("FN-1406", expect.objectContaining({ worktree: null, branch: null, baseCommitSha: null })); + expect((store.logEntry as any).mock.calls.some((c: any[]) => String(c[1]).includes("already-merged rejected"))).toBe(false); + }); + + it("still rejects a foreign tip when the base already carries this task's own commit", async () => { + mockInheritedForeignTip({ trailer: "Fusion-Task-Id: FN-1401", baseHasCurrentTask: true }); + mockTodoSweepTask({ id: "FN-1406", column: "todo", checkedOutBy: null, branch: "fusion/fn-1406", worktree: "/tmp/fn-1406", baseCommitSha: TIP }); + vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({ + kind: "tip-already-merged", + livePath: null, + tipSha: TIP, + integrationRef: "main", + } as any); + + const recovered = await manager.reclaimSelfOwnedBranchConflicts(); + + expect(recovered).toBe(0); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-1406", + expect.stringContaining("[recovery] already-merged rejected FN-1406"), + ); + expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:auto-recover-already-merged-rejected", + metadata: expect.objectContaining({ reason: "foreign-task-tip", candidateOwner: "FN-1401", phase: "tip-already-merged" }), + })); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-1406", expect.objectContaining({ branch: null })); + expect(execMock).not.toHaveBeenCalledWith(expect.stringContaining("git branch -D"), expect.anything()); + }); + }); }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 0a70a62cbe..aab3a4a9c2 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -2264,6 +2264,48 @@ export class SelfHealingManager { return false; } + /** + * FNXC:WorkflowRecovery 2026-07-25-09:40: + * Single shared decision for "is this branch tip's foreign `Fusion-Task-Id`/`Fusion-Task-Lineage` trailer real + * evidence of cross-task contamination, or just an inherited start point?" + * + * A task branch cut from the integration branch points at whatever landed last. When the task has not committed + * anything of its own (planning aborted, spec revise, operator move back to `todo`), the branch tip IS that + * previous task's commit — a foreign trailer with zero foreign intent. FN-1406 hit exactly this: `fusion/fn-1406` + * was cut from `main` at FN-1401's landed commit, planning ended without a commit, and the reclaim sweep logged + * `already-merged rejected ... owner=FN-1401 reason=foreign-task-tip` every pass instead of clearing the stale + * worktree/branch metadata. + * + * Merge-base-to-tip diff proof separates the two: no unique diff means the branch carries no task content, so the + * foreign trailer is inherited and the caller may treat the row as stale metadata. The one exception is when the + * base ALREADY has an explicit commit for this task — then the branch should have been sitting on the task's own + * landed commit, and a foreign tip is genuine misbinding worth rejecting. + * + * Callers: already-merged recovery (`branchTipForeignOwnership`), branch-misbound recovery + * (`isBranchTipMisboundToTask`), and the self-owned-branch reclaim sweep's `tip-already-merged` arm. Keeping the + * three on one helper is the point — the reclaim arm drifted without the diff proof and that was the bug. + */ + private async foreignTipRejection(input: { + taskId: string; + lineageId?: string; + branchTip: string; + baseBranch: string; + ownership: Awaited>; + }): Promise<{ reason: "foreign-task-tip" | "foreign-lineage-tip"; owner?: string } | null> { + const { taskId, lineageId, branchTip, baseBranch, ownership } = input; + if (ownership.rejectionReason !== "foreign-task" && ownership.rejectionReason !== "foreign-lineage") return null; + + const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(branchTip, baseBranch).catch(() => false); + const baseAlreadyHasCurrentTask = hasNoUniqueDiff + ? await this.baseHasExplicitTaskOwnership(taskId, lineageId, baseBranch).catch(() => false) + : false; + if (hasNoUniqueDiff && !baseAlreadyHasCurrentTask) return null; + + return ownership.rejectionReason === "foreign-task" + ? { reason: "foreign-task-tip", owner: ownership.ownerTaskId } + : { reason: "foreign-lineage-tip", owner: ownership.ownerLineageId }; + } + private async rejectForeignAlreadyMergedCandidate(input: { task: Pick; candidateSha: string; @@ -2326,7 +2368,6 @@ export class SelfHealingManager { } const sha = stdout.trim(); if (!sha) return null; - const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(sha, baseBranch).catch(() => false); let ownership: Awaited>; try { ownership = await this.readCommitTaskOwnership(sha, taskId, lineageId); @@ -2336,16 +2377,12 @@ export class SelfHealingManager { /* 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. + + FNXC:WorkflowRecovery 2026-07-25-09:40: + That diff-proof classification now lives in `foreignTipRejection` so this path, branch-misbound recovery, and the reclaim sweep cannot drift apart again. */ - 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 ((!hasNoUniqueDiff || baseAlreadyHasCurrentTask) && ownership.rejectionReason === "foreign-lineage") { - return { sha, owner: ownership.ownerLineageId, reason: "foreign-lineage-tip" }; - } + const rejection = await this.foreignTipRejection({ taskId, lineageId, branchTip: sha, baseBranch, ownership }); + if (rejection) return { sha, owner: rejection.owner, reason: rejection.reason }; return null; } @@ -3809,14 +3846,32 @@ export class SelfHealingManager { if (!ownership) { continue; } - if (ownership?.rejectionReason === "foreign-task" || ownership?.rejectionReason === "foreign-lineage") { + /* + FNXC:SelfHealingReclaim 2026-07-25-09:40: + The foreign-trailer veto here must use the same merge-base diff proof as already-merged and branch-misbound + recovery. A `tip-already-merged` verdict means the tip is an ancestor of the integration ref, which is the + normal shape of a task branch that was cut from the base and never committed anything: its inherited tip is + the PREVIOUS task's landed commit. Vetoing on the trailer alone left that row holding stale + worktree/branch/baseCommitSha metadata and re-logged `reason=foreign-task-tip` on every sweep (FN-1406, + inheriting FN-1401's tip). `foreignTipRejection` only rejects when the branch has unique content, or when + the base already carries this task's own commit (real misbinding); otherwise fall through to the stale-cached + -metadata reclaim below, which is safe precisely because the branch contains nothing unique to lose. + */ + const foreignRejection = await this.foreignTipRejection({ + taskId: task.id, + lineageId: task.lineageId, + branchTip: inspection.tipSha, + baseBranch: inspection.integrationRef, + ownership, + }); + if (foreignRejection) { await this.rejectForeignAlreadyMergedCandidate({ task, candidateSha: inspection.tipSha, - candidateOwner: ownership.rejectionReason === "foreign-task" ? ownership.ownerTaskId : ownership.ownerLineageId, + candidateOwner: foreignRejection.owner, taskBranch: branchName, baseBranch: inspection.integrationRef, - reason: ownership.rejectionReason === "foreign-task" ? "foreign-task-tip" : "foreign-lineage-tip", + reason: foreignRejection.reason, phase: "tip-already-merged", }); continue; @@ -10222,20 +10277,17 @@ 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); /* 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. + + FNXC:WorkflowRecovery 2026-07-25-09:40: + Shared with already-merged recovery and the reclaim sweep via `foreignTipRejection`. */ - 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 ((!hasNoUniqueDiff || baseAlreadyHasCurrentTask) && ownership.rejectionReason === "foreign-lineage") { - return { misbound: false, branchTip, landed: null, rejection: { reason: "foreign-lineage-tip", owner: ownership.ownerLineageId } }; + const rejection = await this.foreignTipRejection({ taskId, lineageId, branchTip, baseBranch, ownership }); + if (rejection) { + return { misbound: false, branchTip, landed: null, rejection }; } const hasTaskId = ownership.ownerTaskId === taskId; const hasLineage = lineageId ? ownership.ownerLineageId === lineageId : false;