fix(daemon): Heal sessions failed by a locked store on boot - #127
Merged
Merged
Conversation
The old event loop marked a session failed on "database is locked" and
stopped reading its log while the harness kept running, so everything
past the persisted offset never reached the store.
On boot, recover() now flips such rows (STORE_BUSY, or the old UNKNOWN
with a locked-store detail) back to working when a log and pid exist,
then runs them through the usual reattach. A live harness keeps being
tailed; a dead one, or a recycled pid, is drained from the offset and
classified. Parsers stamp read time, so the drained backlog and
completedAt take the log mtime instead of the replay time.
A dead codex still closes failed ("exited without reporting a terminal
event"), because its exit code is not observable after a reattach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6o1wJTTJWcHm9mZMGvDTn
A reattached runtime learns of a death by polling the pid, so it never sees an exit code. Codex writes no terminal frame and completes only through exit 0, so every codex turn that ended while the daemon was away closed failed with "exited without reporting a terminal event", healed store-busy sessions included. When the exit is unobserved, there is no signal, the last non-usage event is turn.completed and stderr carries no crash signature, synthesize session.completed with reason "turn completed; exit not observed". A log that stops before turn.completed still closes failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6o1wJTTJWcHm9mZMGvDTn
recover() starts each event loop without awaiting it, and the session flips to completed before the loop's tail runs. The heal tests closed the store as soon as they saw the status, so a slow runner hit "database is not open" as an unhandled rejection and failed CI. afterEach now waits until every healed loop reached finishHeal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6o1wJTTJWcHm9mZMGvDTn
|
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.



Problem
Before #126, the event loop marked a session
failedondatabase is lockedand stopped reading its log, while the harness kept running. Everything past the persistedlogOffsetstayed on disk and never reached the store. One such session was a codex resume turn: its tool calls, final message and second-turn usage were all lost.#126 stops new cases. Rows already failed that way stay
failedforever, becauserecover()only looks at active sessions.Change
Heal on boot (
6c62d5a)reviveStoreBusyFailures()runs at the start ofrecover().failedrows withfailure.code = STORE_BUSY, or the oldUNKNOWNrows whose detail saysdatabase is locked.SessionStore.listStoreBusyFailures()finds them.origin = open.working, then through the same reattach as a daemon restart.pid_reused, since its log is still its own.completedAtare pinned to the log's mtime (finishHeal). Usage-by-hour buckets bycreated_at, so only the timeline andcompletedAtchange.Complete a reattached turn that ended on
turn.completed(750123d)A reattach learns of a death by polling the pid and never sees an exit code. Codex writes no terminal frame and completes only through exit 0. So every codex turn that ended while the daemon was away closed as
failedwith "exited without reporting a terminal event", healed rows included.synthesizeTerminalEventnow returnssession.completedwithreason: "turn completed; exit not observed"when all of these hold:turn.completed(SessionRuntimepassesendedOnTurnCompleted);It stays
failedwhen the log stops beforeturn.completed, on a fatal signal, on an observed non-zero exit, or on a crash signature in stderr.Limitation: the runtime only sees events read since the reattach. A turn whose
turn.completedwas persisted before the daemon went away, and whose exit was then missed, still closesfailed.Spec:
.specs/features/store-busy-healing/spec.md.Validation
Tests were written first and seen red:
tests/daemon-heal-store-busy.test.ts(6 tests) drivesrecover()with the real codex driver against a real ndjson log:completed, events andcompletedAtat the log mtime;working, new lines persisted;STORE_BUSYrows are healed;turn.completedclosesfailed.tests/terminal-synth.test.ts(5 tests) covers the completion rule and each case that keeps itfailed.Full suite in a Node 22 container: 1648 pass, 1 fail. The failure (
open-claude-probes"probes again when the binary size changes") also fails onmain.🤖 Generated with Claude Code
https://claude.ai/code/session_01R6o1wJTTJWcHm9mZMGvDTn
Generated by Claude Code