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

@@ -0,0 +1,18 @@
---
"@runfusion/fusion": patch
---
fix(FN-5627): default auto-prerebase to fire when branch is >=1 commit behind integration
`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) would skip prerebase, build their squash commit against the stale base, and then fail at the `git update-ref` step because the squash commit didn't descend from current main. The merger correctly detected this as a non-fast-forward advance and threw `IntegrationBranchConcurrentAdvanceError` — with both "expected" and "observed" SHAs set to the current main tip (because `observedCurrentSha` was captured from the pre-update rev-parse). This produced the misleading "expected X, observed X" same-SHA error signature that stranded FN-5632 stuck at `mergeRetries=3`.
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 reflect that the signature can come from either the pre-FN-5627 misclassification OR the legitimate post-FN-5627 non-fast-forward path; the auto-recovery sweep is unchanged because both cases self-heal cleanly once prerebase fires on the retry.
Tests:
- 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 (commitsBehind=0)

View File

@@ -49,6 +49,63 @@ describe("decideAutoPrerebase", () => {
expect(decision.fire).toBe(false); expect(decision.fire).toBe(false);
expect(decision.reason).toBe("no-divergence"); 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", () => { describeIfGit("merger-auto-prerebase git integration", () => {

View File

@@ -71,8 +71,16 @@ export function decideAutoPrerebase(input: {
return { fire: true, reason: "hot-file", commitsBehind, hotMatches }; return { fire: true, reason: "hot-file", commitsBehind, hotMatches };
} }
const threshold = input.settings.prerebaseDivergenceThreshold ?? 0; // FN-5627 follow-up: default divergence threshold flipped from 0 ("never
if (threshold > 0 && commitsBehind > threshold) { // 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: [] }; 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). * self-healing sweeps that clean stale `mergeQueue` rows (FN-5353/FN-5363).
* - `spurious-concurrent-advance-same-sha`: the merger reported * - `spurious-concurrent-advance-same-sha`: the merger reported
* `Integration branch X advanced concurrently (expected SHA, observed SHA)` * `Integration branch X advanced concurrently (expected SHA, observed SHA)`
* with identical SHA on both sides — the integration ref didn't actually * with identical SHA on both sides. This signature shows up in two cases:
* move. Pre-FN-5627 misclassification in `merger-ref-update-advance.ts` * (1) Pre-FN-5627 misclassification in `merger-ref-update-advance.ts`
* routed real ref-update-refusal failures (lock contention, hook rejection) * routed real ref-update-refusal failures (lock contention, hook
* through `IntegrationBranchConcurrentAdvanceError`. The current code * rejection) through `IntegrationBranchConcurrentAdvanceError`.
* classifies these as `ref-update-refused`, so this class is here to * (2) Post-FN-5627: the merger's `advanceIntegrationBranchRef` correctly
* recover legacy stuck rows from before the fix landed. * 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 { export function classifyTransientMergeError(error: string | null | undefined): string | null {
if (!error) return null; if (!error) return null;