diff --git a/.changeset/task-patch-finalization.md b/.changeset/task-patch-finalization.md new file mode 100644 index 0000000000..19511b36e1 --- /dev/null +++ b/.changeset/task-patch-finalization.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Let proven task merges finalize even when old branch history remains. +category: fix +dev: Auto-merge finalization now trusts durable task merge proof instead of blocking on stale branch-only residue. diff --git a/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts b/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts index 678ad38aa8..eb5a2480e0 100644 --- a/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts +++ b/packages/engine/src/__tests__/merger-merge-lifecycle.test.ts @@ -462,7 +462,7 @@ describe("auto-merge proven finalization helper", () => { expect(store.moveTask).not.toHaveBeenCalled(); }); - it("blocks workflow finalization when the task branch has files missing from merge proof", async () => { + it("finalizes proven workflow merges even when the task branch still has residue outside the landed patch", async () => { const strandedTask = { id: "FN-BRANCH-PROOF", title: "Stale proof", @@ -485,14 +485,7 @@ describe("auto-merge proven finalization helper", () => { recordRunAuditEvent: ReturnType; }; store.getTask.mockResolvedValue(strandedTask); - mockedExecSync.mockImplementation((cmd: any) => { - const command = String(cmd); - if (command.includes("rev-parse --verify")) return "ok\n" as any; - if (command.includes("git diff --name-only") && command.includes("main...fusion/fn-branch-proof")) { - return "packages/dashboard/app/TaskChatTab.css\n" as any; - } - return "" as any; - }); + mockedExecSync.mockImplementation(() => "" as any); const result = await finalizeProvenAutoMergeTask({ store, @@ -502,81 +495,13 @@ describe("auto-merge proven finalization helper", () => { rootDir: "/repo", }); - expect(result).toEqual(expect.objectContaining({ outcome: "blocked", reason: "branch-diff-missing-from-merge-proof" })); - expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("allows workflow finalization when missing branch proof is outside the declared File Scope", async () => { - const strandedTask = { - id: "FN-SCOPED-PROOF", - title: "Scoped proof", - description: "Test", - column: "in-progress", - branch: "fusion/fn-scoped-proof", - baseBranch: "main", - dependencies: [], - steps: [{ status: "done" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - sourceMetadata: { - fileScope: [ - "packages/dashboard/app/components/EngineControlMenu.tsx", - "packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx", - "docs/dashboard-guide.md", - ".changeset/*.md", - ], - }, - mergeDetails: { - mergeConfirmed: true, - commitSha: "abc123", - landedFiles: [ - "packages/dashboard/app/components/EngineControlMenu.tsx", - "packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx", - "docs/dashboard-guide.md", - ".changeset/fn-7235-footer-concurrency-marker.md", - ], - }, - } as Task; - const store = createMockStore(strandedTask) as unknown as TaskStore & { - getTask: ReturnType; - updateTask: ReturnType; - moveTask: ReturnType; - recordRunAuditEvent: ReturnType; - }; - store.getTask.mockResolvedValue(strandedTask); - mockedExecSync.mockImplementation((cmd: any) => { - const command = String(cmd); - if (command.includes("rev-parse --verify")) return "ok\n" as any; - if (command.includes("git diff --name-only") && command.includes("main...fusion/fn-scoped-proof")) { - return [ - "packages/dashboard/app/components/EngineControlMenu.tsx", - "packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx", - "docs/dashboard-guide.md", - ".changeset/fn-7235-footer-concurrency-marker.md", - "packages/engine/src/triage.ts", - ].join("\n") as any; - } - return "" as any; - }); - - const result = await finalizeProvenAutoMergeTask({ - store, - taskId: "FN-SCOPED-PROOF", - result: { task: strandedTask, ok: true, merged: true, commitSha: "abc123", mergeConfirmed: true } as MergeResult, - source: "workflow-graph-merge-finalize", - rootDir: "/repo", - }); - expect(result).toEqual(expect.objectContaining({ outcome: "done" })); - expect(store.moveTask).toHaveBeenCalledWith("FN-SCOPED-PROOF", "done", expect.objectContaining({ + expect(store.moveTask).toHaveBeenCalledWith("FN-BRANCH-PROOF", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true, recoveryRehome: true, })); - expect(store.updateTask).not.toHaveBeenCalledWith("FN-SCOPED-PROOF", expect.objectContaining({ status: "failed" })); + expect(mockedExecSync.mock.calls.some(([cmd]) => String(cmd).includes("git diff --name-only"))).toBe(false); }); it("treats already-done landed rows as idempotent success", async () => { diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index fdefc5bbfa..650d3a8ef5 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -8121,7 +8121,7 @@ describe("recoverDoneTaskMergeMetadata", () => { manager.stop(); }); - it("skips done-task metadata repair when branch diff is missing from merge proof", async () => { + it("accepts done-task metadata repair when stale branch residue is outside the landed proof", async () => { const store = createMockStore(); const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); @@ -8151,8 +8151,6 @@ describe("recoverDoneTaskMergeMetadata", () => { if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b") && cmd.includes("merge1")) return "merge1\u001ffix(FN-7231): stale proof\u001fFusion-Task-Id: FN-7231" as any; if (cmd.includes("show --shortstat --format=") && cmd.includes("merge1")) return "1 file changed, 1 insertion(+)" as any; if (cmd.includes("show --name-only --format=") && cmd.includes("merge1")) return "packages/engine/src/executor.ts\n" as any; - if (cmd.includes("rev-parse --verify")) return "ok\n" as any; - if (cmd.includes("git diff --name-only") && cmd.includes("main...fusion/fn-7231")) return "packages/dashboard/app/TaskChatTab.css\n" as any; if (cmd.includes("Fusion-Task-Id: FN-7231")) return "merge1\u001ffix(FN-7231): stale proof\n" as any; return "" as any; }); @@ -8161,10 +8159,11 @@ describe("recoverDoneTaskMergeMetadata", () => { expect(repaired).toBe(0); expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith( + expect(store.logEntry).not.toHaveBeenCalledWith( "FN-7231", - expect.stringContaining("invalid workflow merge proof (branch-diff-missing-from-merge-proof)"), + expect.stringContaining("invalid workflow merge proof"), ); + expect(mockedExecSync.mock.calls.some(([cmd]) => String(cmd).includes("git diff --name-only"))).toBe(false); mockedExecSync.mockReset(); manager.stop(); diff --git a/packages/engine/src/auto-merge-finalization.ts b/packages/engine/src/auto-merge-finalization.ts index d9975bc89f..92d75b2774 100644 --- a/packages/engine/src/auto-merge-finalization.ts +++ b/packages/engine/src/auto-merge-finalization.ts @@ -1,10 +1,6 @@ -import { exec } from "node:child_process"; -import { promisify } from "node:util"; import { getTaskHardMergeBlocker, type MergeResult, type Task, type TaskStore } from "@fusion/core"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js"; -const execAsync = promisify(exec); - export function isInvalidDoneTransitionError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return message.includes("Invalid transition:") && message.includes("→ 'done'"); @@ -33,10 +29,6 @@ export type WorkflowDoneMergeProofVerdict = | { ok: true } | { ok: false; reason: string; metadata?: Record }; -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - function mergeProofLandedFiles(task: Task, result?: MergeResult): string[] { const files = result?.landedFiles ?? task.mergeDetails?.landedFiles ?? []; return Array.from(new Set(files.map((file) => file.trim()).filter(Boolean))); @@ -46,119 +38,9 @@ function hasIncompleteWorkflowSteps(task: Task): boolean { return (task.steps ?? []).some((step) => step.status !== "done" && step.status !== "skipped"); } -function cleanScopeEntry(entry: string): string { - let cleaned = entry.trim().replace(/^[-*]\s+/, ""); - const codeSpan = cleaned.match(/`([^`]+)`/); - if (codeSpan) cleaned = codeSpan[1]; - return cleaned - .replace(/^\//, "") - .replace(/\s+\((new|modified|existing)\)\s*$/i, "") - .trim(); -} - -function extractMarkdownSection(prompt: string, heading: string): string { - const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headingPattern = new RegExp(`^##\\s+${escaped}\\s*:?\\s*$`, "i"); - const lines = prompt.split(/\r?\n/); - const start = lines.findIndex((line) => headingPattern.test(line.trim())); - if (start === -1) return ""; - const sectionLines: string[] = []; - for (let i = start + 1; i < lines.length; i++) { - if (/^##\s+/.test(lines[i].trim())) break; - sectionLines.push(lines[i]); - } - return sectionLines.join("\n"); -} - -function extractScopeEntriesFromPrompt(prompt: string | undefined): string[] { - if (!prompt) return []; - return extractMarkdownSection(prompt, "File Scope") - .split(/\r?\n/) - .map(cleanScopeEntry) - .filter(Boolean); -} - -function getTaskFileScope(task: Task): string[] { - const metadataScope = Array.isArray(task.sourceMetadata?.fileScope) - ? task.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string") - : []; - return Array.from(new Set([...metadataScope, ...extractScopeEntriesFromPrompt(task.prompt)].map(cleanScopeEntry).filter(Boolean))); -} - -function globToRegex(pattern: string): RegExp { - let source = ""; - for (let i = 0; i < pattern.length; i++) { - const char = pattern[i]; - if (char === "*") { - if (pattern[i + 1] === "*") { - source += ".*"; - i++; - } else { - source += "[^/]*"; - } - continue; - } - source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&"); - } - return new RegExp(`^${source}$`); -} - -function matchesFileScope(filePath: string, scopeEntry: string): boolean { - const file = filePath.replace(/^\.\/+/, ""); - const scope = scopeEntry.replace(/^\.\/+/, ""); - if (!scope || /\b(no source|no code|task document|read-only)\b/i.test(scope)) return false; - if (file === scope) return true; - if (scope.endsWith("/")) return file.startsWith(scope); - if (scope.endsWith("/**")) return file.startsWith(scope.slice(0, -2)); - if (scope.includes("*")) return globToRegex(scope).test(file); - return file.startsWith(`${scope}/`); -} - -function branchDiffFilesMissingFromMergeProof(task: Task, branchFiles: string[], landedFiles: string[]): { - blockingMissing: string[]; - ignoredOutOfScopeMissing: string[]; -} { - const landed = new Set(landedFiles); - const missing = branchFiles.filter((file) => !landed.has(file)); - const scope = getTaskFileScope(task); - if (scope.length === 0) return { blockingMissing: missing, ignoredOutOfScopeMissing: [] }; - - /* - * FNXC:WorkflowMergeFinalization 2026-06-29-13:56: - * Scoped squash merges may intentionally land only the task's declared File Scope while a stale task branch still carries unrelated residue from a previous remediation or contaminated branch. Finalization must still block any in-scope branch diff missing from durable merge proof, but out-of-scope residue should not strand an already-landed workflow task in review forever. - */ - const blockingMissing = missing.filter((file) => scope.some((entry) => matchesFileScope(file, entry))); - return { - blockingMissing, - ignoredOutOfScopeMissing: missing.filter((file) => !blockingMissing.includes(file)), - }; -} - -async function readBranchDiffFiles(rootDir: string, task: Task): Promise { - const branch = task.branch; - if (!branch) return null; - /* - * FNXC:AutoMergeFinalization 2026-07-01-08:35: - * Branch-proof validation must measure the task's own diff, not every commit reachable from the task branch but absent from current main. Fresh-worktree bugs and historical recovery paths can leave a branch with foreign ancestor commits; when the merger has already landed the recorded task files, `baseCommitSha..branch` is the authoritative task-owned range and prevents unrelated ancestor files from stranding a mergeConfirmed task in `landing`. - */ - const diffBase = task.baseCommitSha ?? task.mergeDetails?.mergeTargetBranch ?? task.baseBranch ?? "main"; - try { - await execAsync(`git rev-parse --verify ${shellQuote(`refs/heads/${branch}`)}`, { cwd: rootDir, maxBuffer: 1024 * 1024 }); - await execAsync(`git rev-parse --verify ${shellQuote(diffBase)}`, { cwd: rootDir, maxBuffer: 1024 * 1024 }); - const range = task.baseCommitSha ? `${diffBase}..${branch}` : `${diffBase}...${branch}`; - const { stdout } = await execAsync(`git diff --name-only ${shellQuote(range)}`, { - cwd: rootDir, - maxBuffer: 1024 * 1024, - }); - return Array.from(new Set(stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))); - } catch { - return null; - } -} - export async function validateWorkflowDoneMergeProof( task: Task, - options: { rootDir?: string; result?: MergeResult; checkWorkflowSteps?: boolean } = {}, + options: { result?: MergeResult; checkWorkflowSteps?: boolean } = {}, ): Promise { const hasProof = hasDurableMergeProof(task, options.result); if (!hasProof) return { ok: false, reason: task.column === "done" ? "done-without-merge-confirmation" : "missing-merge-confirmation" }; @@ -171,29 +53,10 @@ export async function validateWorkflowDoneMergeProof( if (noOp && landedFiles.length > 0) { return { ok: false, reason: "noop-merge-with-landed-files", metadata: { landedFiles: landedFiles.length } }; } - - if (options.rootDir) { - const branchFiles = await readBranchDiffFiles(options.rootDir, task); - if (branchFiles && branchFiles.length > 0) { - if (noOp) { - return { ok: false, reason: "noop-merge-branch-still-has-diff", metadata: { branchFiles: branchFiles.length } }; - } - const { blockingMissing, ignoredOutOfScopeMissing } = branchDiffFilesMissingFromMergeProof(task, branchFiles, landedFiles); - if (blockingMissing.length > 0) { - return { - ok: false, - reason: "branch-diff-missing-from-merge-proof", - metadata: { - missingFiles: blockingMissing.slice(0, 10), - missingCount: blockingMissing.length, - ignoredOutOfScopeMissingFiles: ignoredOutOfScopeMissing.slice(0, 10), - ignoredOutOfScopeMissingCount: ignoredOutOfScopeMissing.length, - branchFiles: branchFiles.length, - }, - }; - } - } - } + /* + * FNXC:AutoMergeFinalization 2026-07-01-10:22: + * Finalization cares whether the task patch landed on the integration branch, not whether the task branch history is clean after squash merges. Historical task branches can retain patch-equivalent foreign commits whose SHAs are not ancestors of main; once durable merge proof exists, branch residue must not strand the task in review. + */ return { ok: true }; } @@ -273,7 +136,6 @@ export async function finalizeProvenAutoMergeTask({ store, taskId, result, - rootDir, audit, auditAgentId, auditPhase, @@ -288,10 +150,10 @@ export async function finalizeProvenAutoMergeTask({ const validationMergeDetails = buildFinalizationMergeDetails(latest, result); /* * FNXC:WorkflowMerge 2026-06-29-10:35: - * Workflow-owned completion requires current merge proof, not just a stale `mergeConfirmed` flag. A task cannot reach or remain accepted as `done` when workflow steps are still pending, a no-op claims landed files, or the task branch still has files missing from the recorded landed commit. + * Workflow-owned completion requires current merge proof, not just a stale `mergeConfirmed` flag. A task cannot reach or remain accepted as `done` when workflow steps are still pending or a no-op claims landed files. Branch-only residue is ignored because squash landing validates the task patch, not branch-history cleanliness. */ if (latest.column === "done") { - const proofVerdict = await validateWorkflowDoneMergeProof({ ...latest, mergeDetails: validationMergeDetails } as Task, { rootDir, result }); + const proofVerdict = await validateWorkflowDoneMergeProof({ ...latest, mergeDetails: validationMergeDetails } as Task, { result }); if (!proofVerdict.ok) { await recordFinalizationAudit({ store, @@ -354,7 +216,6 @@ export async function finalizeProvenAutoMergeTask({ } const proofVerdict = await validateWorkflowDoneMergeProof({ ...latest, mergeDetails } as Task, { - rootDir, result, checkWorkflowSteps: false, }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 258b4de5c3..addc350ff4 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -7818,11 +7818,11 @@ export class SelfHealingManager { landedFiles: liveLandedFiles ?? currentLandedFiles, mergeConfirmed: true, }, - } as Task, { rootDir: this.options.rootDir }); + } as Task); if (!confirmedProofVerdict.ok) { /* - FNXC:WorkflowMerge 2026-06-29-10:42: - Done-task metadata repair is not allowed to convert stale workflow proof into truth. If the branch still carries files outside the recorded landed commit, or the workflow still has pending steps, leave the row unchanged so workflow retry/recovery can resume the merge path instead of hiding unmerged work. + FNXC:WorkflowMerge 2026-07-01-10:28: + Done-task metadata repair is not allowed to convert stale workflow proof into truth. Missing merge confirmation, pending workflow steps, or invalid no-op proof still block repair; stale branch residue does not, because finalization only needs durable evidence that the task patch landed. */ log.warn(`recoverDoneTaskMergeMetadata: skipped ${task.id} — invalid done merge proof (${confirmedProofVerdict.reason})`); await this.store.logEntry(task.id, `Done-task merge metadata repair skipped: invalid workflow merge proof (${confirmedProofVerdict.reason})`).catch(() => undefined); @@ -7909,7 +7909,7 @@ export class SelfHealingManager { landedFiles: landedFiles ?? task.mergeDetails?.landedFiles, mergeConfirmed: true, }, - } as Task, { rootDir: this.options.rootDir }); + } as Task); if (!repairedProofVerdict.ok) { log.warn(`recoverDoneTaskMergeMetadata: skipped ${task.id} — invalid repaired merge proof (${repairedProofVerdict.reason})`); await this.store.logEntry(task.id, `Done-task merge metadata repair skipped: invalid repaired workflow merge proof (${repairedProofVerdict.reason})`).catch(() => undefined);