fix(FN-7231): require current workflow merge proof

This commit is contained in:
gsxdsm
2026-06-29 09:28:02 -07:00
parent 6ce61affe0
commit 123639c89f
8 changed files with 289 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent workflow tasks from completing with stale or partial merge proof.
category: fix
dev: Workflow finalization now validates incomplete steps, no-op proof, and branch file coverage before done.

View File

@@ -357,6 +357,89 @@ describe("auto-merge proven finalization helper", () => {
}));
});
it("blocks workflow finalization while planned steps are still incomplete", async () => {
const strandedTask = {
id: "FN-INCOMPLETE",
title: "Incomplete workflow",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [{ status: "done" }, { status: "pending" }],
currentStep: 1,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeDetails: { mergeConfirmed: true, commitSha: "abc123", landedFiles: ["packages/engine/src/executor.ts"] },
} as Task;
const store = createMockStore(strandedTask) as unknown as TaskStore & {
getTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
moveTask: ReturnType<typeof vi.fn>;
recordRunAuditEvent: ReturnType<typeof vi.fn>;
};
store.getTask.mockResolvedValue(strandedTask);
const result = await finalizeProvenAutoMergeTask({
store,
taskId: "FN-INCOMPLETE",
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: "blocked", reason: "task has incomplete steps" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-INCOMPLETE", expect.objectContaining({
status: "failed",
error: "Merge confirmed but finalization blocked: task has incomplete steps",
}));
expect(store.moveTask).not.toHaveBeenCalled();
});
it("blocks workflow finalization when the task branch has files missing from merge proof", async () => {
const strandedTask = {
id: "FN-BRANCH-PROOF",
title: "Stale proof",
description: "Test",
column: "in-progress",
branch: "fusion/fn-branch-proof",
baseBranch: "main",
dependencies: [],
steps: [{ status: "done" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeDetails: { mergeConfirmed: true, commitSha: "abc123", landedFiles: ["packages/engine/src/executor.ts"] },
} as Task;
const store = createMockStore(strandedTask) as unknown as TaskStore & {
getTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
moveTask: ReturnType<typeof vi.fn>;
recordRunAuditEvent: ReturnType<typeof vi.fn>;
};
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;
});
const result = await finalizeProvenAutoMergeTask({
store,
taskId: "FN-BRANCH-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: "blocked", reason: "branch-diff-missing-from-merge-proof" }));
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
});
it("treats already-done landed rows as idempotent success", async () => {
const doneTask = {
id: "FN-DONE",

View File

@@ -4974,7 +4974,10 @@ describe("SelfHealingManager", () => {
expect(result).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
expect.stringContaining("already-merged rejected FN-1"),
);
managerWithRecovery.stop();
});
@@ -7987,6 +7990,55 @@ describe("recoverDoneTaskMergeMetadata", () => {
manager.stop();
});
it("skips done-task metadata repair when branch diff is missing from merge proof", async () => {
const store = createMockStore();
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-7231",
column: "done",
paused: false,
branch: "fusion/fn-7231",
baseBranch: "main",
steps: [{ status: "done" }],
mergeDetails: {
commitSha: "merge1",
mergeConfirmed: true,
filesChanged: 1,
insertions: 1,
deletions: 0,
mergeCommitMessage: "fix(FN-7231): stale proof",
landedFiles: ["packages/engine/src/executor.ts"],
},
},
]);
mockedExecSync.mockImplementation((command) => {
const cmd = String(command);
if (cmd.includes("merge-base --is-ancestor") && cmd.includes("merge1")) return "" as any;
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;
});
const repaired = await manager.recoverDoneTaskMergeMetadata();
expect(repaired).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-7231",
expect.stringContaining("invalid workflow merge proof (branch-diff-missing-from-merge-proof)"),
);
mockedExecSync.mockReset();
manager.stop();
});
it("populates rebaseBaseSha from landed commit when missing", async () => {
const store = createMockStore();
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });

View File

@@ -1,6 +1,10 @@
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'");
@@ -17,6 +21,7 @@ export interface FinalizeProvenAutoMergeTaskOptions {
store: TaskStore;
taskId: string;
result?: MergeResult;
rootDir?: string;
audit?: RunAuditor;
auditAgentId?: string;
auditPhase?: string;
@@ -24,6 +29,77 @@ export interface FinalizeProvenAutoMergeTaskOptions {
log?: (message: string) => void | Promise<void>;
}
export type WorkflowDoneMergeProofVerdict =
| { ok: true }
| { ok: false; reason: string; metadata?: Record<string, unknown> };
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)));
}
function hasIncompleteWorkflowSteps(task: Task): boolean {
return (task.steps ?? []).some((step) => step.status !== "done" && step.status !== "skipped");
}
async function readBranchDiffFiles(rootDir: string, task: Task): Promise<string[] | null> {
const branch = task.branch;
if (!branch) return null;
const baseBranch = 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(baseBranch)}`, { cwd: rootDir, maxBuffer: 1024 * 1024 });
const { stdout } = await execAsync(`git diff --name-only ${shellQuote(`${baseBranch}...${branch}`)}`, {
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 } = {},
): Promise<WorkflowDoneMergeProofVerdict> {
const hasProof = hasDurableMergeProof(task, options.result);
if (!hasProof) return { ok: false, reason: task.column === "done" ? "done-without-merge-confirmation" : "missing-merge-confirmation" };
if (options.checkWorkflowSteps !== false && hasIncompleteWorkflowSteps(task)) {
return { ok: false, reason: "incomplete-workflow-steps" };
}
const noOp = options.result?.noOp === true || task.mergeDetails?.noOpMerge === true;
const landedFiles = mergeProofLandedFiles(task, options.result);
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 landed = new Set(landedFiles);
const missing = branchFiles.filter((file) => !landed.has(file));
if (missing.length > 0) {
return {
ok: false,
reason: "branch-diff-missing-from-merge-proof",
metadata: { missingFiles: missing.slice(0, 10), missingCount: missing.length, branchFiles: branchFiles.length },
};
}
}
}
return { ok: true };
}
function buildMismatchMetadata(task: Task, reason: string): Record<string, unknown> {
return {
taskId: task.id,
@@ -99,6 +175,7 @@ export async function finalizeProvenAutoMergeTask({
store,
taskId,
result,
rootDir,
audit,
auditAgentId,
auditPhase,
@@ -110,25 +187,31 @@ export async function finalizeProvenAutoMergeTask({
return { outcome: "missing", task: null, previousColumn: null, reason: "task-not-found" };
}
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.
*/
if (latest.column === "done") {
if (!hasDurableMergeProof(latest, result)) {
const reason = "done-without-merge-confirmation";
const proofVerdict = await validateWorkflowDoneMergeProof({ ...latest, mergeDetails: validationMergeDetails } as Task, { rootDir, result });
if (!proofVerdict.ok) {
await recordFinalizationAudit({
store,
audit,
task: latest,
type: "task:auto-merge-finalize-column-mismatch-no-action",
reason,
reason: proofVerdict.reason,
auditAgentId,
auditPhase,
});
return { outcome: "blocked", task: latest, previousColumn: "done", reason };
await log?.(`Auto-merge finalization blocked for ${taskId}: ${proofVerdict.reason}`);
return { outcome: "blocked", task: latest, previousColumn: latest.column, reason: proofVerdict.reason };
}
if (result) result.task = latest;
return { outcome: "already-done", task: latest, previousColumn: "done" };
}
const mergeDetails = buildFinalizationMergeDetails(latest, result);
const mergeDetails = validationMergeDetails;
const hasProof = hasDurableMergeProof({ ...latest, mergeDetails } as Task, result);
if (!hasProof) {
const reason = "missing-merge-confirmation";
@@ -172,6 +255,25 @@ export async function finalizeProvenAutoMergeTask({
return { outcome: "blocked", task: latest, previousColumn: latest.column, reason: hardBlocker };
}
const proofVerdict = await validateWorkflowDoneMergeProof({ ...latest, mergeDetails } as Task, {
rootDir,
result,
checkWorkflowSteps: false,
});
if (!proofVerdict.ok) {
await recordFinalizationAudit({
store,
audit,
task: latest,
type: "task:auto-merge-finalize-column-mismatch-no-action",
reason: proofVerdict.reason,
auditAgentId,
auditPhase,
});
await log?.(`Auto-merge finalization blocked for ${taskId}: ${proofVerdict.reason}`);
return { outcome: "blocked", task: latest, previousColumn: latest.column, reason: proofVerdict.reason };
}
await store.updateTask(taskId, {
paused: false,
status: null,

View File

@@ -5789,6 +5789,7 @@ export class TaskExecutor {
store: this.store,
taskId: task.id,
result,
rootDir: this.rootDir,
audit: createRunAuditor(this.store, {
runId: ctx.run.runId,
agentId: "executor",
@@ -6618,6 +6619,7 @@ export class TaskExecutor {
reason: live.mergeDetails?.noOpReason,
mergeConfirmed: true,
} as MergeResult,
rootDir: this.rootDir,
audit: createRunAuditor(this.store, {
runId: generateSyntheticRunId("workflow-graph-merge-finalize", taskId),
agentId: "executor",

View File

@@ -840,7 +840,7 @@ export async function runAiMerge(
target: branch,
metadata: { taskId, kind: alreadyMerged ? "already-merged" : "never-executed" },
});
return await finalizeTask(store, taskId, noOpResult(task, branch, alreadyMerged ? "already-merged" : "no-branch"));
return await finalizeTask(store, taskId, noOpResult(task, branch, alreadyMerged ? "already-merged" : "no-branch"), undefined, undefined, projectRootDir);
}
// The target branch must exist as a LOCAL ref to merge into it — surface a
@@ -1554,7 +1554,7 @@ async function finalizeMerged(
};
await audit.git({ type: "merge:ai-landed", target: integrationBranch, metadata: { taskId, landedSha, empty: opts.empty } }).catch(() => undefined);
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op), finalizing task row` : `AI merge: landed ${short(landedSha)}, finalizing task row`);
const finalized = await finalizeTask(store, taskId, result, audit, log);
const finalized = await finalizeTask(store, taskId, result, audit, log, projectRootDir);
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op) → done` : `AI merge: landed ${short(landedSha)}, task → done`);
return finalized;
}
@@ -1566,6 +1566,7 @@ async function finalizeTask(
result: MergeResult,
audit?: RunAuditor,
log?: (message: string) => Promise<void>,
rootDir?: string,
): Promise<MergeResult> {
const finalization = await finalizeProvenAutoMergeTask({
store,
@@ -1575,6 +1576,7 @@ async function finalizeTask(
auditAgentId: "merger",
auditPhase: "direct-ai-merge-finalize",
source: "direct-ai-merge",
rootDir,
log,
});
if (finalization.outcome === "blocked") {

View File

@@ -2163,6 +2163,7 @@ export class ProjectEngine {
reason: task.mergeDetails?.noOpReason,
mergeConfirmed: task.mergeDetails?.mergeConfirmed === true,
} as MergeResult,
rootDir: cwd,
audit: auditor,
auditAgentId: "merger",
auditPhase: "auto-merge-fast-path-finalize",

View File

@@ -45,7 +45,7 @@ import {
import { classifyError, extractMissingModulePath, isNonContinuableSessionError, isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError } from "./transient-error-detector.js";
import { classifyForeignOnlyContamination, deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js";
import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { finalizeProvenAutoMergeTask, validateWorkflowDoneMergeProof } from "./auto-merge-finalization.js";
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js";
/*
@@ -7692,6 +7692,23 @@ export class SelfHealingManager {
const liveShortstat = await this.readShortstatForSha(storedSha, task.mergeDetails?.rebaseBaseSha);
const liveLandedFiles = await this.readLandedFilesForSha(storedSha, task.mergeDetails?.rebaseBaseSha);
const currentLandedFiles = task.mergeDetails?.landedFiles;
const confirmedProofVerdict = await validateWorkflowDoneMergeProof({
...task,
mergeDetails: {
...task.mergeDetails,
landedFiles: liveLandedFiles ?? currentLandedFiles,
mergeConfirmed: true,
},
} as Task, { rootDir: this.options.rootDir });
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.
*/
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);
continue;
}
const landedFilesMismatch = Boolean(
liveLandedFiles && (
!currentLandedFiles ||
@@ -7765,6 +7782,20 @@ export class SelfHealingManager {
deletions: landed.deletions ?? 0,
};
const landedFiles = await this.readLandedFilesForSha(landed.sha, task.mergeDetails?.rebaseBaseSha ?? landed.rebaseBaseSha);
const repairedProofVerdict = await validateWorkflowDoneMergeProof({
...task,
mergeDetails: {
...task.mergeDetails,
commitSha: landed.sha,
landedFiles: landedFiles ?? task.mergeDetails?.landedFiles,
mergeConfirmed: true,
},
} as Task, { rootDir: this.options.rootDir });
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);
continue;
}
const needsRepair =
task.mergeDetails?.commitSha !== landed.sha ||