fix(daemon): refuse to replace a reachable daemon newer than the client - #2811
Conversation
In a project depending on @e2edev/mobile (pinning agent-device 0.21.6) an unrelated dependency hoisted agent-device 0.20.8 onto node_modules/.bin. Running it printed "Replacing daemon (pid 88723, v0.21.6): version mismatch (client v0.20.8)" and killed the daemon that owned the live e2e session. The next engine command replaced it back, so every stray old-CLI invocation cost two daemon restarts and the attached sessions. A version mismatch now distinguishes direction. A daemon OLDER than the client is still replaced (the upgrade path). A reachable daemon NEWER than the client is neither reused nor replaced: the command fails with both versions and the stop command for a deliberate downgrade. An unreachable newer daemon is dead and replaced as before.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Address SemVer prerelease handling, restore existing version tests, and safely quote state-directory hints.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (3)
What changed in this PR
This PR prevents older clients from replacing reachable newer daemons.
Changes:
- Adds directional daemon version comparison and refusal errors.
- Passes the state directory into lifecycle diagnostics.
- Adds takeover and version-comparison regression tests.
| File | Description |
|---|---|
src/daemon-client/daemon-launch-spec.ts |
Implements newer-daemon refusal logic. |
src/daemon-client/daemon-client-lifecycle.ts |
Supplies the daemon state directory. |
src/daemon-client/__tests__/daemon-launch-spec.test.ts |
Tests version-direction decisions. |
src/daemon-client/__tests__/daemon-client-lifecycle.test.ts |
Tests end-to-end refusal behavior. |
packages/host-kit/src/version.ts |
Exports version comparison. |
packages/host-kit/src/internal/version.ts |
Implements version ordering. |
packages/host-kit/src/internal/version.test.ts |
Tests version ordering. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-ups on the newer-daemon takeover refusal: - isNewerVersion orders numeric release segments, ranks a release above any prerelease of the same base (0.21.13 > 0.21.13-dev, the shape main carries between releases) and compares prerelease fields per SemVer; tests cover the -dev, rc and build-metadata cases - restore the readVersion and project-root tests the previous commit replaced and append the comparator cases instead - shell-quote the state dir in the stop hint so it pastes back correctly - move the sendToDaemon refusal test into its own file; the lifecycle suite is past the size tripwire and may not grow
|
Addressed the review:
|
The Fallow complexity gate flagged compareVersions at cyclomatic 18; release, prerelease and per-field comparison are now separate functions.
Review follow-ups on the newer-daemon refusal: - resolveDaemonTakeover returns a typed decision (reuse | replace | refuseNewer) instead of a reason string that sometimes throws; the lifecycle layer, which already owns the takeover notice and knows the state dir, builds the refusal error and its shell-quoted hint. The launch-spec module no longer imports device-shell. - compareVersions is exported from host-kit/version and src/cli/update-check.ts uses it, deleting its numeric-collation copy (which ranked 0.12.0 below 0.12.0-dev and never prompted a -dev build). parseVersion is one anchored regex; a malformed version reads as 0.0.0 and the doc says so. - the loopback daemon fixture and captureStderr move to src/__tests__/test-utils/daemon-http-fixture.ts, shared by the lifecycle suite and the new refusal test instead of copied.
|
Restructured after an internal quality review (latest commit):
|
| function parseVersion(version: string): ParsedVersion { | ||
| const match = SEMVER.exec(version.trim()); | ||
| if (!match) return { release: [0, 0, 0], prerelease: [] }; |
|
Reviewed at 1bcdc8c. The refuseNewer gate decides refusal from Not blocking: the refusal could carry a typed Is dropping the exported I did not run any tests; the before/after behavior claims come from reading the code on both sides of the change. I did not confirm that Smoke Tests was queued at review time; it runs a same-version client through |
|
The iOS smoke failure on run 35862934402 is the same lane flake noted on #2804 ( |
The refusal keyed off reachability over the client's transport preference, so an older client with --daemon-transport http replaced a newer socket-only daemon. Reuse still requires the client's transport; refusal only needs the daemon alive. Co-Authored-By: Claude <noreply@anthropic.com>
|
Reviewed at 2c8e0fa. The earlier question is answered: the refusal now checks whether the newer daemon is alive on any transport it advertises, and the new One small note, not blocking: when The two Smoke Tests failures look unrelated. Android fails on "automation-alert-result did not become visible after five scrolls", and iOS fails in |


Summary
Seen while running the Bluesky e2e suite: the project depends on
@e2edev/mobile, which pins agent-device 0.21.6, and an unrelated dependency hoisted agent-device 0.20.8 ontonode_modules/.bin. Running that binary printedand killed the 0.21.6 daemon that owned the live e2e session. The next engine command replaced it back, so each stray invocation cost two daemon restarts plus every attached session.
resolveDaemonTakeoverReasontreated any version difference as grounds to replace the daemon. That is right for an upgrade (newer client meets an older daemon) and wrong for the reverse: an older binary must not kill the daemon a newer install started.Change
daemon-launch-spec.ts: a version mismatch now distinguishes direction. A daemon older than the client is replaced as before. A reachable daemon newer than the client throwsCOMMAND_FAILEDnaming both versions, the pid, andagent-device daemon stop --state-dir <dir>for a deliberate downgrade. An unreachable newer daemon is dead and replaced as before.daemon-client-lifecycle.ts: passes the state dir so the hint names the right daemon.host-kit/version:isNewerVersion, numeric-aware segment ordering. Enough to tell an upgrade from a downgrade; equal strings never reach it.Not in this PR: refusing to replace a same-version-mismatch daemon that has active sessions. That needs a sessions probe before the takeover decision and is a separate change.
Validation
GET /health, prints no takeover notice, and leaves the daemon metadata in place.main.unit-corefor the touched files,tsc -p tsconfig.json,tsc -b packages/host-kit,oxlint,oxfmt,check:layeringclean.