Skip to content

The CLI and MCP show why an auto-started daemon did not start #760

Closed

Description

@V3RON

Part of #754.

Scope

When the CLI or the MCP server auto-starts a daemon that exits before it accepts connections, the command fails at once with the daemon's own start error, in place of a 20 s wait and a socket error. It holds for every start failure, and this feature's refusals are the main case. A daemon that exits because another one won the socket is not a failure: the command goes on with the winner.

In short

Today the launcher returns as soon as the daemon process spawns, and the command polls the socket for up to 20 s. After this task the launcher also tells the command when that process exits, and what it wrote to daemon.log.

# before: after 20 s
$ simlock status
{"error":{"code":"DAEMON_STARTUP_FAILED","message":"Timed out starting simlock daemon: <socket error>"}}
# after: as soon as the daemon exits
$ simlock status
{"error":{"code":"DAEMON_STARTUP_FAILED","message":"The daemon could not start: <the daemon's start error>"}}
The daemon, after launch Today After
accepts a connection goes on goes on
exits, its log has a Daemon failed to start line since launch 20 s, then Timed out starting simlock daemon: <socket error> at once: The daemon could not start: <that line's fields.message>
exits, no such line 20 s, then the timeout at once: The daemon exited with code <code> while starting; see \simlock daemon logs` (` is the exit code, or the signal name)
exits because another daemon holds the socket, which answers goes on with the other daemon goes on with the other daemon
neither accepts nor exits in 20 s the timeout the timeout, as today

The same rows hold for the MCP server's auto-start. Both fail with DAEMON_STARTUP_FAILED, exit 1 for the CLI.

If this goes wrong, a user would see a start error from an older failure, or a command that fails although a daemon came up.

Technical spec

Facts

Commit 8db8112b.

# Fact Source
F1 NodeDaemonLauncher.launch opens daemon.log for append (0600), spawns the daemon detached with stdout and stderr on it, resolves on spawn, then unrefs the child src/ports/daemon-launcher.ts:35-60
F2 DaemonLauncher is launch(): Promise<void>; FakeDaemonLauncher counts launches and runs a callback src/ports/daemon-launcher.ts:6-8, :63-72
F3 DAEMON_STARTUP_TIMEOUT_MS = 20_000, shared by both frontends src/ports/daemon-launcher.ts:17
F4 The CLI launches, then retries the connect every 50 ms until the deadline, then throws DAEMON_STARTUP_FAILED Timed out starting simlock daemon: <last error> src/cli/environment.ts:40-62
F5 The MCP server does the same src/mcp/connect.ts:50-72
F6 A start that throws writes one JSON line to the role's daemon.log: message Daemon failed to start, fields.message the error, then exit code 1 src/daemon/main.ts:1486-1493, :1503-1506; record shape src/ports/logger.ts:79-90
F7 A daemon that loses the socket claim throws DaemonAlreadyRunningError Simlock daemon is already running at <endpoint> src/daemon/connection-host.ts:18-23
F8 The CLI prints an error as one JSON line {"error":{"code","message"}} on stderr; DAEMON_STARTUP_FAILED exits 1 src/cli/errors.ts:22-29; docs/CLI.md:148
F9 Tests: src/ports/daemon-launcher.test.ts (real spawns in a temp dir), src/mcp/connect.test.ts:11 (the 20 s deadline), src/cli/index.test.ts:1227 (FakeDaemonLauncher cold start) as cited
F11 NodeFileLogSink rotates by renaming the file to <path>.1 and opening a new one, past maxBytes (5 MiB default for the fatal logger, log.rotateBytes for the daemon's) src/ports/logger.ts:144-166; src/daemon/main.ts:1476-1484; src/core/config.ts:1080
F12 environment.ts loads every component value by dynamic import() (rule cli-router-imports-no-component-values, src/cli/lazy-loading.test.ts) src/cli/environment.ts:42-43
F10 FakeDaemonLauncher users: src/mcp/connect.test.ts, src/mcp/main.test.ts, src/cli/lazy-parent-watch.test.ts, src/cli/index.test.ts, src/cli/lazy-loading.test.ts rg -l FakeDaemonLauncher src

Modules touched

Searches: DaemonLauncher, launch(), DAEMON_STARTUP_TIMEOUT_MS, Timed out starting over src/, docs/.

  • src/ports/daemon-launcher.ts (launch() answers a LaunchedDaemon; startErrorFrom(output)), src/ports/index.ts, src/ports/AGENTS.md.
  • src/cli/environment.ts, src/mcp/connect.ts.
  • Tests of F9 and F10.
  • Docs: docs/CLI.md:148 (the exit-code row) and the simlock daemon section, src/cli/AGENTS.md, src/mcp/AGENTS.md if they describe the start wait.
sequenceDiagram
  participant C as CLI or MCP
  participant L as NodeDaemonLauncher
  participant D as daemon process
  C->>L: launch()
  L->>D: spawn (log fd, offset noted)
  L-->>C: LaunchedDaemon
  loop every 50 ms, up to 20 s
    C->>D: connect
  end
  D-->>L: exit(code)
  L-->>C: exited: { code, signal }
  C->>L: readOutput()
  C->>D: connect once more
  alt a daemon answers
    C->>C: go on
  else none
    C-->>C: DAEMON_STARTUP_FAILED with the start error
  end
Loading

Surface and extension points

  • DaemonLauncher.launch(): Promise<LaunchedDaemon>. LaunchedDaemon.exited: Promise<DaemonExit>, with DaemonExit = { code: number | null; signal: string | null }. The promise settles only if the child exits. LaunchedDaemon.readOutput(): Promise<string> reads what the log gained since the launch. It is read only on demand, so a daemon that exits long after a good start costs no read. The launcher notes the log's size and its inode at launch. If daemon.log's inode still matches, the output is daemon.log from the noted size. If it rotated (F11), the output is daemon.log.1 from the noted size when .1 has the noted inode, then daemon.log from 0. The read is capped to its last 64 KiB. Watching the exit keeps the CLI process from exiting no longer than today: the child stays unrefed.
  • startErrorFrom(exit: DaemonExit, output: string): string in src/ports/daemon-launcher.ts, one place for both frontends (architecture rule 10). The last line of output that parses as JSON with message === "Daemon failed to start" and a string fields.message gives The daemon could not start: <fields.message>. With none, The daemon exited with code <code> while starting; see \simlock daemon logs`, using the signal name when codeisnull`. A line that does not parse is skipped.
  • FakeDaemonLauncher takes an optional exit and output; it resolves exited with exit after its callback, and readOutput answers output.
  • src/cli/environment.ts loads startErrorFrom by dynamic import(), like DAEMON_STARTUP_TIMEOUT_MS (F12).

Contract and event changes

None. DAEMON_STARTUP_FAILED keeps its code and details (src/contract/errors.ts:34, :242); only its message text is new.

Other code on the same state

Searched daemon.log readers: simlock daemon logs (src/cli/commands/daemon.ts:93-94) reads the file and is unchanged. The daemon's own sink appends to the same file (F1). The launcher reads only the bytes after its noted offset, so an older failure in the log is never shown.

Failure modes

Call Outcome What happens Limit, counted from
connect during start a daemon answers go on, as today —
the child exits readOutput, then one more connect: a daemon answers → go on; else fail with startErrorFrom the remaining start deadline, from launch
neither in time Timed out starting simlock daemon: <last error>, as today 20 s, from launch
readOutput the read fails, or the noted inode is in neither file The daemon exited with code <code> while starting; see \simlock daemon logs`` —

Two commands auto-starting at once: one daemon wins, the other exits (F7), and both commands connect to the winner. Two daemons of one role append to one log, and a record carries no pid, so in the rare case where both fail at once, a command can show the other daemon's start error. Accepted: both errors come from the same folder and the same start.

Rules in play

  • ADR 0036 §5 (PR docs(adr): 0036 daemon state is a SQLite database, and each owner keeps its own tables #755, accepted with this feature), "A CLI or MCP server that auto-started the daemon prints that error as soon as the daemon exits".
  • Architecture rule 11, every wait bounded: the deadline stays DAEMON_STARTUP_TIMEOUT_MS.
  • Architecture rule 10, one place: startErrorFrom serves both frontends.
  • Ports rule, "What the bytes mean is the caller's business": startErrorFrom is the one place that knows the fatal line, kept beside the launcher, which knows the log.

Tests

Seams: NodeDaemonLauncher (model: src/ports/daemon-launcher.test.ts), the CLI's auto-start through runCli with FakeDaemonLauncher (model: src/cli/index.test.ts:1227), and connectWithAutoLaunch (model: src/mcp/connect.test.ts:11).

  • NodeDaemonLauncher's exited gives the exit code, and readOutput only the log bytes written since the launch, for: a child that writes a line and exits 1 into a log that already held an older Daemon failed to start line, and a child that renames the log to daemon.log.1 (as NodeFileLogSink rotates) before writing its fatal line to a new daemon.log.
  • A parent process that launched a long-lived child with NodeDaemonLauncher and awaits nothing exits while the child keeps running.
  • startErrorFrom gives, for: the fatal line alone, the fatal line after other lines, two fatal lines (the last wins), no fatal line with code 1, no fatal line killed by SIGKILL, a non-JSON line before the fatal line. Each is the In short table's text word for word.
  • The CLI's status, with a launcher whose daemon exits with a fatal line, fails with DAEMON_STARTUP_FAILED, that message and exit 1, before the fake clock reaches 20 s.
  • The CLI's status, with a launcher whose daemon exits already running while another in-memory daemon answers, succeeds.
  • End to end: simlock status with the worker folder holding state.json (task 1's refusal) prints DAEMON_STARTUP_FAILED with The daemon could not start: Found <home>/state.json, … and exits 1, in under 5 s from the command's start (new e2e/start-refusal.test.ts, real CLI and real daemon, model: e2e/daemon-folders.test.ts:170).
  • connectWithAutoLaunch rejects with the start error as soon as the launched daemon exits, connects when the launched daemon exits but another daemon answers, and still times out at 20 s when it neither exits nor answers (extends src/mcp/connect.test.ts:11).

Done when

  • pnpm test:changed passes.
  • pnpm test:e2e e2e/start-refusal.test.ts passes.
  • docs/CLI.md:148's row says the daemon's start error is shown when it exits during start.

Out of scope

  • The history's refusal after the socket claim (history-refused): the command is already connected by then.
  • Showing more than the one start error.

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 20, 2026
  2. changed the title [-]The gateway's drained workers live in state.db[/-] [+]The CLI and MCP show why an auto-started daemon did not start[/+] on Oct 20, 2026
  3. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 20, 2026
  4. self-assigned this
    on Oct 20, 2026
  5. added a commit that references this issue on Oct 20, 2026
    3cafa87
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions