Skip to content

fix(daemon): Allow a single daemon per run-agent dir - #125

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

4ndreello merged 1 commit into
mainfrom
claude/busy-einstein-2n3ho9

Conversation

@4ndreello

@4ndreello 4ndreello commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

One machine had 10 dist/daemon/daemon.js --daemon processes running on the same ~/.run-agent. Only the newest owned the socket. The other nine kept tailing session logs and writing to SQLite.

How it happens:

  1. isDaemonRunning() gives up on the socket after 1s.
  2. The daemon uses synchronous DatabaseSync, so a long query, checkpoint or busy_timeout wait blocks accept.
  3. ensureDaemonStarted() then spawns a second daemon.
  4. createIpcServer() unlinks the live socket, and the new daemon's recover() reattaches the same sessions.

With several daemons writing, the deferred BEGIN in attachDriverEvents fails immediately with SQLITE_BUSY_SNAPSHOT (errcode 517), which busy_timeout does not wait on. The catch then marks the session failed with database is locked and leaves the event loop, while the harness is still running (seen on a codex exec resume turn).

Change

  • src/daemon/instance-lock.ts: acquireInstanceLock() holds daemon.lock as a small SQLite file in locking_mode=EXCLUSIVE with busy_timeout=0.
    • The kernel drops the fcntl lock on any exit, SIGKILL included, so there is never a stale lock to clean up.
    • It is a separate file because an exclusive lock on run-agent.db would block read-only CLIs.
  • The --daemon entry takes the lock before new Daemon(), so a losing daemon never migrates, recovers or touches the socket. It logs one line to daemon.log and exits 0, and the spawning CLI keeps polling the daemon that already runs.
  • getPaths().daemonLock, plus the spec in .specs/features/daemon-single-instance/spec.md.

Validation

  • tests/daemon-instance-lock.test.ts (5 tests, written first and seen red): same-process refusal, cross-process refusal, release after SIGKILL, release after release().
  • Manual run against dist/ with two daemons on one RUN_AGENT_DIR:
    • the second exits 0 and logs the refusal;
    • the first keeps the socket and daemon.pid;
    • after kill -9 on the first, a new daemon starts.
  • Full suite in a Node 22 container: 1630 pass, 1 fail. The failure (open-claude-probes "probes again when the binary size changes") also fails without this diff.

Follow-up

The busy-store handling in the event loop is a separate PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R6o1wJTTJWcHm9mZMGvDTn

A CLI whose 1s socket probe timed out against a busy daemon spawned
another one, which unlinked the live socket and reattached the same
sessions. Stray daemons kept writing to SQLite, so the event loop hit
SQLITE_BUSY_SNAPSHOT and marked live sessions failed with "database is
locked". One machine had 10 daemons running.

The daemon entry now takes an exclusive SQLite lock on daemon.lock
before opening the database. The kernel drops it on any exit, SIGKILL
included, so there is no stale lock to clean. A losing daemon logs and
exits 0, and the spawning CLI keeps polling the running one.

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 23bb4fb into main Sep 25, 2026
4 checks passed
@4ndreello 4ndreello changed the title fix(daemon): Allow a single daemon per run-agent dir fix(daemon): Keep one daemon and survive a busy store Sep 25, 2026
@4ndreello 4ndreello changed the title fix(daemon): Keep one daemon and survive a busy store fix(daemon): Allow a single daemon per run-agent dir Sep 25, 2026
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