Skip to content

fix(core): recover sessions and jobs from transient storage failures - #53704

Closed
kitlangton wants to merge 5 commits into
devfrom
fix/disk-full-session-recovery
Closed

kitlangton wants to merge 5 commits into
devfrom
fix/disk-full-session-recovery

Conversation

@kitlangton

Copy link
Copy Markdown
Contributor

Why

When the host disk filled for ~90 seconds during active work, SQLite WAL buffered transaction pages until COMMIT failed with SQLITE_FULL. Durable event writes crashed in-flight turns, the subsequent SessionEvent.Execution.Failed publish also failed and was dropped, and those sessions kept their write-ahead execution claims and appeared busy forever. At the same time, background jobs persisted their outcome inside Job.settle before resolving job.done, so a failed KV write left finished jobs stuck in "running" state in memory, hung their waiters, silently dropped background subagent parent notifications, and caused prompt admissions to return 500s.

What Changes

Path Before After
Durable event commits (Bus.publish, Bus.publishAll, Bus.replay) A single SQLITE_FULL / SQLITE_BUSY / SQLITE_LOCKED / SQLITE_IOERR at COMMIT immediately died and aborted the caller. Reruns the full transaction with exponential backoff (100 ms to 2 s cap) for up to 30 s (StorageRetry.write), riding out brief disk-full or lock windows without failing the turn or prompt admission.
Session execution lifecycle (SessionExecution) If Execution.Started or a terminal event (Succeeded, Interrupted, Failed) failed to commit, the error was logged and dropped, leaving the session's claim set and its status permanently busy. Retries lifecycle publications with StorageRetry.settlement (1 s to 10 s cap, up to 15 min) once the initial write window fails, then releases the claim and records { type: "storage", message: "Couldn’t save the session: …" } when storage recovers. Shutdown interrupts the retry wait and preserves the claim for restart recovery.
Background job settlement (Job.settle) Persisted the KV recovery marker before updating job state; a storage failure aborted settle, leaving the job "running" in memory and its done deferred unresolved. Retries the KV marker write through StorageRetry.write, logs any exhausted storage error, and always settles the in-memory job and resolves job.done.
Background subagent completion (SubagentJob.notify) Called SubagentCompletion.deliver once without retry or error logging; a storage outage after jobs.wait silently lost the parent notification. Retries SubagentCompletion.deliver with StorageRetry.settlement using the job's stable notificationID (so retries are idempotent) and logs exhausted non-interrupt failures.
sequenceDiagram
    participant Runner as SessionRunner / Job
    participant Exec as SessionExecution / SubagentJob
    participant Bus as Bus / KV
    participant DB as SQLite (WAL)

    rect rgb(60, 35, 35)
    Note over Runner,DB: Before: 90 s full disk
    Runner->>Bus: publish(StepEvent)
    Bus->>DB: COMMIT
    DB-->>Bus: SQLITE_FULL (rollback)
    Bus-->>Exec: Defect (SQLITE_FULL)
    Exec->>Bus: publish(Execution.Failed) + release claim
    Bus->>DB: COMMIT
    DB-->>Bus: SQLITE_FULL (rollback)
    Note over Exec,DB: Terminal event dropped; claim stays held and session looks busy forever
    end

    rect rgb(30, 55, 40)
    Note over Runner,DB: After: < 30 s full disk
    Runner->>Bus: publish(StepEvent)
    loop StorageRetry.write (up to 30 s)
        Bus->>DB: BEGIN IMMEDIATE ... COMMIT
        DB-->>Bus: SQLITE_FULL (rollback)
    end
    Note over DB: Disk space frees
    Bus->>DB: BEGIN IMMEDIATE ... COMMIT
    DB-->>Bus: OK
    Bus-->>Runner: Committed once, turn continues
    end

    rect rgb(35, 45, 65)
    Note over Runner,DB: After: > 30 s full disk (e.g. 90 s)
    Runner->>Bus: publish(StepEvent)
    Bus-->>Exec: Exhausted after 30 s (SQLITE_FULL)
    loop StorageRetry.settlement (interruptible, up to 15 min)
        Exec->>Bus: publish(Execution.Failed { type: "storage" }) + release claim
        Bus->>DB: COMMIT
        DB-->>Bus: SQLITE_FULL (rollback)
    end
    Note over DB: Disk space frees
    Exec->>Bus: publish(Execution.Failed { type: "storage" }) + release claim
    Bus->>DB: COMMIT
    DB-->>Bus: OK (claim released, session marked failed)
    end
Loading

Storage Recovery & Tradeoffs

  • Visible failure instead of auto-resume: When a turn fails because mid-turn writes exhausted the 30 s StorageRetry.write window, it records SessionEvent.Execution.Failed with { type: "storage" } and releases the execution claim once storage recovers. It does not auto-resume the turn after storage returns, avoiding re-executing tool calls with external side effects. If the server shuts down while waiting to record the terminal event, the interruptible settlement retry exits immediately and leaves the write-ahead claim intact for normal restart recovery.
  • Retry windows: StorageRetry.write (100 ms exponential backoff capped at 2 s, up to 30 s) is sized to keep HTTP requests and active steps responsive while riding out short stalls. StorageRetry.settlement (1 s exponential backoff capped at 10 s, up to 15 min) is reserved for terminal session outcomes and subagent completion notices that nothing else will re-drive.
  • SQLITE_IOERR and double-commit risk: StorageRetry classifies SQLITE_FULL, SQLITE_BUSY, SQLITE_LOCKED, and SQLITE_IOERR (including node:sqlite's ERR_SQLITE_ERROR with primary codes 5, 6, 10, 13) as transient. SQLite automatically rolls back the transaction on SQLITE_FULL, SQLITE_BUSY, SQLITE_LOCKED, and most SQLITE_IOERR failures, making whole-transaction replay atomic. However, an I/O error occurring late in COMMIT after WAL frames have flushed to disk can theoretically report SQLITE_IOERR after the transaction has already become durable, which carries a small risk of applying a retried transaction twice.

Scope

This PR covers @opencode/core durable event transactions (Bus), session lifecycle settlement (SessionExecution, toSessionError), in-memory vs durable background job settlement (Job), background subagent parent notifications (SubagentJob), and the StorageFault SQLite full-disk test harness.

Verification

bun run --cwd packages/core typecheck
bunx oxlint packages/core/src/bus.ts packages/core/src/database/storage-retry.ts packages/core/src/job.ts packages/core/src/session/execution.ts packages/core/src/session/subagent-job.ts packages/core/src/session/to-session-error.ts packages/core/test/lib/storage-fault.ts packages/core/test/session-execution.test.ts packages/core/test/session-runner.test.ts packages/core/test/storage-retry.test.ts
bun run --cwd packages/core test test/storage-retry.test.ts test/bus.test.ts test/bus-session-routing.test.ts test/job.test.ts test/kv.test.ts test/tool-subagent.test.ts test/session-error.test.ts test/session-execution.test.ts test/session-run-coordinator.test.ts test/session-runner.test.ts test/session-store.test.ts test/session-prompt.test.ts test/session-wait.test.ts test/session-shell.test.ts
bun run --cwd packages/core test test/database.test.ts test/database-drizzle.test.ts test/session-projector.test.ts test/session-log.test.ts test/session-runner-retry.test.ts test/session-runner-recorded.test.ts test/session-runner-message.test.ts test/session-runner-tool-events.test.ts test/session-step.test.ts test/session-revert.test.ts test/session-remove.test.ts test/session-move.test.ts test/session-owned.test.ts test/session-view.test.ts test/session-compact.test.ts test/session-compaction.test.ts test/session-activation.test.ts test/session-create.test.ts
  • packages/core typecheck and repo-wide bun run check (lint + 36-package turbo typecheck) pass with 0 errors.
  • 448 tests across the 14 storage, bus, job, subagent, and session execution/runner suites pass, plus 197 tests across the 18 additional database and session suites.
  • In the full packages/core suite, test/plugin/provider-digitalocean.test.ts fails 6 OAuth callback tests with ConnectionRefused against http://localhost:1456/auth/*; verified the identical 6 failures on unmodified origin/v2 (34cf183c4c).

…ures

A full disk made every durable publish die at COMMIT with SQLITE_FULL, so a
running turn failed on its next event. SQLite rolls such a transaction back,
so the bus now reruns the whole commit with backoff for up to 30 seconds
when the failure is SQLITE_FULL, SQLITE_IOERR, SQLITE_BUSY, or SQLITE_LOCKED.
Brief outages now stall the turn instead of breaking it.
When a turn died on a full disk, publishing its terminal event failed the
same way and was only logged. The Session lost its outcome, kept its
execution claim, and looked busy to clients until someone interrupted it.

Lifecycle events now keep retrying while storage reports a transient
failure, so the outcome lands once space frees. The retries wait
interruptibly, so shutdown during an outage keeps the claim for restart
recovery instead of hanging. Storage failures also surface as a storage
error rather than an unknown one.
A background job persisted its outcome inside settlement, so a write that
failed on a full disk aborted settlement entirely: the job stayed running
forever, its waiters never resumed, and the parent of a background subagent
was never told the child had failed. Later continuations joined the stuck
job and skipped notification too.

Persisting the outcome now retries transient storage failures and then
settles regardless, logging the lost marker. Delivering the outcome to the
parent retries through longer outages, which is safe because the notice
reuses its notification ID, and failures are logged instead of vanishing.
@thdxr
thdxr changed the base branch from v2 to dev October 10, 2026 19:35
woodsonl added a commit to woodsonl/opencode that referenced this pull request Oct 27, 2026
…ment the control-statement bypass

Review follow-up:

- The lock holder is released by the test at wide margins instead of
  setTimeout, so the first attempt always precedes the release; the
  cross-fiber race is ordered with a Deferred that parks the interfering
  transaction mid-body. The interleaving no longer depends on 10/30/120ms
  sleeps lining up, and the race test now fails on the pre-fix head
  deterministically instead of by luck.
- Shared setup (schema, WAL, holder, value reads) shrinks the file from 361
  to ~250 lines across 6 focused cases: bun retry, values-path retry, node
  classification, fail-fast on a stale snapshot, fail-fast on non-lock
  errors, and the cross-fiber race.
- The transactionOpen bypass in gateStatement now documents why only the
  transaction's own BEGIN/COMMIT/ROLLBACK can reach it: every gated
  statement runs holding the client semaphore, and the flag is true exactly
  while a transaction holds it, so a foreign statement queues instead of
  observing it. The header comment states the layering with caller-side
  whole-transaction replay (anomalyco#53704) so the two retry layers stay disjoint.
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant