fix(FN-5627): default auto-prerebase to fire when branch >=1 commit behind

decideAutoPrerebase() previously defaulted prerebaseDivergenceThreshold
to 0, which meant the threshold path NEVER fired unless the user
explicitly set a positive value. Only hot-file matches could trigger
prerebase.

The result: tasks whose branch was started against an older main tip
(because other tasks landed concurrently) skipped prerebase, built their
squash commit against the stale base, and then failed at git update-ref
because the squash didn't descend from current main. The merger correctly
detected this as non-fast-forward and threw
IntegrationBranchConcurrentAdvanceError, but with both 'expected' and
'observed' SHAs set to current main tip \u2014 because observedCurrentSha was
captured from the pre-update rev-parse, not post-failure. This produced
the misleading 'expected X, observed X' same-SHA error signature that
stranded FN-5632 stuck at mergeRetries=3 after the FN-5627 merger fix
and engine restart.

New default: prerebaseDivergenceThreshold = 1. Any branch behind by at
least 1 commit auto-rebases before squash. Users who want the legacy
never-fire behavior can explicitly set prerebaseDivergenceThreshold = 0.
Threshold comparison also changed from > to >= so an explicit threshold
of N rebases at N+ commits behind instead of N+1+.

The self-healing classifier comment for spurious-concurrent-advance-same-sha
is updated to note the signature can come from either pre-FN-5627
misclassification OR the legitimate post-FN-5627 non-FF path; the
auto-recovery sweep is unchanged because both cases self-heal cleanly
once prerebase fires on the retry.

Tests (3 new):
- Default threshold (undefined) fires at 1 commit behind
- Explicit threshold = 0 stays as opt-out (never fire on commit-count)
- Default threshold doesn't fire when branch is up-to-date

Engine suite: 6160 tests pass.

In-flight: FN-5632 manually SQL-reset to mergeRetries=0 / status=null
once more so the next merger tick (after engine restart picks up this
code) auto-prerebases and lands the work. Future occurrences self-recover.

Fusion-Task-Id: FN-5627
This commit is contained in:
gsxdsm
2026-05-28 14:21:38 -07:00
parent 5768d5ec45
commit 6b27ab5aab
4 changed files with 99 additions and 8 deletions

View File

@@ -49,6 +49,63 @@ describe("decideAutoPrerebase", () => {
expect(decision.fire).toBe(false);
expect(decision.reason).toBe("no-divergence");
});
it("FN-5627: fires on default threshold (1 commit) when prerebaseDivergenceThreshold is undefined", () => {
// Pre-FN-5627 default behavior: undefined threshold meant 'never fire on
// commit-count'. This left tasks that branched off older main tips with
// no recourse — the merger would build the squash against the stale
// base, update-ref would refuse non-FF, and IntegrationBranchConcurrentAdvanceError
// surfaced with a misleading same-SHA pair (the exact signature that
// stranded FN-5632). New default: threshold=1, fire on any divergence.
const settingsNoThreshold = {
prerebaseAutoEnabled: true,
prerebaseHotFiles: ["AGENTS.md"],
// prerebaseDivergenceThreshold intentionally undefined
};
const decision = decideAutoPrerebase({
settings: settingsNoThreshold,
baseCommitSha: "abc",
commitsBehind: 1,
changedFiles: ["x.ts"],
worktrunkEnabled: false,
});
expect(decision.fire).toBe(true);
expect(decision.reason).toBe("divergence-threshold");
expect(decision.commitsBehind).toBe(1);
});
it("FN-5627: respects explicit prerebaseDivergenceThreshold = 0 as opt-out (never fire on commit-count)", () => {
const settingsOptOut = {
prerebaseAutoEnabled: true,
prerebaseHotFiles: ["AGENTS.md"],
prerebaseDivergenceThreshold: 0,
};
const decision = decideAutoPrerebase({
settings: settingsOptOut,
baseCommitSha: "abc",
commitsBehind: 100,
changedFiles: ["x.ts"],
worktrunkEnabled: false,
});
expect(decision.fire).toBe(false);
expect(decision.reason).toBe("no-divergence");
});
it("FN-5627: default threshold doesn't fire when branch is up-to-date", () => {
const settingsNoThreshold = {
prerebaseAutoEnabled: true,
prerebaseHotFiles: ["AGENTS.md"],
};
const decision = decideAutoPrerebase({
settings: settingsNoThreshold,
baseCommitSha: "abc",
commitsBehind: 0,
changedFiles: [],
worktrunkEnabled: false,
});
expect(decision.fire).toBe(false);
expect(decision.reason).toBe("no-divergence");
});
});
describeIfGit("merger-auto-prerebase git integration", () => {

View File

@@ -71,8 +71,16 @@ export function decideAutoPrerebase(input: {
return { fire: true, reason: "hot-file", commitsBehind, hotMatches };
}
const threshold = input.settings.prerebaseDivergenceThreshold ?? 0;
if (threshold > 0 && commitsBehind > threshold) {
// FN-5627 follow-up: default divergence threshold flipped from 0 ("never
// fire on commit-count") to 1 ("fire when branch is behind by at least 1
// commit"). The legacy default left tasks that branched off an older main
// tip with no recourse — the squash would build against the stale base,
// `git update-ref` would correctly refuse the non-fast-forward advance,
// and the merger would surface `IntegrationBranchConcurrentAdvanceError`
// with a misleading same-SHA pair. Users who want the legacy never-fire
// behavior can explicitly set `prerebaseDivergenceThreshold = 0`.
const threshold = input.settings.prerebaseDivergenceThreshold ?? 1;
if (threshold > 0 && commitsBehind >= threshold) {
return { fire: true, reason: "divergence-threshold", commitsBehind, hotMatches: [] };
}

View File

@@ -331,12 +331,20 @@ export const MAX_TRANSIENT_MERGE_RECOVERIES = 2;
* self-healing sweeps that clean stale `mergeQueue` rows (FN-5353/FN-5363).
* - `spurious-concurrent-advance-same-sha`: the merger reported
* `Integration branch X advanced concurrently (expected SHA, observed SHA)`
* with identical SHA on both sides — the integration ref didn't actually
* move. Pre-FN-5627 misclassification in `merger-ref-update-advance.ts`
* routed real ref-update-refusal failures (lock contention, hook rejection)
* through `IntegrationBranchConcurrentAdvanceError`. The current code
* classifies these as `ref-update-refused`, so this class is here to
* recover legacy stuck rows from before the fix landed.
* with identical SHA on both sides. This signature shows up in two cases:
* (1) Pre-FN-5627 misclassification in `merger-ref-update-advance.ts`
* routed real ref-update-refusal failures (lock contention, hook
* rejection) through `IntegrationBranchConcurrentAdvanceError`.
* (2) Post-FN-5627: the merger's `advanceIntegrationBranchRef` correctly
* detects `non-fast-forward-advance` when the freshly built squash
* commit does not descend from the current integration ref (typically
* because the task branch was started against an older main tip and
* auto-prerebase was skipped). The error carries the same SHA in both
* the "expected" and "observed" slots because the pre-advance rev-parse
* captured the ref state and update-ref refused without moving it. On
* the next merge attempt, the lifted auto-prerebase default (threshold
* = 1 commit, FN-5627 follow-up to `merger-auto-prerebase.ts`) rebases
* the task branch onto current main, so the retry succeeds.
*/
export function classifyTransientMergeError(error: string | null | undefined): string | null {
if (!error) return null;