Skip to content

feat(session): retry safe recovery up to 3 times with exponential backoff - #1008

Merged
Astro-Han merged 7 commits into
devfrom
feat/safe-recovery-retry-budget
May 31, 2026
Merged

feat(session): retry safe recovery up to 3 times with exponential backoff#1008
Astro-Han merged 7 commits into
devfrom
feat/safe-recovery-retry-budget

Conversation

@Astro-Han

@Astro-Han Astro-Han commented May 31, 2026

Copy link
Copy Markdown
Owner

Problem

Safe recovery handles a clean stream disconnect that happens before any
visible output or tool side effect — the case where it is safe to silently
replay the assistant turn. Previously it retried only once with a fixed
1s delay; if that single replay also failed, the session errored out.
On a flaky connection one extra immediate retry is often not enough.

Closes #1006.

Change

  • Raise SAFE_RECOVERY_MAX_ATTEMPTS from 1 to 3.
  • Reuse the existing API-path delay() backoff (2s -> 4s -> 8s, capped at
    30s) instead of the fixed 1s wait. No jitter (deliberate — matches the
    existing API-path schedule).
  • recoveryFor() now reports auto_retry: { max_attempts: 3, backoff_ms: 2000 }.

The budget gate was already generic (safeRecoveryAttempt < maxAttempts), so
no gate logic changed. Each replay still emits a type: "retry" status with
attempt 1/2/3 and the scheduled next timestamp, so the frontend keeps
showing its "Recovering…" state.

Out of scope

  • The API-error retry path (policy(), 10 attempts) is unchanged.
  • The run-observability recorder's diagnostic attempt counter still stops at
    the first retry. This only affects developer-facing diagnostics logging,
    never the user or the final success/failed outcome — left as-is by design.

Verification

  • bun test test/session/retry.test.ts — 34 pass
  • bun test test/session/processor-effect.test.ts — 33 pass
  • bun test test/session/run-incident-safety-gate.test.ts test/session/run-observability.test.ts — 106 pass
  • bun run typecheck — clean

New/updated coverage asserts the real backoff is 2s/4s/8s across the budget
then terminates, the safety gate allows 3 replays and blocks the 4th, and the
processor fixtures model 4 total stream calls (initial + 3 replays).

Note: reusing the real backoff makes the processor-effect suite wait the
actual 2+4+8s per exhausted-budget case, so its wall-clock time grows
(~77s -> ~118s locally). The new retry.test.ts backoff test gets an explicit
20s timeout so it stays green under Bun's 5s default.

Summary by CodeRabbit

  • Improvements
    • Enhanced retry recovery mechanism with exponential backoff timing and increased recovery attempts (up to 3 instead of 1).
    • Improved handling of process shutdown scenarios to prevent unnecessary retries during lifecycle closure.
    • Added support for custom recovery delay strategies.

…koff

Safe recovery handles a clean stream disconnect before any visible output
or tool side effect, where it is safe to silently replay the assistant
turn. Previously it retried only once with a fixed 1s delay and then
errored the session out.

Raise SAFE_RECOVERY_MAX_ATTEMPTS from 1 to 3 and reuse the existing
API-path delay() backoff (2s -> 4s -> 8s, capped at 30s) instead of the
fixed 1s wait. The budget gate is already generic (safeRecoveryAttempt <
maxAttempts); recoveryFor now reports max_attempts: 3, backoff_ms: 2000.

The API-error retry path (policy(), 10 attempts) is unchanged.

Closes #1006
@Astro-Han Astro-Han added the enhancement New feature or request label May 31, 2026
@github-actions github-actions Bot added the harness Model harness, prompts, tool descriptions, and session mechanics label May 31, 2026
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Astro-Han, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 45 minutes and 4 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fa7c65cd-6742-46be-bdca-99d3c8d1e892

📥 Commits

Reviewing files that changed from the base of the PR and between 817b347 and ebd917a.

📒 Files selected for processing (1)
  • packages/opencode/src/session/processor.ts
📝 Walkthrough

Walkthrough

This PR raises safe-recovery max attempts to 3, replaces the fixed replay delay with a shared exponential backoff (threadable via a safeRecoveryDelay hook), adds lifecycle "closing begins" watchers to interrupt backoff races, switches run-incident recommendation to "auto_retry", updates recorder guards, and updates tests accordingly.

Changes

Safe-recovery retry backoff upgrade

Layer / File(s) Summary
Lifecycle close start signaling
packages/opencode/src/session/lifecycle-provenance.ts
Adds closingStartWaiters, isLifecycleClosing, whenLifecycleCloseBegins, closingStartWaiterCount, and notifies waiters when closing begins (used to interrupt backoff races).
Safe-recovery policy and attempt budget
packages/opencode/src/session/retry.ts
SAFE_RECOVERY_MAX_ATTEMPTS increased to 3. safeRecoveryPolicy computes backoff from delay(meta.attempt) (or opts.delay) and uses the computed wait for persisted next and returned step duration (removes fixed replay delay).
Processor: safe-recovery wiring and backoff interruption
packages/opencode/src/session/processor.ts
Input gains safeRecoveryDelay?: (attempt)=>number; retryStillAllowed distinguishes lifecycle-close action vs pending close; passes delay into safeRecoveryPolicy; replaces boolean scheduling with a race between safeRecoveryStep and lifecycle-close watcher to handle exhaustion and interruptions.
Run-incident recommendation and types
packages/opencode/src/session/run-incident/*
Replace auto_retry_once with auto_retry across policy/presentation/safety-gate; introduce shared SAFE_RECOVERY_AUTO_RETRY using SAFE_RECOVERY_MAX_ATTEMPTS and RETRY_INITIAL_DELAY; widen auto_retry.max_attempts type from 1 to number.
Run-observability recorder guard
packages/opencode/src/session/run-observability/recorder.ts
Avoid updating recoveryDecision when next.recovery_mode is auto_replay_blocked.
Tests and expectations
packages/opencode/test/session/*
Add/expose FAST_SAFE_RECOVERY_DELAY in tests, validate SessionRetry.delay exponential progression, run safeRecoveryPolicy across full budget in tests, extend processor-effect and run-observability tests to expect additional attempts/recovered_incidents/llm.calls, add maintenance lifecycle-close-during-backoff test, and update snapshots to expect auto_retry recommendation.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Processor
  participant SessionRetry as RetryPolicy
  participant LifecycleProvenance as Lifecycle
  Client->>Processor: run session / encounter disconnect
  Processor->>RetryPolicy: safeRecoveryPolicy(attempt, delay?)
  RetryPolicy-->>Processor: waitDuration (persist next)
  Processor->>Lifecycle: whenLifecycleCloseBegins(directory)
  par backoff vs close
    RetryPolicy->>Processor: backoff timeout fires
    Processor->>Processor: schedule replay attempt
  and
    Lifecycle->>Processor: notify closing begun
    Processor->>Processor: re-check retryStillAllowed(during_backoff)
    Processor-->>Client: abort if closing/blocked
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Astro-Han/pawwork#929: Related refactor of safe-recovery/replay safety routing and evaluation used by this change.
  • Astro-Han/pawwork#931: Prior safe-recovery policy work; this PR adjusts backoff computation and attempt budget on top of that.
  • Astro-Han/pawwork#914: Overlapping edits to run-incident recoveryFor auto-retry branches.

Poem

🐰 I hopped three times to fix the stream,

backoff grew like a waking dream —
two, then four, then eight in tune,
a watcher stirs to end too soon,
tests cheer loud beneath the moon.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: enabling safe recovery retries up to 3 times with exponential backoff, matching the PR's primary objective.
Description check ✅ Passed The description provides clear problem statement, specific changes, verification steps, and explicitly scopes out unrelated work, though some optional template sections are missing.
Linked Issues check ✅ Passed All coding requirements from #1006 are met: safe recovery now retries 3 times with exponential backoff (2s→4s→8s), type changes from auto_retry_once to auto_retry, safety gate logic updated, and comprehensive test coverage added.
Out of Scope Changes check ✅ Passed All changes are scoped to safe recovery retry enhancement: constants, delays, types, tests, and related infrastructure. API-error retry path and run-observability diagnostics counter are correctly left unchanged per scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/safe-recovery-retry-budget

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the P2 Medium priority label May 31, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request increases the automatic safe recovery retry budget from 1 to 3 attempts and replaces the static 1-second delay with an exponential backoff schedule (2s, 4s, 8s). It updates the recovery policies, types, and corresponding integration and unit tests to accommodate the multi-attempt budget. The review feedback points out an inaccurate comment in retry.test.ts regarding test execution time, clarifying that the test runs instantly because Schedule.toStepWithMetadata only calculates the backoff duration without actually sleeping.

Comment thread packages/opencode/test/session/retry.test.ts Outdated
@Astro-Han Astro-Han removed P2 Medium priority harness Model harness, prompts, tool descriptions, and session mechanics labels May 31, 2026
@github-actions github-actions Bot added harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority labels May 31, 2026
@Astro-Han Astro-Han removed P2 Medium priority harness Model harness, prompts, tool descriptions, and session mechanics labels May 31, 2026
@github-actions github-actions Bot added harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority labels May 31, 2026
Astro-Han added 2 commits May 31, 2026 15:52
The recommendation value carried a misleading `_once` suffix now that
safe recovery retries up to 3 times.  Rename across type definition,
policy, safety gate, presentation, and all test fixtures.
…play in recorder

When safe recovery retries exhaust the budget, the final
auto_replay_blocked decision now overwrites the earlier replay
decision in the recorder diagnostics.  Previously the recorder
skipped all subsequent recordRecoveryDecision calls after the
first retry_attempted, so the diagnostic summary never reflected
the budget exhaustion reason.
Astro-Han added 3 commits May 31, 2026 16:43
…e close

Race the backoff sleep against a 500ms lifecycle-close polling loop so
that beginLifecycleClose (maintenance reload/dispose) is detected
during the sleep, not only at the before/after_backoff checkpoints.

Also check isLifecycleClosing in retryStillAllowed so the polling
path and the checkpoint path both cover maintenance closes.
…signal, injectable delay

P3-1: policy.ts now imports SAFE_RECOVERY_MAX_ATTEMPTS and
RETRY_INITIAL_DELAY from retry.ts instead of hardcoding 3 / 2_000.

P3-2: Replace 500ms polling loop in processor with
whenLifecycleCloseBegins() — a promise-based signal from
lifecycle-provenance that resolves when beginLifecycleClose or
withLifecycleCloseAction fires for the directory.

P3-3: safeRecoveryPolicy accepts an optional delay function.
Processor threads it from its Input type.  Tests inject a 10ms
delay, dropping processor-effect from ~118s to ~21s and
retry.test.ts from ~25s to ~11s.
whenLifecycleCloseBegins now returns { promise, cancel } instead of a
bare Promise.  The processor wraps it in Effect.callback with a
cleanup finalizer that calls cancel() on interrupt, so the waiter is
removed from closingStartWaiters when backoff completes normally.

Adds closingStartWaiterCount() for test observability and a test that
verifies no waiter leaks after a full safe-recovery exhaust cycle
without any lifecycle close.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/opencode/test/session/retry-decision.test.ts (1)

5-10: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update this fixture to the new 3-attempt auto-retry contract

recommendation was renamed, but auto_retry.max_attempts and backoff_ms still describe the old one-retry behavior. That means the budget-exhaustion case below is still validating the pre-PR boundary, so this suite would miss a regression that blocks attempt 2 or 3 too early.

Suggested fix
 const safeReplayGate: RunIncident.Recovery = {
   recommendation: "auto_retry",
   confidence: "high",
   reason: "reasoning_only_without_final_text_or_tool_activity",
-  auto_retry: { max_attempts: 1, backoff_ms: 1_000 },
+  auto_retry: { max_attempts: 3, backoff_ms: 2_000 },
   safety_scope: "visible_output_and_tool_side_effects",
 }

Then move the exhaustion assertion to safeRecoveryAttempt: 3 so the test matches the new contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/session/retry-decision.test.ts` around lines 5 - 10,
Update the fixture object safeReplayGate (type RunIncident.Recovery) to reflect
the new 3-attempt auto-retry contract: change the renamed recommendation field
to its new name as used elsewhere in the codebase, set auto_retry.max_attempts
to 3 (instead of 1) and keep backoff_ms as appropriate (1_000), and update any
related test variable safeRecoveryAttempt to 3 so the exhaustion assertion
validates the new third-attempt boundary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/opencode/src/session/processor.ts`:
- Around line 1458-1463: The current catchCause on the Effect.race swallows all
causes; change it to only convert the specific schedule-exhausted cause produced
by Schedule.toStepWithMetadata into "exhausted" and rethrow all other causes so
interruptions and real failures still propagate and allow Effect.onInterrupt(()
=> recordProcessInterrupt(attemptID)) to run. Concretely, replace the
unconditional Effect.catchCause with a handler that inspects the incoming Cause
(from Effect.catchCause), detects the schedule-done/exhausted marker emitted by
Schedule.toStepWithMetadata (or otherwise matches the schedule exhaustion
Cause), and returns Effect.succeed("exhausted" as const) only in that case; for
any other Cause rethrow using Effect.failCause(cause) so failures and interrupts
remain intact. Ensure references: backoffResult, Effect.race(safeRecoveryStep,
lifecycleCloseWatch), Effect.catchCause, Schedule.toStepWithMetadata,
recordProcessInterrupt, and attemptID are used to locate and implement the fix.

---

Outside diff comments:
In `@packages/opencode/test/session/retry-decision.test.ts`:
- Around line 5-10: Update the fixture object safeReplayGate (type
RunIncident.Recovery) to reflect the new 3-attempt auto-retry contract: change
the renamed recommendation field to its new name as used elsewhere in the
codebase, set auto_retry.max_attempts to 3 (instead of 1) and keep backoff_ms as
appropriate (1_000), and update any related test variable safeRecoveryAttempt to
3 so the exhaustion assertion validates the new third-attempt boundary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f2d8638a-2084-4d56-96e8-47cfbd122c0f

📥 Commits

Reviewing files that changed from the base of the PR and between 686572f and 817b347.

📒 Files selected for processing (14)
  • packages/opencode/src/session/lifecycle-provenance.ts
  • packages/opencode/src/session/processor.ts
  • packages/opencode/src/session/retry.ts
  • packages/opencode/src/session/run-incident/policy.ts
  • packages/opencode/src/session/run-incident/presentation.ts
  • packages/opencode/src/session/run-incident/safety-gate.ts
  • packages/opencode/src/session/run-incident/types.ts
  • packages/opencode/src/session/run-observability/recorder.ts
  • packages/opencode/test/session/export.test.ts
  • packages/opencode/test/session/processor-effect.test.ts
  • packages/opencode/test/session/retry-decision.test.ts
  • packages/opencode/test/session/retry.test.ts
  • packages/opencode/test/session/run-incident-safety-gate.test.ts
  • packages/opencode/test/session/run-observability.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/opencode/test/session/run-incident-safety-gate.test.ts
  • packages/opencode/src/session/run-incident/policy.ts
  • packages/opencode/test/session/processor-effect.test.ts

Comment thread packages/opencode/src/session/processor.ts
…terrupts

catchCause on the backoff race now checks Cause.hasInterruptsOnly:
pure interrupts are re-raised as Effect.interrupt so onInterrupt
still fires, while schedule exhaustion maps to "exhausted".
@Astro-Han
Astro-Han merged commit 663bda2 into dev May 31, 2026
35 of 37 checks passed
@Astro-Han
Astro-Han deleted the feat/safe-recovery-retry-budget branch August 21, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] improve stream disconnect retry with exponential backoff and more attempts

1 participant