Repository navigation
The CLI and MCP show why an auto-started daemon did not start #760
Description
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 aLaunchedDaemon;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 thesimlock daemonsection,src/cli/AGENTS.md,src/mcp/AGENTS.mdif 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
Surface and extension points
DaemonLauncher.launch(): Promise<LaunchedDaemon>.LaunchedDaemon.exited: Promise<DaemonExit>, withDaemonExit = { 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. Ifdaemon.log's inode still matches, the output isdaemon.logfrom the noted size. If it rotated (F11), the output isdaemon.log.1from the noted size when.1has the noted inode, thendaemon.logfrom 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 staysunrefed.startErrorFrom(exit: DaemonExit, output: string): stringinsrc/ports/daemon-launcher.ts, one place for both frontends (architecture rule 10). The last line ofoutputthat parses as JSON withmessage === "Daemon failed to start"and a stringfields.messagegivesThe daemon could not start: <fields.message>. With none,The daemon exited with code <code> while starting; see \simlock daemon logs`, using the signal name whencodeisnull`. A line that does not parse is skipped.FakeDaemonLaunchertakes an optionalexitandoutput; it resolvesexitedwithexitafter its callback, andreadOutputanswersoutput.src/cli/environment.tsloadsstartErrorFromby dynamicimport(), likeDAEMON_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:
startErrorFromserves both frontends. - Ports rule, "What the bytes mean is the caller's business":
startErrorFromis 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'sexitedgives the exit code, andreadOutputonly the log bytes written since the launch, for: a child that writes a line and exits 1 into a log that already held an olderDaemon failed to startline, and a child that renames the log todaemon.log.1(asNodeFileLogSinkrotates) before writing its fatal line to a newdaemon.log.- A parent process that launched a long-lived child with
NodeDaemonLauncherand awaits nothing exits while the child keeps running. startErrorFromgives, 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 bySIGKILL, 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 withDAEMON_STARTUP_FAILED, that message and exit 1, before the fake clock reaches 20 s. - The CLI's
status, with a launcher whose daemon exitsalready runningwhile another in-memory daemon answers, succeeds. - End to end:
simlock statuswith the worker folder holdingstate.json(task 1's refusal) printsDAEMON_STARTUP_FAILEDwithThe daemon could not start: Found <home>/state.json, …and exits 1, in under 5 s from the command's start (newe2e/start-refusal.test.ts, real CLI and real daemon, model:e2e/daemon-folders.test.ts:170). connectWithAutoLaunchrejects 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 (extendssrc/mcp/connect.test.ts:11).
Done when
pnpm test:changedpasses.pnpm test:e2e e2e/start-refusal.test.tspasses.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
- state.db opens and checks at start, and the worker's registry lives in it #761: the e2e test uses its
state.jsonrefusal.
Written by an agent.
Activity
[-]The gateway's drained workers live in state.db[/-][+]The CLI and MCP show why an auto-started daemon did not start[/+]test: the CLI's status goes on with the winner when the launched daem…
test: startErrorFrom names the last fatal line's message, else the ex…
test: NodeDaemonLauncher's exited gives the exit code and readOutput …
feat(ports): NodeDaemonLauncher reports the daemon's exit and the log…
test: a parent that launched a daemon and awaits nothing exits while …
test: connectWithAutoLaunch rejects with the start error as soon as t…
feat(mcp): the MCP server's auto-start fails with the daemon's start …
test: simlock status prints the daemon's start error when the folder …
docs(cli): the daemon's start error is shown when it exits during sta…
refactor(cli): the exited-daemon path is its own function in both fro…
test(launcher): the parent guard can fire, and the unreadable-output …
feat(cli): the CLI and MCP show why an auto-started daemon did not st…