Repository navigation
fix(core): recover sessions and jobs from transient storage failures - #53704
Closed
kitlangton wants to merge 5 commits into
Closed
kitlangton wants to merge 5 commits into
kitlangton wants to merge 5 commits into
Conversation
…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.
3 tasks done
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.
3 tasks done
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:
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
When the host disk filled for ~90 seconds during active work, SQLite WAL buffered transaction pages until
COMMITfailed withSQLITE_FULL. Durable event writes crashed in-flight turns, the subsequentSessionEvent.Execution.Failedpublish 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 insideJob.settlebefore resolvingjob.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
Bus.publish,Bus.publishAll,Bus.replay)SQLITE_FULL/SQLITE_BUSY/SQLITE_LOCKED/SQLITE_IOERRatCOMMITimmediately died and aborted the caller.StorageRetry.write), riding out brief disk-full or lock windows without failing the turn or prompt admission.SessionExecution)Execution.Startedor 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.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.Job.settle)settle, leaving the job"running"in memory and itsdonedeferred unresolved.StorageRetry.write, logs any exhausted storage error, and always settles the in-memory job and resolvesjob.done.SubagentJob.notify)SubagentCompletion.deliveronce without retry or error logging; a storage outage afterjobs.waitsilently lost the parent notification.SubagentCompletion.deliverwithStorageRetry.settlementusing the job's stablenotificationID(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) endStorage Recovery & Tradeoffs
StorageRetry.writewindow, it recordsSessionEvent.Execution.Failedwith{ 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.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_IOERRand double-commit risk:StorageRetryclassifiesSQLITE_FULL,SQLITE_BUSY,SQLITE_LOCKED, andSQLITE_IOERR(includingnode:sqlite'sERR_SQLITE_ERRORwith primary codes5,6,10,13) as transient. SQLite automatically rolls back the transaction onSQLITE_FULL,SQLITE_BUSY,SQLITE_LOCKED, and mostSQLITE_IOERRfailures, making whole-transaction replay atomic. However, an I/O error occurring late inCOMMITafter WAL frames have flushed to disk can theoretically reportSQLITE_IOERRafter the transaction has already become durable, which carries a small risk of applying a retried transaction twice.Scope
This PR covers
@opencode/coredurable event transactions (Bus), session lifecycle settlement (SessionExecution,toSessionError), in-memory vs durable background job settlement (Job), background subagent parent notifications (SubagentJob), and theStorageFaultSQLite full-disk test harness.Verification
packages/coretypecheck and repo-widebun run check(lint+ 36-packageturbo typecheck) pass with 0 errors.packages/coresuite,test/plugin/provider-digitalocean.test.tsfails 6 OAuth callback tests withConnectionRefusedagainsthttp://localhost:1456/auth/*; verified the identical 6 failures on unmodifiedorigin/v2(34cf183c4c).