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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 09:09:04 -07:00
parent 82b5d78bf8
commit 936dd1ea96
2 changed files with 15 additions and 2 deletions

View File

@@ -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.

View File

@@ -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",
});