Files
fusion/packages/engine/src/transient-merge-error-classifier.ts
gsxdsm e75c4dae28 fix(FN-5627): suppress ntfy notifications for transient merge failures the engine auto-recovers
Even with FN-5627's merger TOCTOU fix + transient-failure self-healing
sweep + safety-fallback auto-prerebase landed, the merger can still hit
transient failure classes (lease handoff races, brief same-SHA non-FF
advances) for tasks whose branches are particularly out-of-sync. The
self-healing sweep auto-recovers them within bounded budget \u2014 but each
individual failure cycle was firing a ntfy alarm before the recovery
cleared the failed state, producing user-facing alarm spam for tasks
that were never actually stuck.

Two layers of fix:

1. NotificationService.handleTaskUpdated now classifies task.error via
   the new shared classifyTransientMergeError helper before scheduling
   the deferred failure notification. Transient classes
   (lease-handoff-target-not-queued, spurious-concurrent-advance-same-sha)
   get logged as suppressed and never schedule a ntfy timer.

2. Defense-in-depth: fireDeferredFailureNotification re-classifies the
   error at dispatch time, so a failure scheduled before the suppression
   landed on a newer cycle still suppresses if the error matches a
   transient class.

The classifier itself moved from self-healing.ts to a new logger-free
transient-merge-error-classifier.ts module so consumers in
NotificationService don't pull createLogger through the import chain and
break test mocks of ../logger.js (per project-memory rule about new
modules using createLogger). self-healing.ts re-exports the symbol for
backward compatibility.

Log prefix for the recovery actions also changed from
'[FN-5627] Auto-recovering...' to 'Auto-recovered:' so that
NotificationService.maybeSuppressTransientFailedNotification's existing
/^Auto-recovered:/ log-prefix check cancels any already-scheduled failure
notification when the sweep runs mid-grace-window.

Tests (3 new):
- transient lease-handoff-target-not-queued failure NOT notified
- transient spurious-concurrent-advance-same-sha failure NOT notified
- genuine different-SHAs concurrent-advance still notifies (control)

Engine suite: 6166 tests pass.

Fusion-Task-Id: FN-5627
2026-05-28 15:39:23 -07:00

48 lines
2.5 KiB
TypeScript

/**
* FN-5627: Shared classifier for transient merge failure error messages.
*
* Extracted from `self-healing.ts` to break the import chain that would
* otherwise pull in `createLogger` and break `vi.mock("../logger.js")` setups
* in tests that don't currently mock the full logger surface (notification-
* service.test.ts in particular).
*
* Used by both `SelfHealingManager.recoverTransientMergeFailures` (the
* recovery sweep) and `NotificationService.handleTaskUpdated` (the
* notification-suppression gate). Both consumers must agree on what counts
* as transient so the user doesn't get ntfy alarms for failures that the
* engine will auto-recover within bounded budget.
*
* Recognized classes:
*
* - `lease-handoff-target-not-queued`: the merge queue lease acquisition saw
* the task drop out of the queue between enqueue and handoff. Race with
* 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. 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. 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 safety-fallback auto-prerebase (`merger-auto-prerebase.ts`,
* FN-5627) rebases the task branch onto current main, so the retry
* succeeds.
*/
export function classifyTransientMergeError(error: string | null | undefined): string | null {
if (!error) return null;
if (/lease-handoff-failed[^a-z]+target-not-queued/i.test(error)) {
return "lease-handoff-target-not-queued";
}
const sameSha = error.match(/advanced concurrently \(expected ([0-9a-f]{7,40}),\s+observed ([0-9a-f]{7,40})\)/i);
if (sameSha && sameSha[1].toLowerCase() === sameSha[2].toLowerCase()) {
return "spurious-concurrent-advance-same-sha";
}
return null;
}