fix(FN-7231): require current workflow merge proof
This commit is contained in:
7
.changeset/fn-7231-workflow-merge-proof.md
Normal file
7
.changeset/fn-7231-workflow-merge-proof.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user