From 936dd1ea96b314ac467a585f75c83d645e8eb90d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 10 Aug 2026 09:09:04 -0700 Subject: [PATCH] FN-8924: retain safe age gate for merge recovery Document why interrupted merge recovery retains its age-based admission gate. - Record why merger logs cannot establish task ownership or orphan status. - Pin non-owner recovery behavior when a merging task was recently updated. Files changed: docs/self-healing-backward-move-audit.md | 8 +++++++- packages/engine/src/__tests__/self-healing.test.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8924 Fusion-Task-Lineage: 19e7f803-21fe-43fb-8ca6-eecf8ff61df4 Co-authored-by: Fusion (runfusion.ai) --- docs/self-healing-backward-move-audit.md | 8 +++++++- packages/engine/src/__tests__/self-healing.test.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/self-healing-backward-move-audit.md b/docs/self-healing-backward-move-audit.md index 3a94d9a829..fa837751c9 100644 --- a/docs/self-healing-backward-move-audit.md +++ b/docs/self-healing-backward-move-audit.md @@ -37,7 +37,7 @@ Stages that cannot satisfy all three must either (a) tighten predicate to requir | surfaceStalePausedReviews | 4478 | stale paused in-review | `settings.stalePausedReviewThresholdMs` | age signal | log only | ANNOTATION-ONLY | keep | n/a | n/a | | surfaceStalePausedTodos | 4529 | stale paused todo | `settings.stalePausedTodoThresholdMs` | age signal | log only | ANNOTATION-ONLY | keep | n/a | n/a | | recoverGhostReviewTasks | 4583 | idle in-review ghost | `settings.taskStuckTimeoutMs` | idle + status filters | move to todo preserveProgress | BACKWARD | tighten | triple proof + existing ghost predicates | gate move; emit `task:ghost-review-no-action` | -| recoverInterruptedMergingTasks | 4650 | stale transient merging status | `settings.taskStuckTimeoutMs` | stale status + landed-commit detection | done finalize or status clear/requeue | FORWARD/INTERNAL-RETRY | keep | n/a | n/a | +| recoverInterruptedMergingTasks | 4650 | stale transient merging status | `updatedAt` age ≥ `settings.taskStuckTimeoutMs` | stale status + landed-commit detection | done finalize or status clear/requeue | FORWARD/INTERNAL-RETRY | keep | FN-8924 retained the age gate: merger-agent logs are not an orphan-proof task-scoped clock | n/a | | recoverDoneTaskMergeMetadata | 4777 | done merge metadata drift | n/a | landed evidence + metadata gap | metadata update | RECONCILE-ONLY | keep | n/a | n/a | | recoverMergedReviewTasks | 4937 | in-review already merged | landed commit proof | landed proof | move to done | FORWARD | keep | n/a | n/a | | recoverStuckMergeDeadlocks (landed) | 5036 | retry-exhausted merge failed but landed | cooldown + retries | landed proof | move to done + unblock deps | FORWARD | keep | n/a | n/a | @@ -62,6 +62,12 @@ Stages that cannot satisfy all three must either (a) tighten predicate to requir | recoverOrphanedPlanningTasks | 6940 | planning-status tasks drifted | `APPROVED_TRIAGE_RECOVERY_GRACE_MS` | planning drift + inactive | clear planning status | RECONCILE-ONLY | keep | n/a | n/a | ## Per-stage rationale +### FN-8924 — retain age-based interrupted-merge recovery +- **Decision: REJECT.** `recoverInterruptedMergingTasks()` retains `Date.now() - task.updatedAt >= settings.taskStuckTimeoutMs` for its non-owner grace gate. +- **Concrete counter-evidence:** the required replacement clock, `getLastMergerAgentActivityMs(taskId)`, reads entries written by the merger itself. The merge body's `log()` helper in `packages/engine/src/merge/merger-ai.ts` appends a task `logEntry` and a `"merger"` agent log together (lines 1317–1321; duplicated by the workspace helper at 1869–1873). These diagnostic writes are intentionally best-effort/unfenced, so an abandoned or superseded merge body can refresh both `updatedAt` and the proposed attributed clock. Merger-recency is therefore not an orphan-proof, task-scoped clock and cannot satisfy FN-8924's adoption bias. +- The process-global active-merge-start clock is not an alternative: `measureActiveMergeSilenceMs()` falls back to `getActiveMergeStartedAtMs()` only for the owner path, where `isActiveMergeWedged()` first proves task identity. It has no task parameter and can belong to another merge. No durable cross-process merge-ownership claim or lease exists; the remaining pending/task-active/session/lock signals are in-process only, leaving the same cross-process blind spot that the retained age gate already has. +- Consequently, the owner arm and all mutation-time recovery behavior remain unchanged. The proposed two-phase re-proof and routing-input divergence design is not adopted without a sound admission clock; changing sibling stale-status, deadlock, or dashboard Retry definitions would create unsupported divergent semantics. The regression test pins this contract: a fresh-`updatedAt`, unowned merge-active task is not recovered even if an older merger log would satisfy the rejected quiet-window proposal. + ### recoverStaleIncompleteReviewTasks - Current evidence is age + incomplete steps only; this can race live executor/session churn. - Tighten chosen: preserve recovery semantics but require dead-session + unusable-worktree + stale activity proof. diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 1e709b80ec..e699b5ebe1 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -4946,7 +4946,14 @@ describe("SelfHealingManager", () => { managerWithRecovery.stop(); }); - it("does not recover fresh merging tasks before the stuck timeout", async () => { + /* + FNXC:MergeReliability 2026-08-10-15:52: + FN-8924 rejects replacing this age gate with merger-log recency: abandoned merge bodies can + write the same `agent: "merger"` records, so that clock is not independently attributable. + Keep this pin until a task-scoped ownership/activity generation exists that an orphan cannot + refresh; a fresh `updatedAt` must therefore continue to prevent non-owner recovery. + */ + it("retains the age gate when a non-owner merging task was recently updated", async () => { const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", });