Skip to content

fix(daemon): Heal sessions failed by a locked store on boot - #127

Merged
4ndreello merged 3 commits into
mainfrom
claude/busy-einstein-2n3ho9
Sep 25, 2026
Merged

4ndreello merged 3 commits into
mainfrom
claude/busy-einstein-2n3ho9

Conversation

@4ndreello

Copy link
Copy Markdown
Owner

Problem

Before #126, the event loop marked a session failed on database is locked and stopped reading its log, while the harness kept running. Everything past the persisted logOffset stayed 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 failed forever, because recover() only looks at active sessions.

Change

Heal on boot (6c62d5a)

reviveStoreBusyFailures() runs at the start of recover().

  • Which rows: failed rows with failure.code = STORE_BUSY, or the old UNKNOWN rows whose detail says database is locked. SessionStore.listStoreBusyFailures() finds them.
  • Conditions: the row needs a log file and a pid, and is never origin = open.
  • Mechanism: the row goes back to working, then through the same reattach as a daemon restart.
    • Live harness (same pid identity): it keeps being tailed from the offset.
    • Dead harness, or a recycled pid: the log is drained from the offset and the outcome is classified. A healing row with a reused pid counts as dead instead of failing with pid_reused, since its log is still its own.
  • Timestamps: parsers stamp events with read time, so the drained backlog and completedAt are pinned to the log's mtime (finishHeal). Usage-by-hour buckets by created_at, so only the timeline and completedAt change.
  • Idempotence: the new outcome no longer carries a locked-store detail, so the next boot does not revive the row again.

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 failed with "exited without reporting a terminal event", healed rows included.

synthesizeTerminalEvent now returns session.completed with reason: "turn completed; exit not observed" when all of these hold:

  • the exit code is unknown and there is no signal;
  • the last non-usage event is turn.completed (SessionRuntime passes endedOnTurnCompleted);
  • stderr carries no crash signature.

It stays failed when the log stops before turn.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.completed was persisted before the daemon went away, and whose exit was then missed, still closes failed.

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) drives recover() with the real codex driver against a real ndjson log:

  • dead harness: backlog drained, completed, events and completedAt at the log mtime;
  • live harness: back to working, new lines persisted;
  • STORE_BUSY rows are healed;
  • non-lock failures and rows without a log are left alone;
  • a log cut before turn.completed closes failed.

tests/terminal-synth.test.ts (5 tests) covers the completion rule and each case that keeps it failed.

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 on main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R6o1wJTTJWcHm9mZMGvDTn


Generated by Claude Code

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
@sonarqubecloud

Copy link
Copy Markdown

@4ndreello
4ndreello merged commit 1d01381 into main Sep 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants