Skip to content

fix(agent): cap recovery chain depth and force-save at the cap#6082

Merged
SivanCola merged 2 commits into
esengine:main-v2from
SivanCola:fix/recovery-chain-depth-cap
Jul 6, 2026
Merged

fix(agent): cap recovery chain depth and force-save at the cap#6082
SivanCola merged 2 commits into
esengine:main-v2from
SivanCola:fix/recovery-chain-depth-cap

Conversation

@SivanCola

Copy link
Copy Markdown
Collaborator

Summary

Defense-in-depth for the #5993 class: a save-conflict loop used to fork recovery branches from recovery branches without bound — the report reached 8 nested levels, each fork polluting the session list. The conflict triggers behind that loop are fixed (1.17.x + #6063), but nothing prevents a future regression from spawning chains again: BranchMeta.Recovered was display-only and SaveRecoveryBranch never looked at the parent's recovery state.

  • Stamp BranchMeta.RecoveryDepth on every recovery fork (parent depth + 1; legacy recovery metas without the field count as depth 1). Always derived from the parent chain, never trusted from the caller's meta, which copies tab/session meta wholesale.
  • SaveRecoveryBranch refuses to fork a parent already at SessionRecoveryMaxDepth (3) with a typed ErrSessionRecoveryDepthExceeded.
  • At the cap, recoverSnapshotConflict force-saves the transcript onto the current branch instead of forking: this runtime is the only writer of the recovery branches it created (recovery paths are digest-derived; identical snapshots dedupe to Existing), so the force write keeps the data, re-anchors the persistence baseline so the next snapshot does not re-conflict, and stops the file proliferation. A warn log (with the conflict's revision ledger) plus a distinct notice make the condition visible instead of silent.

Semantic choice worth reviewing: at the cap the fallback prefers keeping the in-memory transcript (force write over the runtime's own recovery branch) over adopting whatever is on disk — recovery exists to prevent data loss, and the branch has no other legitimate writer.

Stacked on #6080 (fix/snapshot-conflict-trigger-telemetry) — merge that first; this PR's controller change builds on its logAttrs instrumentation and will narrow to a single commit once #6080 lands.

Verification

  • TestSaveRecoveryBranchStampsAndCapsChainDepth: depth 1 stamped on first fork; nested fork increments to 2; legacy Recovered meta treated as depth 1; parent at cap refuses with the typed error and creates no file.
  • TestSnapshotConflictAtRecoveryDepthCapForceSavesCurrentBranch: diverged conflict at a depth-capped path → Snapshot() succeeds, no *-recovery-*.jsonl created, disk holds the local transcript, depth-cap notice emitted, and the follow-up snapshot does not re-conflict.
  • Full suites green: internal/agent, internal/control, internal/cli, internal/serve, internal/acp, desktop (55s). go vet clean.

Cache impact

Cache-impact: none - session persistence/recovery only; no provider request, system prompt, or tool schema changes.
Cache-guard: not applicable (no watchlist files); recovery-path suites above cover the change.
System-prompt-review: N/A

Refs #5993, #6054.

SivanCola added 2 commits July 6, 2026 15:37
Field reports of frequent "session changed on disk" notices (esengine#6069)
are undiagnosable today: the stale-prefix adoption and the
recovery-not-needed adoption emit the same notice text, nothing logs
which trigger fired, and the revision ledger that drove the conflict
decision is discarded.

- Log every recoverSnapshotConflict outcome (stale-prefix adopt,
  recovery-not-needed adopt, failed adoption, recovery fork) with the
  conflict kind, save mode, message counts, and the
  BaseRevision/DiskRevision pair from SessionSnapshotConflictError.
- Differentiate the recovery-not-needed notice text ("local changes
  already covered") so user screenshots identify the trigger.
- The recovery-not-needed path whose disk adoption fails used to drop
  the snapshot silently; it now leaves a warning so "my last turns
  vanished" reports can be tied to it.
A save-conflict loop used to fork recovery branches from recovery
branches without bound — esengine#5993 reached 8 nested levels, each fork
polluting the session list. The conflict triggers behind that loop are
fixed, but nothing prevents a future regression from spawning chains
again: BranchMeta.Recovered was display-only and SaveRecoveryBranch
never looked at the parent's recovery state.

- Stamp BranchMeta.RecoveryDepth on every recovery fork (parent depth
  + 1; legacy recovery metas count as depth 1).
- SaveRecoveryBranch refuses to fork a parent already at
  SessionRecoveryMaxDepth (3) with ErrSessionRecoveryDepthExceeded.
- At the cap the controller force-saves the transcript onto the
  current branch instead: this runtime is the only writer of recovery
  branches it created, so the force write keeps the data, re-anchors
  the persistence baseline (no immediate re-conflict), and stops the
  file proliferation. A warn log plus a distinct notice make the
  condition visible.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Core agent loop (internal/agent, internal/control) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant