fix: let a proven merge finalize even with unfinished steps

FN-9193's branch landed on main as eaa1d47c, but a Code Review revision request
had reset its steps while the approved merge was in flight. The card was left
mergeConfirmed WITH incomplete steps, and every finalization site refused with
"task has incomplete steps" — so it sat failed, re-reading its own contradiction.
Restarting it made things worse: replanning issued seven fresh pending steps, so
the retry re-created the exact condition blocking it. A loop with no exit.

Holding a landed card out of done un-merges nothing; the code is on the target
branch either way. All four finalization sites now use
getMergeConfirmedFinalizationBlocker, which exempts incomplete steps once
landing is proven and records the unfinished ones on the task instead of
dropping them. A no-op merge that landed no content still blocks — that is the
protective half of the guard being replaced, and the executor's no-op branch
depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-23 09:24:31 -07:00
parent 3857e0dc14
commit a879ead0fc
9 changed files with 224 additions and 20 deletions

View File

@@ -484,8 +484,21 @@ describe("auto-merge proven finalization helper", () => {
}));
});
it("blocks workflow finalization while planned steps are still incomplete", async () => {
const strandedTask = {
/*
FNXC:MergeConfirmedFinalization 2026-08-23-21:40 (FN-9193 — DELIBERATE INVERSION of this test):
This case previously asserted that incomplete steps BLOCK a proven merge's finalization. That is
the behaviour that wedged FN-9193: its branch landed on main as eaa1d47c, a Code Review revision
request had reset its steps while the approved merge was in flight, and the card was then left
`mergeConfirmed` WITH incomplete steps — unfinalizable, parked `failed` for five hours. Restarting
it re-planned seven fresh `pending` steps, so the retry re-created the block it was meant to clear.
Holding a landed card out of `done` un-merges nothing; the code is on the target branch either way.
So the exemption is now asserted here, scoped to merges that actually landed content — the sibling
case below keeps the blocker for a no-op merge that landed nothing, which is the protective half
the original test was really carrying.
*/
it("finalizes a proven merge even when planned steps are still incomplete", async () => {
const landedTask = {
id: "FN-INCOMPLETE",
title: "Incomplete workflow",
description: "Test",
@@ -498,27 +511,66 @@ describe("auto-merge proven finalization helper", () => {
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 & {
const store = createMockStore(landedTask) as unknown as TaskStore & {
getTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
moveTask: ReturnType<typeof vi.fn>;
logEntry: ReturnType<typeof vi.fn>;
recordRunAuditEvent: ReturnType<typeof vi.fn>;
};
store.getTask.mockResolvedValue(landedTask);
const result = await finalizeProvenAutoMergeTask({
store,
taskId: "FN-INCOMPLETE",
result: { task: landedTask, ok: true, merged: true, commitSha: "abc123", mergeConfirmed: true } as MergeResult,
source: "workflow-graph-merge-finalize",
rootDir: "/repo",
});
expect(result).not.toEqual(expect.objectContaining({ reason: "task has incomplete steps" }));
expect(store.updateTask).not.toHaveBeenCalledWith("FN-INCOMPLETE", expect.objectContaining({
error: "Merge confirmed but finalization blocked: task has incomplete steps",
}));
// The unfinished work is recorded on the task rather than silently dropped.
expect(store.logEntry).toHaveBeenCalledWith(
"FN-INCOMPLETE",
expect.stringContaining("unfinished step"),
"MergeConfirmedFinalizeUnfinishedSteps",
);
});
it("still blocks finalization when a no-op merge landed no content", async () => {
const noOpTask = {
id: "FN-NOOP",
title: "No-op merge",
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, noOpMerge: true, landedFiles: [] },
} as unknown as Task;
const store = createMockStore(noOpTask) 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);
store.getTask.mockResolvedValue(noOpTask);
const result = await finalizeProvenAutoMergeTask({
store,
taskId: "FN-INCOMPLETE",
result: { task: strandedTask, ok: true, merged: true, commitSha: "abc123", mergeConfirmed: true } as MergeResult,
taskId: "FN-NOOP",
result: { task: noOpTask, ok: true, merged: true, mergeConfirmed: true, noOp: 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();
});

View File

@@ -1,5 +1,6 @@
import {
getTaskHardMergeBlocker,
getMergeConfirmedFinalizationBlocker,
getUnfinishedStepTitles,
resolveWorkflowIrForTask,
resolveCompleteColumn,
resolveMergeOrchestrationColumn,
@@ -285,7 +286,10 @@ export async function finalizeProvenAutoMergeTask({
return { outcome: "blocked", task: latest, previousColumn: latest.column, reason };
}
const hardBlocker = getTaskHardMergeBlocker({
/* FNXC:MergeConfirmedFinalization 2026-08-23-21:40 (FN-9193): landing is already proven above by
`hasDurableMergeProof`, so incomplete steps must not hold the card out of `done` — see the
core helper for why that hold was self-defeating. Unfinished steps are logged, not dropped. */
const hardBlocker = getMergeConfirmedFinalizationBlocker({
...latest,
/*
FNXC:WorkflowMerge 2026-06-29-09:15:
@@ -300,6 +304,14 @@ export async function finalizeProvenAutoMergeTask({
status: clearMergeConfirmedTransientStatus(latest.status),
error: undefined,
});
const unfinishedSteps = getUnfinishedStepTitles(latest);
if (unfinishedSteps.length > 0) {
await store.logEntry(
taskId,
`Finalizing proven merge with ${unfinishedSteps.length} unfinished step(s) — the branch already landed, so these did not run: ${unfinishedSteps.slice(0, 8).join("; ")}`,
"MergeConfirmedFinalizeUnfinishedSteps",
).catch(() => undefined);
}
if (hardBlocker) {
// FNXC:MergeReliability 2026-08-11-21:39: A blocker discovered before finalization
// still writes task lifecycle state, so an orphan must reject rather than return a blocked result.

View File

@@ -33,7 +33,8 @@ import {
emitOverseerRecoveryAttempt,
emitOverseerRetry,
emitOverseerSteering,
getTaskHardMergeBlocker,
getMergeConfirmedFinalizationBlocker,
getUnfinishedStepTitles,
PreMergeStepsNotRunError,
PRE_MERGE_STEPS_NOT_RUN_BLOCKER,
classifyMergeSweepAdmission,
@@ -4369,7 +4370,7 @@ export class ProjectEngine {
continue;
}
} // end !isWorkspaceTask reachability gate (B2): workspace tasks skip the root-cwd commitSha check
const blockerReason = getTaskHardMergeBlocker({
const blockerReason = getMergeConfirmedFinalizationBlocker({
...(task as Task),
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:05 (this parked ALREADY-MERGED work as failed):
@@ -4383,7 +4384,13 @@ export class ProjectEngine {
and for the reason recorded there: `"in-review"` is the review-eligible SENTINEL for this
helper, not a lifecycle column, so a merge-confirmed card evaluates the same blocker set
on a custom workflow as on the builtin one. The column identity of an already-landed card
is not what this check is for — paused / error / incomplete steps still apply.
is not what this check is for.
FNXC:MergeConfirmedFinalization 2026-08-23-21:40 (FN-9193): incomplete steps NO LONGER
apply here. The fast path above already proved this merge landed, and holding a landed
card out of `done` for unfinished steps is what left FN-9193 permanently unfinalizable —
a restart then replanned fresh pending steps and re-created the very block it was meant
to clear. Paused/error/pre-merge-step blockers still apply.
*/
column: REVIEW_ELIGIBLE_SENTINEL_COLUMN,
// Merge-confirmed tasks have already landed. Treat stale merge
@@ -4393,6 +4400,14 @@ export class ProjectEngine {
status: clearMergeConfirmedTransientStatus(task.status),
error: undefined,
});
const unfinishedFastPathSteps = getUnfinishedStepTitles(task as Task);
if (unfinishedFastPathSteps.length > 0) {
await store.logEntry(
taskId,
`Finalizing proven merge with ${unfinishedFastPathSteps.length} unfinished step(s) — the branch already landed, so these did not run: ${unfinishedFastPathSteps.slice(0, 8).join("; ")}`,
"MergeConfirmedFinalizeUnfinishedSteps",
).catch(() => undefined);
}
if (blockerReason) {
await store.updateTask(taskId, {
status: "failed",

View File

@@ -30,7 +30,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import { loadWorkspaceConfig, type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, hasSharedBranchMemberAutoMergeHold, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isLiveSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, resolveReboundTargetForTask, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
import { loadWorkspaceConfig, type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, hasSharedBranchMemberAutoMergeHold, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getMergeConfirmedFinalizationBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isLiveSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, resolveReboundTargetForTask, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
resolveNearDuplicateCanonicalFlags,
LEGACY_COLUMN_IDS_BY_ROLE,
TERMINAL_ROLES,
@@ -12131,8 +12131,11 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
mergeTargetSource: mergeTarget.source,
};
/* Wired: unwired, this would decline every card the widened read now finds. */
const hardBlocker = getTaskHardMergeBlocker({
/* Wired: unwired, this would decline every card the widened read now finds.
FNXC:MergeConfirmedFinalization 2026-08-23-21:40 (FN-9193): this sweep has already
proven the content landed on the base branch, so incomplete steps must not hold the
card — that hold is what left FN-9193 unfinalizable, and a restart re-created it. */
const hardBlocker = getMergeConfirmedFinalizationBlocker({
...task,
steps: task.steps ?? [],
workflowStepResults: task.workflowStepResults,
@@ -12395,8 +12398,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
mergeTargetSource: mergeTarget.source,
};
/* Wired with THIS card's review lanes — see the note on `ownReviewLanesForAlreadyMerged` above. */
const hardBlocker = getTaskHardMergeBlocker({
/* Wired with THIS card's review lanes — see the note on `ownReviewLanesForAlreadyMerged` above.
FNXC:MergeConfirmedFinalization 2026-08-23-21:40 (FN-9193): merged content is already
proven on the base branch here, so incomplete steps must not block finalization. */
const hardBlocker = getMergeConfirmedFinalizationBlocker({
...task,
steps: task.steps ?? [],
workflowStepResults: task.workflowStepResults,