fix(FN-5627): close merger TOCTOU + add fast-path reachability gate
The merger persisted `mergeConfirmed: true` + `commitSha` to the task row
as soon as the local squash commit was built, BEFORE running
`git update-ref refs/heads/<integration>` to actually advance the
integration branch. If the ref-advance then failed for any reason (lock
contention, hook rejection, packed-refs race, or a misclassified non-CAS
error via the merger-ref-update-advance.ts string heuristic), the task row
was poisoned: the auto-merge scheduler's mergeConfirmed fast-path would
silently promote the never-landed work to 'done' on the next tick,
including emitting task:merged and closing the linked GitHub tracking
issue. The 'expected SHA == observed SHA' log signature on FN-5625 was a
red herring — the ref-advance had failed for non-race reasons but the
string heuristic in merger-ref-update-advance.ts classified it as
'concurrent-advance', and the downstream IntegrationBranchConcurrentAdvanceError
routed through the unsafe 'merge already confirmed' recovery path.
This silently dropped real work on at least 9 tasks across 2026-05-27/28
(FN-5596, FN-5597, FN-5599, FN-5612, FN-5613, FN-5614, FN-5616, FN-5623,
FN-5625) and likely affected older now-archived tasks for which evidence
has been pruned.
Three-layer fix:
1. merger.ts (~9752): in reuseTaskWorktreeMerge mode, persist
`mergeConfirmed: false` initially. After advanceIntegrationBranchRef
returns advanced=true, do a follow-up updateTask to flip the flag.
Other merge paths (legacy in-place, verified no-op fast-paths,
owned-commit recovery) advance the ref BEFORE the mergeDetails write
and remain unchanged.
2. project-engine.ts (~1378): defense-in-depth reachability gate on the
auto-merge 'merge already confirmed' fast-path. Before moveTask to
'done', verify `git merge-base --is-ancestor <commitSha>
refs/heads/<integration>` succeeds. On failure, clear mergeConfirmed,
set status='failed' with descriptive error, leave task in 'in-review',
and emit `merger:fast-path-blocked-foreign-commit` run-audit event.
Legitimate no-op merges (no commitSha) bypass the gate; ancient tasks
missing mergeTargetBranch also bypass to avoid false-positive parks.
3. merger-ref-update-advance.ts (~189): replace fragile string heuristic
('is at' / 'expected' / 'cannot lock ref' in stderr) with structured
detection. After update-ref fails, re-read the ref: if observed ==
expected, classify as `ref-update-refused` (no race occurred); only
classify as `concurrent-advance` when ref actually moved. Eliminates
the misleading 'expected X observed X' same-SHA pair.
Tests: 3 new regression tests covering all three layers. Full engine
suite: 6150 tests pass.
Fixes:
- FN-5625 (autopilot validator trigger fix lost)
- FN-5623 (`fn goals` CLI lost)
- FN-5616 (source-issue close handlers lost)
- FN-5614 (`fn update` collision retry lost)
- FN-5613 (dashboard reload banner lost)
- FN-5612 (bundled-plugin-install lost)
- FN-5599 (tablet modal width lost)
- FN-5597 (ntfy notifier priority lost)
- FN-5596 (PR tab spacing test lost)
Fusion-Task-Id: FN-5627
This commit is contained in:
17
.changeset/FN-5627-merger-fast-path-toctou-fix.md
Normal file
17
.changeset/FN-5627-merger-fast-path-toctou-fix.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(FN-5627): close TOCTOU window between merger optimistic `mergeConfirmed: true` write and integration ref advance, add reachability gate on auto-merge fast-path
|
||||||
|
|
||||||
|
The merger previously persisted `mergeConfirmed: true` + `commitSha` to the task row as soon as the local squash commit was built, **before** running `git update-ref refs/heads/<integration>` to actually advance the integration branch. If the ref-advance then failed for any reason (lock contention, hook rejection, packed-refs race, or a misclassified non-CAS error via the `merger-ref-update-advance.ts` string heuristic), the task row was poisoned: the auto-merge scheduler's `mergeConfirmed` fast-path would silently promote the never-landed work to `done` on the next tick, including emitting `task:merged` and closing the GitHub tracking issue.
|
||||||
|
|
||||||
|
This affected at least 9 tasks across 2026-05-27/28 (FN-5596, FN-5597, FN-5599, FN-5612, FN-5613, FN-5614, FN-5616, FN-5623, FN-5625) — the merger silently dropped real work and marked the tasks complete.
|
||||||
|
|
||||||
|
The fix has three layers:
|
||||||
|
|
||||||
|
1. **merger.ts** — In `reuseTaskWorktreeMerge` mode, persist `mergeConfirmed: false` initially. Promote to `true` only after `advanceIntegrationBranchRef` returns `advanced: true`. Other merge paths (legacy in-place merge, verified no-op fast-paths, owned-commit recovery) are unchanged because they advance the ref before this point.
|
||||||
|
|
||||||
|
2. **project-engine.ts** — Defense-in-depth reachability gate on the auto-merge "merge already confirmed" fast-path. Before `moveTask(taskId, "done")`, verify `git merge-base --is-ancestor <commitSha> <integrationBranch>` succeeds. On failure, clear `mergeConfirmed`, mark task `status: "failed"`, leave in `in-review`, and emit `merger:fast-path-blocked-foreign-commit` run-audit event. Legitimate no-op merges (no `commitSha`) bypass the gate.
|
||||||
|
|
||||||
|
3. **merger-ref-update-advance.ts** — Replace the fragile string heuristic that classified update-ref failures as `concurrent-advance` (matching `"is at"` / `"expected"` / `"cannot lock ref"` in error text) with structured detection. After update-ref fails, re-read the ref: if observed equals expected, classify as `ref-update-refused` (no actual race occurred). Eliminates the misleading "expected X observed X" same-SHA log signature seen on FN-5625.
|
||||||
@@ -257,6 +257,84 @@ describe("advanceIntegrationBranchRef", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-5627: classifies update-ref refusal as ref-update-refused (NOT concurrent-advance) when ref did not move", async () => {
|
||||||
|
// Repro for the FN-5625/FN-5623 false-positive done class: an update-ref
|
||||||
|
// failure where the ref did NOT actually move (e.g., lock contention,
|
||||||
|
// hook rejection, packed-refs write race) was previously classified as
|
||||||
|
// `concurrent-advance` purely by string-matching the git error message
|
||||||
|
// ("is at" / "expected" / "cannot lock ref"). That heuristic
|
||||||
|
// misreported "expected X, observed X" with the SAME sha for both
|
||||||
|
// values, and the downstream IntegrationBranchConcurrentAdvanceError
|
||||||
|
// routed through the unsafe "merge already confirmed" recovery path.
|
||||||
|
//
|
||||||
|
// The fix: after update-ref fails, re-read the ref. If observed ==
|
||||||
|
// expected, classify as `ref-update-refused`. Only classify as
|
||||||
|
// `concurrent-advance` when the ref actually moved.
|
||||||
|
const dir = setupRepo("main");
|
||||||
|
const events: Array<{ type: string; metadata?: Record<string, unknown> }> = [];
|
||||||
|
try {
|
||||||
|
const expectedCurrentSha = git(dir, "git rev-parse refs/heads/main");
|
||||||
|
git(dir, "git checkout -b feat");
|
||||||
|
writeFileSync(join(dir, "feature.txt"), "feature\n");
|
||||||
|
git(dir, "git add feature.txt");
|
||||||
|
git(dir, "git commit -m feat");
|
||||||
|
const newSha = git(dir, "git rev-parse HEAD");
|
||||||
|
|
||||||
|
// Simulate update-ref refusal that did NOT move the ref. The cleanest
|
||||||
|
// way to force this is to inject a runGit stub that fails the
|
||||||
|
// update-ref call but leaves the ref untouched (re-read returns the
|
||||||
|
// same expected sha). We monkey-patch the internal test hooks.
|
||||||
|
const { __test__ } = await import("../merger-ref-update-advance.js");
|
||||||
|
const originalRunGit = __test__.runGit;
|
||||||
|
let updateRefSeen = false;
|
||||||
|
__test__.runGit = (async (args: string[], cwd: string) => {
|
||||||
|
if (args[0] === "update-ref") {
|
||||||
|
updateRefSeen = true;
|
||||||
|
// Mimic git's CAS-failure-shaped error message but without
|
||||||
|
// actually moving the ref. This matches the FN-5625 log exactly.
|
||||||
|
throw new Error(
|
||||||
|
`fatal: update_ref failed for ref 'refs/heads/main': cannot lock ref 'refs/heads/main': is at ${expectedCurrentSha} but expected ${expectedCurrentSha}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalRunGit(args, cwd);
|
||||||
|
}) as typeof originalRunGit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await advanceIntegrationBranchRef({
|
||||||
|
rootDir: dir,
|
||||||
|
projectRootDir: dir,
|
||||||
|
integrationBranch: "main",
|
||||||
|
newSha,
|
||||||
|
expectedCurrentSha,
|
||||||
|
taskId: "FN-5627",
|
||||||
|
audit: {
|
||||||
|
git: async (event: any) => events.push(event),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateRefSeen).toBe(true);
|
||||||
|
expect(result.advanced).toBe(false);
|
||||||
|
if (result.advanced) throw new Error("expected refusal");
|
||||||
|
// The critical assertion: ref-update-refused, NOT concurrent-advance.
|
||||||
|
expect(result.reason).toBe("ref-update-refused");
|
||||||
|
// Observed should equal expected because the ref did not move.
|
||||||
|
expect(result.observedCurrentSha).toBe(expectedCurrentSha);
|
||||||
|
expect(git(dir, "git rev-parse refs/heads/main")).toBe(expectedCurrentSha);
|
||||||
|
// The emitted audit event should also use the correct classification.
|
||||||
|
expect(String(events[0]?.metadata?.error ?? "")).toContain(
|
||||||
|
"ref-update-refused",
|
||||||
|
);
|
||||||
|
expect(String(events[0]?.metadata?.error ?? "")).not.toContain(
|
||||||
|
"concurrent-advance",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
__test__.runGit = originalRunGit;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
removeTmpDirSync(dir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("throws on missing precondition shas", async () => {
|
it("throws on missing precondition shas", async () => {
|
||||||
const dir = setupRepo("main");
|
const dir = setupRepo("main");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1499,6 +1499,140 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
|||||||
await engine.stop();
|
await engine.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-5627: refuses fast-path and parks task when mergeConfirmed commitSha is not ancestor of integration branch", async () => {
|
||||||
|
// Repro for the FN-5625/FN-5623 false-positive done class: the merger
|
||||||
|
// has a TOCTOU between writing `mergeConfirmed: true` and `git update-ref`
|
||||||
|
// succeeding. When the ref-advance fails after the optimistic write, the
|
||||||
|
// task row is poisoned. Without this gate, the auto-merge fast-path
|
||||||
|
// would silently promote the poisoned row to `done`. With the gate, the
|
||||||
|
// fast-path verifies reachability and refuses, parking the task in
|
||||||
|
// in-review with status=failed for manual review.
|
||||||
|
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||||
|
mockStore.store.getTask.mockResolvedValueOnce({
|
||||||
|
id: "FN-poisoned",
|
||||||
|
column: "in-review",
|
||||||
|
paused: false,
|
||||||
|
mergeRetries: 0,
|
||||||
|
status: null,
|
||||||
|
branch: "fusion/fn-poisoned",
|
||||||
|
baseBranch: "main",
|
||||||
|
mergeDetails: {
|
||||||
|
mergeConfirmed: true,
|
||||||
|
commitSha: "abc123abc123abc123abc123abc123abc1234567",
|
||||||
|
mergeTargetBranch: "main",
|
||||||
|
mergedAt: "2026-05-28T19:34:17.022Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mocks.currentStore = mockStore.store;
|
||||||
|
|
||||||
|
// Simulate the gate: `git cat-file -e` succeeds (commit exists locally
|
||||||
|
// on the orphan task branch), but `git merge-base --is-ancestor` fails
|
||||||
|
// with exit code 1 (commit is NOT reachable from main).
|
||||||
|
mocks.execFile.mockImplementation((
|
||||||
|
_file: string,
|
||||||
|
args: string[],
|
||||||
|
_options: unknown,
|
||||||
|
callback?: (error: (Error & { code?: number }) | null, result: { stdout: string; stderr: string }) => void,
|
||||||
|
) => {
|
||||||
|
const cb = (typeof _options === "function" ? _options : callback) as (
|
||||||
|
error: (Error & { code?: number }) | null,
|
||||||
|
result: { stdout: string; stderr: string },
|
||||||
|
) => void;
|
||||||
|
if (args[0] === "cat-file") {
|
||||||
|
cb(null, { stdout: "", stderr: "" });
|
||||||
|
return {} as never;
|
||||||
|
}
|
||||||
|
if (args[0] === "merge-base" && args[1] === "--is-ancestor") {
|
||||||
|
const err = new Error("Command failed: git merge-base --is-ancestor") as Error & { code?: number };
|
||||||
|
err.code = 1;
|
||||||
|
cb(err, { stdout: "", stderr: "" });
|
||||||
|
return {} as never;
|
||||||
|
}
|
||||||
|
// Default success for any other git call.
|
||||||
|
cb(null, { stdout: "/usr/bin/mock\n", stderr: "" });
|
||||||
|
return {} as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
const engine = createEngine();
|
||||||
|
await engine.start();
|
||||||
|
engine.enqueueMerge("FN-poisoned");
|
||||||
|
|
||||||
|
// Gate refuses the fast-path: the task row is updated with status=failed,
|
||||||
|
// mergeConfirmed is cleared, and the task is parked in in-review.
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const calls = (mockStore.store.updateTask as ReturnType<typeof vi.fn>).mock.calls as unknown as Array<[string, Record<string, unknown>]>;
|
||||||
|
const refusalCall = calls.find((call) =>
|
||||||
|
call[0] === "FN-poisoned"
|
||||||
|
&& call[1]?.status === "failed"
|
||||||
|
&& typeof call[1]?.error === "string"
|
||||||
|
&& /not reachable/.test(call[1].error as string),
|
||||||
|
);
|
||||||
|
expect(refusalCall).toBeDefined();
|
||||||
|
const updates = refusalCall![1] as { mergeDetails?: { mergeConfirmed?: boolean } };
|
||||||
|
expect(updates.mergeDetails?.mergeConfirmed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// moveTask("done") was NOT called.
|
||||||
|
expect(mockStore.store.moveTask).not.toHaveBeenCalledWith("FN-poisoned", "done");
|
||||||
|
// task:merged was NOT emitted for the poisoned task.
|
||||||
|
const emitCalls = (mockStore.store.emit as ReturnType<typeof vi.fn>).mock.calls as unknown as Array<[string, { task?: { id: string } }]>;
|
||||||
|
const mergedCalls = emitCalls.filter((call) => call[0] === "task:merged");
|
||||||
|
const poisonedEmit = mergedCalls.find((call) => call[1]?.task?.id === "FN-poisoned");
|
||||||
|
expect(poisonedEmit).toBeUndefined();
|
||||||
|
|
||||||
|
await engine.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-5627: fast-path still works when mergeConfirmed has no commitSha (verified-no-op path)", async () => {
|
||||||
|
// Legitimate no-op merges have mergeConfirmed=true with no commitSha
|
||||||
|
// (verified-short-circuit / proven-no-op / already-on-main paths). The
|
||||||
|
// reachability gate must not break those.
|
||||||
|
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||||
|
mockStore.store.getTask.mockResolvedValueOnce({
|
||||||
|
id: "FN-noop",
|
||||||
|
column: "in-review",
|
||||||
|
paused: false,
|
||||||
|
mergeRetries: 0,
|
||||||
|
status: null,
|
||||||
|
branch: "fusion/fn-noop",
|
||||||
|
mergeDetails: {
|
||||||
|
mergeConfirmed: true,
|
||||||
|
noOpMerge: true,
|
||||||
|
mergedAt: "2026-05-28T19:34:17.022Z",
|
||||||
|
mergeTargetBranch: "main",
|
||||||
|
// No commitSha — verified no-op.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockStore.store.moveTask.mockResolvedValueOnce({
|
||||||
|
id: "FN-noop",
|
||||||
|
column: "done",
|
||||||
|
branch: "fusion/fn-noop",
|
||||||
|
mergeDetails: {
|
||||||
|
mergeConfirmed: true,
|
||||||
|
noOpMerge: true,
|
||||||
|
mergedAt: "2026-05-28T19:34:17.022Z",
|
||||||
|
mergeTargetBranch: "main",
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
mocks.currentStore = mockStore.store;
|
||||||
|
|
||||||
|
const engine = createEngine();
|
||||||
|
await engine.start();
|
||||||
|
engine.enqueueMerge("FN-noop");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockStore.store.emit).toHaveBeenCalledWith(
|
||||||
|
"task:merged",
|
||||||
|
expect.objectContaining({
|
||||||
|
merged: true,
|
||||||
|
task: expect.objectContaining({ id: "FN-noop", column: "done" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits task:merged when PR merge strategy returns merged", async () => {
|
it("emits task:merged when PR merge strategy returns merged", async () => {
|
||||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||||
mockStore.store.getTask
|
mockStore.store.getTask
|
||||||
|
|||||||
@@ -190,12 +190,36 @@ export async function advanceIntegrationBranchRef(args: {
|
|||||||
return { advanced: true, previousSha: expectedCurrentSha, newSha };
|
return { advanced: true, previousSha: expectedCurrentSha, newSha };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const diagnostic = error instanceof Error ? error.message : String(error);
|
const diagnostic = error instanceof Error ? error.message : String(error);
|
||||||
const lower = diagnostic.toLowerCase();
|
// FN-5627: Replace the fragile string heuristic ("is at" / "expected" /
|
||||||
const isConcurrent = lower.includes("cannot lock ref") || lower.includes("is at") || lower.includes("expected");
|
// "cannot lock ref") with structured detection. After an update-ref
|
||||||
const reason = isConcurrent ? "concurrent-advance" : "ref-update-refused";
|
// failure, re-read the ref:
|
||||||
|
// * If the observed value moved away from expected → genuine CAS race
|
||||||
|
// (concurrent-advance).
|
||||||
|
// * If the observed value still equals expected → the ref did NOT
|
||||||
|
// advance and there was no race; update-ref was refused by lock
|
||||||
|
// contention, a pre/post-ref hook, packed-refs write contention, or
|
||||||
|
// a permissions/quota error. Classify as `ref-update-refused` so the
|
||||||
|
// downstream IntegrationBranchConcurrentAdvanceError does NOT fire
|
||||||
|
// with the misleading "expected X observed X" pair that previously
|
||||||
|
// routed legitimate ref-update failures into the unsafe "merge
|
||||||
|
// already confirmed" recovery path (FN-5625 / FN-5623 et al.).
|
||||||
|
let postFailureCurrentSha: string | undefined;
|
||||||
|
try {
|
||||||
|
const { stdout: postStdout } = await testHooks.runGit(
|
||||||
|
["rev-parse", "--verify", ref],
|
||||||
|
rootDir,
|
||||||
|
);
|
||||||
|
postFailureCurrentSha = postStdout.trim() || undefined;
|
||||||
|
} catch {
|
||||||
|
// Couldn't re-read; preserve original observed value for diagnostics
|
||||||
|
postFailureCurrentSha = observedCurrentSha;
|
||||||
|
}
|
||||||
|
const effectiveObserved = postFailureCurrentSha || observedCurrentSha;
|
||||||
|
const refMoved = !!effectiveObserved && effectiveObserved !== expectedCurrentSha;
|
||||||
|
const reason = refMoved ? "concurrent-advance" : "ref-update-refused";
|
||||||
await emitRefAdvance({
|
await emitRefAdvance({
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
fromSha: observedCurrentSha || expectedCurrentSha,
|
fromSha: effectiveObserved || expectedCurrentSha,
|
||||||
toSha: newSha,
|
toSha: newSha,
|
||||||
error: `${reason}: ${diagnostic}`,
|
error: `${reason}: ${diagnostic}`,
|
||||||
});
|
});
|
||||||
@@ -203,7 +227,7 @@ export async function advanceIntegrationBranchRef(args: {
|
|||||||
advanced: false,
|
advanced: false,
|
||||||
reason,
|
reason,
|
||||||
diagnostic,
|
diagnostic,
|
||||||
observedCurrentSha,
|
observedCurrentSha: effectiveObserved,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9748,6 +9748,19 @@ export async function aiMergeTask(
|
|||||||
const recordedInsertions = mergeWasEmpty ? 0 : insertions;
|
const recordedInsertions = mergeWasEmpty ? 0 : insertions;
|
||||||
const recordedDeletions = mergeWasEmpty ? 0 : deletions;
|
const recordedDeletions = mergeWasEmpty ? 0 : deletions;
|
||||||
|
|
||||||
|
// FN-5627: TOCTOU fix. In reuseTaskWorktreeMerge mode, the integration
|
||||||
|
// branch ref is advanced via `git update-ref` LATER in this function
|
||||||
|
// (see "5c. Advance integration branch ref after squash" below). If we
|
||||||
|
// persist `mergeConfirmed: true` here and the ref-advance then fails for
|
||||||
|
// any reason (lock contention, hook rejection, misclassified non-CAS
|
||||||
|
// errors via merger-ref-update-advance.ts), the task row becomes
|
||||||
|
// poisoned: the project-engine fast-path would silently promote
|
||||||
|
// in-review → done on the next tick, classifying never-landed work as
|
||||||
|
// complete. Defer `mergeConfirmed` to the post-ref-advance promotion
|
||||||
|
// block below for the reuse path. Other paths (legacy in-place merge,
|
||||||
|
// verified no-op fast-paths, owned-commit recovery) advance the ref
|
||||||
|
// BEFORE this point and can safely set the flag here.
|
||||||
|
const mergeConfirmedAtThisPoint = !reuseTaskWorktreeMerge;
|
||||||
const mergeDetails: MergeDetails = {
|
const mergeDetails: MergeDetails = {
|
||||||
commitSha: recordedSha,
|
commitSha: recordedSha,
|
||||||
rebaseBaseSha: !mergeWasEmpty && rebaseMergeBaseSha ? rebaseMergeBaseSha : undefined,
|
rebaseBaseSha: !mergeWasEmpty && rebaseMergeBaseSha ? rebaseMergeBaseSha : undefined,
|
||||||
@@ -9760,7 +9773,7 @@ export async function aiMergeTask(
|
|||||||
landedFilesCaptureFallback,
|
landedFilesCaptureFallback,
|
||||||
mergeCommitMessage: aiMergeSummary || commitLog,
|
mergeCommitMessage: aiMergeSummary || commitLog,
|
||||||
mergedAt: new Date().toISOString(),
|
mergedAt: new Date().toISOString(),
|
||||||
mergeConfirmed: true,
|
mergeConfirmed: mergeConfirmedAtThisPoint,
|
||||||
mergeTargetBranch: mergeTarget.branch,
|
mergeTargetBranch: mergeTarget.branch,
|
||||||
mergeTargetSource: mergeTarget.source,
|
mergeTargetSource: mergeTarget.source,
|
||||||
resolutionStrategy: result.resolutionStrategy,
|
resolutionStrategy: result.resolutionStrategy,
|
||||||
@@ -9874,6 +9887,29 @@ export async function aiMergeTask(
|
|||||||
`${taskId}: ${integrationBranch} advanced to ${worktreeHeadSha.slice(0, 8)} via update-ref; your checked-out worktree at ${projectRootDir} is now behind`,
|
`${taskId}: ${integrationBranch} advanced to ${worktreeHeadSha.slice(0, 8)} via update-ref; your checked-out worktree at ${projectRootDir} is now behind`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// FN-5627: Promote `mergeConfirmed` to true ONLY after the ref-advance
|
||||||
|
// succeeds. This closes the TOCTOU window where the optimistic write
|
||||||
|
// above poisoned the task row when the ref-advance subsequently failed.
|
||||||
|
try {
|
||||||
|
const currentTask = await store.getTask(taskId).catch(() => null);
|
||||||
|
const currentMergeDetails = currentTask?.mergeDetails;
|
||||||
|
if (currentMergeDetails && !currentMergeDetails.mergeConfirmed) {
|
||||||
|
await store.updateTask(taskId, {
|
||||||
|
mergeDetails: { ...currentMergeDetails, mergeConfirmed: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (promoteErr: unknown) {
|
||||||
|
// Non-fatal: log + continue. The ref already advanced; the worst
|
||||||
|
// case is the next merge tick re-attempts and the work is now
|
||||||
|
// genuinely landed so the reachability gate in project-engine
|
||||||
|
// will succeed.
|
||||||
|
mergerLog.warn(
|
||||||
|
`${taskId}: failed to promote mergeConfirmed post-ref-advance: ${
|
||||||
|
promoteErr instanceof Error ? promoteErr.message : String(promoteErr)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-sync other worktrees still on the integration branch so their
|
// Auto-sync other worktrees still on the integration branch so their
|
||||||
// index + working tree catch up to the new tip. When `off`, the legacy
|
// index + working tree catch up to the new tip. When `off`, the legacy
|
||||||
// surprise behavior is preserved and the user pulls manually via the
|
// surprise behavior is preserved and the user pulls manually via the
|
||||||
|
|||||||
@@ -96,6 +96,69 @@ function isInvalidDoneTransitionError(error: unknown): boolean {
|
|||||||
return message.includes("Invalid transition:") && message.includes("→ 'done'");
|
return message.includes("Invalid transition:") && message.includes("→ 'done'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FN-5627: Defense-in-depth gate for the auto-merge "merge already confirmed"
|
||||||
|
* fast-path. Verifies the task's recorded `mergeDetails.commitSha` is actually
|
||||||
|
* reachable from the integration branch tip before promoting in-review → done.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - { reachable: true } when commitSha is an ancestor of integrationBranch.
|
||||||
|
* - { reachable: false, reason } when it is NOT reachable (the merger poisoned
|
||||||
|
* the row with mergeConfirmed=true before ref-advance succeeded, OR a self-
|
||||||
|
* healing path set the flag prematurely). Caller must refuse the fast-path.
|
||||||
|
* - { reachable: true, skipped: "no-commit-sha" } when commitSha is unset —
|
||||||
|
* legacy/no-op finalize paths and verified-no-op merges legitimately have
|
||||||
|
* no commitSha; the fast-path must remain functional for those.
|
||||||
|
*/
|
||||||
|
async function verifyMergeConfirmedReachability(args: {
|
||||||
|
commitSha: string | undefined;
|
||||||
|
integrationBranch: string | undefined;
|
||||||
|
cwd: string;
|
||||||
|
}): Promise<
|
||||||
|
| { reachable: true; skipped?: "no-commit-sha" | "no-integration-branch" }
|
||||||
|
| { reachable: false; reason: "not-ancestor" | "commit-missing" | "git-error"; diagnostic: string }
|
||||||
|
> {
|
||||||
|
const { commitSha, integrationBranch, cwd } = args;
|
||||||
|
// No commit sha = legitimate no-op/verified-short-circuit/early-recovery case.
|
||||||
|
if (!commitSha || !commitSha.trim()) {
|
||||||
|
return { reachable: true, skipped: "no-commit-sha" };
|
||||||
|
}
|
||||||
|
// No integration branch resolvable = degrade safely (caller continues fast-path);
|
||||||
|
// this keeps the gate from breaking ancient tasks missing mergeTargetBranch.
|
||||||
|
if (!integrationBranch || !integrationBranch.trim()) {
|
||||||
|
return { reachable: true, skipped: "no-integration-branch" };
|
||||||
|
}
|
||||||
|
// Verify the commit exists locally before testing ancestry — git
|
||||||
|
// merge-base --is-ancestor returns exit 128 for missing commits, which we
|
||||||
|
// want to surface as "commit-missing" rather than "not-ancestor".
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["cat-file", "-e", `${commitSha}^{commit}`], {
|
||||||
|
cwd,
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const diagnostic = error instanceof Error ? error.message : String(error);
|
||||||
|
return { reachable: false, reason: "commit-missing", diagnostic };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
"git",
|
||||||
|
["merge-base", "--is-ancestor", commitSha, `refs/heads/${integrationBranch}`],
|
||||||
|
{ cwd, timeout: 10_000 },
|
||||||
|
);
|
||||||
|
return { reachable: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Exit code 1 = not an ancestor. Other non-zero = git error.
|
||||||
|
const err = error as { code?: number; message?: string };
|
||||||
|
const code = typeof err.code === "number" ? err.code : undefined;
|
||||||
|
const diagnostic = err.message ?? String(error);
|
||||||
|
if (code === 1) {
|
||||||
|
return { reachable: false, reason: "not-ancestor", diagnostic };
|
||||||
|
}
|
||||||
|
return { reachable: false, reason: "git-error", diagnostic };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildVerificationFailureSignature(error: VerificationError): string {
|
function buildVerificationFailureSignature(error: VerificationError): string {
|
||||||
const commandResult = error.verificationResult.testResult ?? error.verificationResult.buildResult;
|
const commandResult = error.verificationResult.testResult ?? error.verificationResult.buildResult;
|
||||||
const lane = commandResult?.command?.trim()
|
const lane = commandResult?.command?.trim()
|
||||||
@@ -1377,6 +1440,72 @@ export class ProjectEngine {
|
|||||||
// in-review by auto-recovery after a successful merge) — just
|
// in-review by auto-recovery after a successful merge) — just
|
||||||
// complete the task without re-running the merge process.
|
// complete the task without re-running the merge process.
|
||||||
if (task.mergeDetails?.mergeConfirmed) {
|
if (task.mergeDetails?.mergeConfirmed) {
|
||||||
|
// FN-5627: Reachability defense-in-depth. The merger has a TOCTOU
|
||||||
|
// window where `mergeConfirmed: true` can be persisted to the task
|
||||||
|
// row before `git update-ref refs/heads/<integration>` actually
|
||||||
|
// advances the integration branch. If ref-advance then fails for any
|
||||||
|
// reason (lock contention, hook rejection, misclassified errors via
|
||||||
|
// merger-ref-update-advance.ts string heuristic), the task row is
|
||||||
|
// poisoned. Without this gate, the next auto-merge tick would
|
||||||
|
// silently promote the poisoned row to `done` — exactly the
|
||||||
|
// false-positive completion class that lost FN-5612/5613/5614/5616/
|
||||||
|
// 5623/5625 work on 2026-05-27/28.
|
||||||
|
const integrationBranchForGate =
|
||||||
|
task.mergeDetails.mergeTargetBranch || task.baseBranch || "main";
|
||||||
|
const reachability = await verifyMergeConfirmedReachability({
|
||||||
|
commitSha: task.mergeDetails.commitSha,
|
||||||
|
integrationBranch: integrationBranchForGate,
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
if (!reachability.reachable) {
|
||||||
|
const sha = task.mergeDetails.commitSha || "";
|
||||||
|
const shortSha = sha ? sha.slice(0, 8) : "<no-sha>";
|
||||||
|
const errorMsg =
|
||||||
|
`Merge confirmed flag set but commit ${shortSha} is not reachable from ` +
|
||||||
|
`${integrationBranchForGate} (${reachability.reason}). ` +
|
||||||
|
`Task parked in in-review pending manual review.`;
|
||||||
|
runtimeLog.warn(
|
||||||
|
`Auto-merge: ${taskId} fast-path REFUSED — ${reachability.reason}: ${reachability.diagnostic}`,
|
||||||
|
);
|
||||||
|
await store.logEntry(
|
||||||
|
taskId,
|
||||||
|
`[FN-5627] Auto-merge fast-path refused — ${errorMsg}`,
|
||||||
|
);
|
||||||
|
await store.updateTask(taskId, {
|
||||||
|
mergeDetails: {
|
||||||
|
...task.mergeDetails,
|
||||||
|
mergeConfirmed: false,
|
||||||
|
},
|
||||||
|
status: "failed",
|
||||||
|
error: errorMsg,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const auditor = createRunAuditor(store, {
|
||||||
|
runId: generateSyntheticRunId("merger-fast-path-refused", taskId),
|
||||||
|
agentId: "merger",
|
||||||
|
taskId,
|
||||||
|
phase: "auto-merge-fast-path-gate",
|
||||||
|
});
|
||||||
|
await auditor.database({
|
||||||
|
type: "merger:fast-path-blocked-foreign-commit",
|
||||||
|
target: taskId,
|
||||||
|
metadata: {
|
||||||
|
taskId,
|
||||||
|
commitSha: sha,
|
||||||
|
integrationBranch: integrationBranchForGate,
|
||||||
|
reason: reachability.reason,
|
||||||
|
diagnostic: reachability.diagnostic,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
runtimeLog.warn(
|
||||||
|
`Auto-merge: ${taskId} fast-path audit emit failed: ${
|
||||||
|
auditErr instanceof Error ? auditErr.message : String(auditErr)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const blockerReason = getTaskHardMergeBlocker(task as Task);
|
const blockerReason = getTaskHardMergeBlocker(task as Task);
|
||||||
if (blockerReason) {
|
if (blockerReason) {
|
||||||
await store.updateTask(taskId, {
|
await store.updateTask(taskId, {
|
||||||
|
|||||||
@@ -544,7 +544,16 @@ export type DatabaseMutationType =
|
|||||||
*/
|
*/
|
||||||
| "worktree:pool-double-lease-detected"
|
| "worktree:pool-double-lease-detected"
|
||||||
| "room:ambiguity:branch"
|
| "room:ambiguity:branch"
|
||||||
| "room:coordination:branch";
|
| "room:coordination:branch"
|
||||||
|
/**
|
||||||
|
* FN-5627: Auto-merge fast-path refused to promote in-review → done because
|
||||||
|
* `task.mergeDetails.commitSha` is not reachable from the integration branch
|
||||||
|
* tip, indicating the merger persisted `mergeConfirmed: true` before the
|
||||||
|
* ref-advance actually landed (TOCTOU window in merger.ts ~9762 vs ~9845).
|
||||||
|
* Task is parked in in-review with `status: "failed"` for manual review.
|
||||||
|
* Metadata: { taskId, commitSha, integrationBranch, reason, diagnostic }
|
||||||
|
*/
|
||||||
|
| "merger:fast-path-blocked-foreign-commit";
|
||||||
|
|
||||||
// ── Filesystem mutation types ─────────────────────────────────────────────────
|
// ── Filesystem mutation types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user