diff --git a/packages/host-kit/src/internal/version.test.ts b/packages/host-kit/src/internal/version.test.ts index 6e7e164f59..e46f17e3f3 100644 --- a/packages/host-kit/src/internal/version.test.ts +++ b/packages/host-kit/src/internal/version.test.ts @@ -5,7 +5,7 @@ import { afterEach, test, vi } from 'vitest'; import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { resolveAgentDeviceProjectRoot } from './project-root.ts'; -import { findProjectRoot, readVersion } from './version.ts'; +import { compareVersions, findProjectRoot, isNewerVersion, readVersion } from './version.ts'; afterEach(() => { vi.restoreAllMocks(); @@ -128,3 +128,32 @@ test('from this source tree, the project root is the agent-device manifest, not assert.equal(manifest.name, 'agent-device'); assert.equal(readVersion(), manifest.version); }); + +test('isNewerVersion orders releases numerically per segment', () => { + assert.equal(isNewerVersion('0.21.6', '0.20.8'), true); + assert.equal(isNewerVersion('0.21.12', '0.21.6'), true); + assert.equal(isNewerVersion('1.0.0', '0.21.12'), true); + assert.equal(isNewerVersion('0.20.8', '0.21.6'), false); + assert.equal(isNewerVersion('0.21.6', '0.21.6'), false); +}); + +test('isNewerVersion ranks a release above the prerelease of the same base', () => { + // main carries `-dev` between releases (scripts/release-mark-dev.mjs): a released client meeting + // a `-dev` daemon of the same base is the upgrade, and the reverse is the downgrade. + assert.equal(isNewerVersion('0.21.13', '0.21.13-dev'), true); + assert.equal(isNewerVersion('0.21.13-dev', '0.21.13'), false); + assert.equal(isNewerVersion('0.21.13-dev', '0.21.12'), true); + assert.equal(isNewerVersion('0.21.12', '0.21.13-dev'), false); + assert.equal(isNewerVersion('0.21.13-dev', '0.21.13-dev'), false); + assert.equal(isNewerVersion('0.21.13-rc.2', '0.21.13-rc.1'), true); + assert.equal(isNewerVersion('0.21.13-rc.10', '0.21.13-rc.9'), true); + assert.equal(isNewerVersion('0.21.13-beta', '0.21.13-alpha.1'), true); + assert.equal(isNewerVersion('0.21.13+build.2', '0.21.13+build.1'), false); +}); + +test('compareVersions reads malformed or prefixed versions conservatively', () => { + assert.equal(compareVersions('v0.21.13', '0.21.13'), 0); + assert.equal(compareVersions('garbage', '0.0.1'), -1); + assert.equal(compareVersions('garbage', '0.0.0'), 0); + assert.equal(compareVersions('1.2.3.4', '1.2.3'), -1, 'four segments is not a version'); +}); diff --git a/packages/host-kit/src/internal/version.ts b/packages/host-kit/src/internal/version.ts index bc10587fea..82d9c14823 100644 --- a/packages/host-kit/src/internal/version.ts +++ b/packages/host-kit/src/internal/version.ts @@ -31,3 +31,69 @@ export function findProjectRoot(): string { projectRootMemo.set('self', resolved); return resolved; } + +/** + * Whether `candidate` is a later release than `baseline` (see {@link compareVersions}). + */ +export function isNewerVersion(candidate: string, baseline: string): boolean { + return compareVersions(candidate, baseline) > 0; +} + +/** + * SemVer order for the versions this package publishes: numeric `major.minor.patch` first, then a + * release sorts after any prerelease of the same base (`0.21.13` > `0.21.13-dev`, the shape main + * carries between releases), and prerelease fields compare per dot-separated field, numerically + * when both are numbers and lexically otherwise. Build metadata is ignored. A string that is not a + * version at all reads as `0.0.0`, so a malformed version always compares as the oldest. + */ +export function compareVersions(left: string, right: string): number { + const a = parseVersion(left); + const b = parseVersion(right); + return compareRelease(a.release, b.release) || comparePrerelease(a.prerelease, b.prerelease); +} + +const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +type ParsedVersion = { release: [number, number, number]; prerelease: string[] }; + +function parseVersion(version: string): ParsedVersion { + const match = SEMVER.exec(version.trim()); + if (!match) return { release: [0, 0, 0], prerelease: [] }; + return { + release: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split('.') ?? [], + }; +} + +function compareRelease(a: ParsedVersion['release'], b: ParsedVersion['release']): number { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i]! > b[i]! ? 1 : -1; + } + return 0; +} + +/** A release (no prerelease) sorts after every prerelease of the same base. */ +function comparePrerelease(a: string[], b: string[]): number { + if (a.length === 0) return b.length === 0 ? 0 : 1; + if (b.length === 0) return -1; + const fields = Math.max(a.length, b.length); + for (let i = 0; i < fields; i += 1) { + const x = a[i]; + const y = b[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const order = comparePrereleaseField(x, y); + if (order !== 0) return order; + } + return 0; +} + +/** Numeric fields compare as numbers and sort below alphanumeric ones; the rest compare lexically. */ +function comparePrereleaseField(x: string, y: string): number { + if (x === y) return 0; + const xNumeric = /^\d+$/.test(x); + const yNumeric = /^\d+$/.test(y); + if (xNumeric && yNumeric) return Number(x) > Number(y) ? 1 : -1; + if (xNumeric !== yNumeric) return xNumeric ? -1 : 1; + return x > y ? 1 : -1; +} diff --git a/packages/host-kit/src/version.ts b/packages/host-kit/src/version.ts index 00d05cc677..2db3b9d7a0 100644 --- a/packages/host-kit/src/version.ts +++ b/packages/host-kit/src/version.ts @@ -1,2 +1,7 @@ -export { findProjectRoot, readVersion } from './internal/version.ts'; +export { + compareVersions, + findProjectRoot, + isNewerVersion, + readVersion, +} from './internal/version.ts'; export { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from './internal/project-root.ts'; diff --git a/src/__tests__/test-utils/daemon-http-fixture.ts b/src/__tests__/test-utils/daemon-http-fixture.ts new file mode 100644 index 0000000000..65dba9ab23 --- /dev/null +++ b/src/__tests__/test-utils/daemon-http-fixture.ts @@ -0,0 +1,74 @@ +import http from 'node:http'; +import { listenOnLoopback } from './loopback.ts'; + +// A loopback stand-in for a running daemon: answers `GET /health`, echoes `responseData` as the +// result of every `POST /rpc`, and records what it was asked, for the daemon-client tests that +// decide which daemon a command keeps. + +export type HttpDaemonFixture = { + server: http.Server; + port: number; + seenPaths: string[]; + rpcRequests: Record[]; +}; + +export async function startHttpDaemonFixture( + responseData: Record, +): Promise { + const seenPaths: string[] = []; + const rpcRequests: Record[] = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + seenPaths.push(`${req.method ?? 'GET'} ${url.pathname}`); + + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200); + res.end('ok'); + return; + } + + if (req.method === 'POST' && url.pathname === '/rpc') { + const chunks: Buffer[] = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => { + const rpcRequest = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record< + string, + any + >; + rpcRequests.push(rpcRequest); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: rpcRequest.id, + result: { ok: true, data: responseData }, + }), + ); + }); + return; + } + + res.writeHead(404); + res.end('not found'); + }); + const port = await listenOnLoopback(server); + return { server, port, seenPaths, rpcRequests }; +} + +/** Swaps `process.stderr.write` for a buffer until `restore`, so a test can read what was printed. */ +export function captureStderr(): { read: () => string; restore: () => void } { + const originalWrite = process.stderr.write.bind(process.stderr); + let captured = ''; + (process.stderr as { write: typeof process.stderr.write }).write = ((chunk: unknown) => { + captured += String(chunk); + return true; + }) as typeof process.stderr.write; + return { + read: () => captured, + restore: () => { + process.stderr.write = originalWrite; + }, + }; +} diff --git a/src/__tests__/update-check.test.ts b/src/__tests__/update-check.test.ts index 903bc31091..58e2baf6b7 100644 --- a/src/__tests__/update-check.test.ts +++ b/src/__tests__/update-check.test.ts @@ -87,6 +87,32 @@ test('notifier prints cached upgrade notice once for a newly discovered version' assert.equal(cache.prompted, true); }); +test('notifier treats the release as newer than the -dev build of the same base', () => { + // main carries `-dev` between releases; the shared SemVer comparator ranks the release above it, + // where numeric string collation ranked it below and never prompted. + const stateDir = makeTempStateDir(); + cleanupPaths.push(stateDir); + writeCache(stateDir, { + latestVersion: '0.12.0', + checkedAt: '2026-03-25T10:00:00.000Z', + }); + + let stderr = ''; + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write); + + maybeRunUpgradeNotifier({ + command: 'devices', + currentVersion: '0.12.0-dev', + stateDir, + flags: {}, + }); + + assert.match(stderr, /Update available: agent-device 0\.12\.0-dev -> 0\.12\.0/); +}); + test('notifier skips repeat prompts after the cached version was already shown', () => { const stateDir = makeTempStateDir(); cleanupPaths.push(stateDir); diff --git a/src/cli/update-check.ts b/src/cli/update-check.ts index e430e336a2..6a1e0a7039 100644 --- a/src/cli/update-check.ts +++ b/src/cli/update-check.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCmdDetached } from '@agent-device/host-kit/command'; +import { compareVersions } from '@agent-device/host-kit/version'; const PACKAGE_NAME = 'agent-device'; const UPDATE_CHECK_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000; @@ -168,10 +169,6 @@ function parseTimestamp(value: string | undefined): number | undefined { return Number.isNaN(parsed) ? undefined : parsed; } -function compareVersions(left: string, right: string): number { - return left.localeCompare(right, undefined, { numeric: true }); -} - export function readUpdateCheckWorkerArgs( argv: string[], ): { cachePath: string; currentVersion: string } | null { diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 810672ff6b..073f789a58 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -28,6 +28,11 @@ import { listenOnLoopback, supportsLoopbackBind, } from '../../__tests__/test-utils/loopback.ts'; +import { + captureStderr, + startHttpDaemonFixture, + type HttpDaemonFixture, +} from '../../__tests__/test-utils/daemon-http-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { runCmdDetachedMonitored, runCmdSync } from '@agent-device/host-kit/command'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; @@ -46,13 +51,6 @@ type DaemonInfoFixture = { processStartTime?: string; }; -type HttpDaemonFixture = { - server: http.Server; - port: number; - seenPaths: string[]; - rpcRequests: Record[]; -}; - const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored); const mockRunCmdSync = vi.mocked(runCmdSync); const mockSleep = vi.mocked(sleep); @@ -109,51 +107,6 @@ function writeDaemonLock( ); } -async function startHttpDaemonFixture( - responseData: Record, -): Promise { - const seenPaths: string[] = []; - const rpcRequests: Record[] = []; - const server = http.createServer((req, res) => { - const url = new URL(req.url || '/', 'http://127.0.0.1'); - seenPaths.push(`${req.method ?? 'GET'} ${url.pathname}`); - - if (req.method === 'GET' && url.pathname === '/health') { - res.writeHead(200); - res.end('ok'); - return; - } - - if (req.method === 'POST' && url.pathname === '/rpc') { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => { - const rpcRequest = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record< - string, - any - >; - rpcRequests.push(rpcRequest); - res.writeHead(200, { 'content-type': 'application/json' }); - res.end( - JSON.stringify({ - jsonrpc: '2.0', - id: rpcRequest.id, - result: { ok: true, data: responseData }, - }), - ); - }); - return; - } - - res.writeHead(404); - res.end('not found'); - }); - const port = await listenOnLoopback(server); - return { server, port, seenPaths, rpcRequests }; -} - /** Like `startHttpDaemonFixture`, but every RPC call returns `errorResult` as an `{ok:false}` result. */ async function startHttpDaemonErrorFixture( errorResult: Record, @@ -649,21 +602,6 @@ test('sendToDaemon replaces socket-only daemon metadata when HTTP transport is r } }); -function captureStderr(): { read: () => string; restore: () => void } { - const originalWrite = process.stderr.write.bind(process.stderr); - let captured = ''; - (process.stderr as { write: typeof process.stderr.write }).write = ((chunk: unknown) => { - captured += String(chunk); - return true; - }) as typeof process.stderr.write; - return { - read: () => captured, - restore: () => { - process.stderr.write = originalWrite; - }, - }; -} - test('sendRequest timeout cleanup uses resolved daemon paths instead of request flags', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); diff --git a/src/daemon-client/__tests__/daemon-client-newer-daemon.test.ts b/src/daemon-client/__tests__/daemon-client-newer-daemon.test.ts new file mode 100644 index 0000000000..4229f8cc79 --- /dev/null +++ b/src/daemon-client/__tests__/daemon-client-newer-daemon.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { afterEach, test, vi } from 'vitest'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ + ...(await importOriginal()), + runCmdDetached: vi.fn(), + runCmdDetachedMonitored: vi.fn(), + runCmdSync: vi.fn(() => ({ exitCode: 1, stdout: '', stderr: '' })), +})); + +import { resolveDaemonPaths } from '../../daemon-resolution.ts'; +import { sendToDaemon } from '../daemon-client.ts'; +import { closeLoopbackServer, supportsLoopbackBind } from '../../__tests__/test-utils/loopback.ts'; +import { + captureStderr, + startHttpDaemonFixture, +} from '../../__tests__/test-utils/daemon-http-fixture.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; + +// The daemon-version half of the takeover ladder (`resolveDaemonTakeover`): an older CLI +// hoisted onto PATH meets the daemon a newer install started, with live sessions attached. It must +// neither spawn a replacement nor kill the daemon. + +const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored); + +afterEach(() => { + mockRunCmdDetached.mockReset(); + vi.unstubAllEnvs(); +}); + +test('sendToDaemon refuses to replace a reachable daemon newer than the client', async (t) => { + if (!(await supportsLoopbackBind())) { + t.skip('loopback listeners are not permitted in this environment'); + return; + } + const stateDir = mkdtempForTestSync('agent-device-daemon-newer-refused-'); + const paths = resolveDaemonPaths(stateDir); + const newerDaemon = await startHttpDaemonFixture({ via: 'newer-daemon' }); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); + fs.mkdirSync(paths.baseDir, { recursive: true }); + fs.writeFileSync( + paths.infoPath, + `${JSON.stringify({ + token: 'local-secret', + pid: 999_999, + version: '999.0.0', + httpPort: newerDaemon.port, + transport: 'http', + })}\n`, + 'utf8', + ); + const stderrCapture = captureStderr(); + + try { + await assert.rejects( + () => + sendToDaemon({ + session: 'default', + command: 'newer-daemon-smoke', + positionals: [], + flags: { stateDir, daemonTransport: 'http' }, + meta: { requestId: 'req-newer-daemon' }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.match(error.message, /v999\.0\.0\) is newer than this client/); + assert.match(String(error.details?.hint), /agent-device daemon stop --state-dir /); + return true; + }, + ); + + assert.equal(mockRunCmdDetached.mock.calls.length, 0, 'no replacement daemon is spawned'); + assert.deepEqual(newerDaemon.seenPaths, ['GET /health']); + assert.equal(stderrCapture.read(), '', 'no takeover notice is printed'); + assert.ok(fs.existsSync(paths.infoPath), 'the newer daemon keeps its metadata'); + } finally { + stderrCapture.restore(); + await closeLoopbackServer(newerDaemon.server); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('sendToDaemon refuses a newer socket-only daemon when the client prefers http', async (t) => { + if (!(await supportsLoopbackBind())) { + t.skip('loopback listeners are not permitted in this environment'); + return; + } + const stateDir = mkdtempForTestSync('agent-device-daemon-newer-socket-only-'); + const paths = resolveDaemonPaths(stateDir); + const newerDaemon = await startHttpDaemonFixture({ via: 'newer-socket-daemon' }); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); + fs.mkdirSync(paths.baseDir, { recursive: true }); + fs.writeFileSync( + paths.infoPath, + `${JSON.stringify({ + token: 'local-secret', + pid: 999_999, + version: '999.0.0', + port: newerDaemon.port, + transport: 'socket', + })}\n`, + 'utf8', + ); + const stderrCapture = captureStderr(); + + try { + await assert.rejects( + () => + sendToDaemon({ + session: 'default', + command: 'newer-daemon-smoke', + positionals: [], + flags: { stateDir, daemonTransport: 'http' }, + meta: { requestId: 'req-newer-socket-daemon' }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.match(error.message, /v999\.0\.0\) is newer than this client/); + return true; + }, + ); + + assert.equal(mockRunCmdDetached.mock.calls.length, 0, 'no replacement daemon is spawned'); + assert.equal(stderrCapture.read(), '', 'no takeover notice is printed'); + assert.ok(fs.existsSync(paths.infoPath), 'the newer daemon keeps its metadata'); + } finally { + stderrCapture.restore(); + await closeLoopbackServer(newerDaemon.server); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon-client/__tests__/daemon-launch-spec.test.ts b/src/daemon-client/__tests__/daemon-launch-spec.test.ts index 0a9b7f587f..37af703064 100644 --- a/src/daemon-client/__tests__/daemon-launch-spec.test.ts +++ b/src/daemon-client/__tests__/daemon-launch-spec.test.ts @@ -5,7 +5,8 @@ import { afterEach, test, vi } from 'vitest'; import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { resolveDaemonLaunchSpec, - resolveDaemonTakeoverReason, + resolveDaemonTakeover, + type DaemonReachability, resolveLocalDaemonCodeIdentity, } from '../daemon-launch-spec.ts'; import { isSourceCheckoutProjectRoot, readVersion } from '@agent-device/host-kit/version'; @@ -100,6 +101,53 @@ test('a source client fingerprints the source entry through the stat-validated c } }); +test('a reachable daemon newer than the client is refused, not replaced', async () => { + assert.deepEqual( + await resolveDaemonTakeover(runningDaemon({ version: '999.0.0' }), reachable()), + { + kind: 'refuseNewer', + daemonVersion: '999.0.0', + clientVersion: readVersion(), + }, + ); +}); + +test('an unreachable newer daemon is replaced like any version mismatch', async () => { + assert.deepEqual( + await resolveDaemonTakeover(runningDaemon({ version: '999.0.0' }), unreachable()), + { + kind: 'replace', + reason: `version mismatch (client v${readVersion()})`, + }, + ); +}); + +test('a newer daemon alive only on a transport the client does not prefer is still refused', async () => { + assert.deepEqual( + await resolveDaemonTakeover(runningDaemon({ version: '999.0.0' }), onlyOnAnotherTransport()), + { kind: 'refuseNewer', daemonVersion: '999.0.0', clientVersion: readVersion() }, + ); +}); + +test('a same-version daemon the client transport cannot reach is replaced', async () => { + useClientTree(false); + + assert.deepEqual( + await resolveDaemonTakeover( + runningDaemon({ codeOrigin: 'installed' }), + onlyOnAnotherTransport(), + ), + { kind: 'replace', reason: 'unreachable' }, + ); +}); + +test('a reachable daemon older than the client is replaced', async () => { + assert.deepEqual(await resolveDaemonTakeover(runningDaemon({ version: '0.0.1' }), reachable()), { + kind: 'replace', + reason: `version mismatch (client v${readVersion()})`, + }); +}); + /** * Which daemon a command keeps. `daemon-client-lifecycle.test.ts` pins the same * decision end to end from a source checkout, which is what this test process runs @@ -110,6 +158,18 @@ function useClientTree(sourceCheckout: boolean): void { vi.mocked(isSourceCheckoutProjectRoot).mockReturnValue(sourceCheckout); } +function reachable(): DaemonReachability { + return { viaClientTransport: true, onAnyAdvertisedTransport: async () => true }; +} + +function unreachable(): DaemonReachability { + return { viaClientTransport: false, onAnyAdvertisedTransport: async () => false }; +} + +function onlyOnAnotherTransport(): DaemonReachability { + return { viaClientTransport: false, onAnyAdvertisedTransport: async () => true }; +} + function runningDaemon(info: { version?: string; codeOrigin?: 'installed' | 'checkout'; @@ -142,12 +202,12 @@ test('an installed client keeps an installed daemon whose code signature differs // whole of the identity either can offer, and the session on the daemon stands. useClientTree(false); - assert.equal( - await resolveDaemonTakeoverReason( + assert.deepEqual( + await resolveDaemonTakeover( runningDaemon({ codeOrigin: 'installed', codeSignature: 'some-other-install' }), - true, + reachable(), ), - undefined, + { kind: 'reuse' }, ); }); @@ -157,21 +217,24 @@ test('an installed client replaces a daemon that reports a source checkout (#245 // fingerprint of its own to notice. useClientTree(false); - assert.equal( - await resolveDaemonTakeoverReason( + assert.deepEqual( + await resolveDaemonTakeover( runningDaemon({ codeOrigin: 'checkout', codeSignature: 'edited-checkout' }), - true, + reachable(), ), - 'code origin mismatch (daemon checkout, client installed)', + { kind: 'replace', reason: 'code origin mismatch (daemon checkout, client installed)' }, ); }); test('an installed client replaces a daemon that predates the code origin field (#2458)', async () => { useClientTree(false); - assert.equal( - await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: 'any' }), true), - 'code origin mismatch (daemon unreported, client installed)', + assert.deepEqual( + await resolveDaemonTakeover(runningDaemon({ codeSignature: 'any' }), reachable()), + { + kind: 'replace', + reason: 'code origin mismatch (daemon unreported, client installed)', + }, ); }); @@ -180,12 +243,12 @@ test('a source checkout keeps a daemon that reports the same code signature', as const ownCodeSignature = await ownCheckoutCodeSignature(); assert.ok(ownCodeSignature); - assert.equal( - await resolveDaemonTakeoverReason( + assert.deepEqual( + await resolveDaemonTakeover( runningDaemon({ codeOrigin: 'checkout', codeSignature: ownCodeSignature }), - true, + reachable(), ), - undefined, + { kind: 'reuse' }, ); }); @@ -194,12 +257,15 @@ test('a source checkout replaces a daemon whose code signature differs', async ( // notice a daemon serving code its client no longer has. useClientTree(true); - assert.equal( - await resolveDaemonTakeoverReason( + assert.deepEqual( + await resolveDaemonTakeover( runningDaemon({ codeOrigin: 'checkout', codeSignature: 'an-older-build' }), - true, + reachable(), ), - 'code-signature mismatch', + { + kind: 'replace', + reason: 'code-signature mismatch', + }, ); }); @@ -208,12 +274,15 @@ test('a source checkout replaces a daemon that reports an installed package', as // nor disprove what an install holds, and must not run it on faith. useClientTree(true); - assert.equal( - await resolveDaemonTakeoverReason( + assert.deepEqual( + await resolveDaemonTakeover( runningDaemon({ codeOrigin: 'installed', codeSignature: 'the-published-artifact' }), - true, + reachable(), ), - 'code origin mismatch (daemon installed, client checkout)', + { + kind: 'replace', + reason: 'code origin mismatch (daemon installed, client checkout)', + }, ); }); @@ -222,21 +291,21 @@ test('a source checkout judges a daemon that predates the code origin field by i // comparison they were reused under until now. useClientTree(true); - assert.equal( - await resolveDaemonTakeoverReason(runningDaemon({}), true), - 'code-signature mismatch', - ); + assert.deepEqual(await resolveDaemonTakeover(runningDaemon({}), reachable()), { + kind: 'replace', + reason: 'code-signature mismatch', + }); }); test('a mismatched version replaces the daemon whichever tree the client runs from', async () => { - const expected = `version mismatch (client v${readVersion()})`; + const expected = { kind: 'replace', reason: `version mismatch (client v${readVersion()})` }; for (const sourceCheckout of [false, true]) { useClientTree(sourceCheckout); - assert.equal( - await resolveDaemonTakeoverReason( + assert.deepEqual( + await resolveDaemonTakeover( runningDaemon({ version: '0.0.0-mismatch', codeOrigin: 'installed', codeSignature: 'any' }), - true, + reachable(), ), expected, ); @@ -246,13 +315,18 @@ test('a mismatched version replaces the daemon whichever tree the client runs fr test('a reachable daemon of a matching identity survives, an unreachable one does not', async () => { useClientTree(false); - assert.equal( - await resolveDaemonTakeoverReason(runningDaemon({ codeOrigin: 'installed' }), true), - undefined, + assert.deepEqual( + await resolveDaemonTakeover(runningDaemon({ codeOrigin: 'installed' }), reachable()), + { + kind: 'reuse', + }, ); - assert.equal( - await resolveDaemonTakeoverReason(runningDaemon({ codeOrigin: 'installed' }), false), - 'unreachable', + assert.deepEqual( + await resolveDaemonTakeover(runningDaemon({ codeOrigin: 'installed' }), unreachable()), + { + kind: 'replace', + reason: 'unreachable', + }, ); }); diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index d8d194e1c3..21c728798b 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -19,7 +19,11 @@ import { type DaemonServerMode, type DaemonTransportPreference, } from '../daemon-resolution.ts'; -import { resolveDaemonLaunchSpec, resolveDaemonTakeoverReason } from './daemon-launch-spec.ts'; +import { + resolveDaemonLaunchSpec, + resolveDaemonTakeover, + type DaemonTakeoverDecision, +} from './daemon-launch-spec.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { @@ -181,11 +185,18 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise< const existing = readDaemonInfo(settings.paths.infoPath); if (!existing) return null; - const existingReachable = await canConnectReusableDaemon(existing, settings.transportPreference); - const takeoverReason = await resolveDaemonTakeoverReason(existing, existingReachable); - if (!takeoverReason) return existing; + const viaClientTransport = await canConnectReusableDaemon(existing, settings.transportPreference); + const decision = await resolveDaemonTakeover(existing, { + viaClientTransport, + onAnyAdvertisedTransport: async () => + viaClientTransport || (await canConnectReusableDaemon(existing, 'auto')), + }); + if (decision.kind === 'reuse') return existing; + if (decision.kind === 'refuseNewer') { + throw newerDaemonRefusedError(existing, decision, settings.paths.baseDir); + } - emitDaemonTakeoverNotice(existing, takeoverReason, settings.paths.baseDir); + emitDaemonTakeoverNotice(existing, decision.reason, settings.paths.baseDir); await stopDaemonProcessForTakeover(existing); removeDaemonInfo(settings.paths.infoPath); return null; @@ -212,6 +223,24 @@ function isDaemonTransportUnavailableError(error: unknown): boolean { ); } +function newerDaemonRefusedError( + info: DaemonInfo, + decision: Extract, + stateDir: string, +): AppError { + const { daemonVersion, clientVersion } = decision; + return new AppError( + 'COMMAND_FAILED', + `Daemon (pid ${info.pid}, v${daemonVersion}) is newer than this client (v${clientVersion}); refusing to replace it.`, + { + daemonPid: info.pid, + daemonVersion, + clientVersion, + hint: `Use the agent-device v${daemonVersion} CLI that started it, or stop it deliberately: agent-device daemon stop --state-dir ${shellQuoteIfNeeded(stateDir)}`, + }, + ); +} + function emitDaemonTakeoverNotice(info: DaemonInfo, reason: string, stateDir: string): void { try { const identity = info.version ? `pid ${info.pid}, v${info.version}` : `pid ${info.pid}`; diff --git a/src/daemon-client/daemon-launch-spec.ts b/src/daemon-client/daemon-launch-spec.ts index 645bc89636..d0cd527bb3 100644 --- a/src/daemon-client/daemon-launch-spec.ts +++ b/src/daemon-client/daemon-launch-spec.ts @@ -1,7 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { DAEMON_SOURCE_ENTRY, findProjectRoot, readVersion } from '@agent-device/host-kit/version'; +import { + DAEMON_SOURCE_ENTRY, + findProjectRoot, + isNewerVersion, + readVersion, +} from '@agent-device/host-kit/version'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; import { @@ -102,25 +107,53 @@ export async function resolveLocalDaemonCodeIdentity(): Promise Promise; +}; + +/** + * One ladder decides reuse, replace, or refuse, so a daemon can never be reused and announced as + * replaced, or replaced without a reason to print. The version answers first because it is cheap + * and decides alone for the common pair of installed trees; the code identity + * (`resolveCodeIdentityMismatch`) answers next and unreachability last. * - * One ladder answers both questions, so a daemon can never be reused and announced as - * replaced, or replaced without a reason to print. The version answers first because - * it is cheap and decides alone for the common pair of installed trees; the code - * identity (`resolveCodeIdentityMismatch`) answers next and unreachability last. + * A live daemon NEWER than this client is neither reused nor replaced: it was started by a + * newer install that may still own live sessions, and an older binary that a package manager + * hoisted onto PATH must not kill it under that install. An unreachable newer daemon is dead and + * replaced like any version mismatch. */ -export async function resolveDaemonTakeoverReason( +export async function resolveDaemonTakeover( info: DaemonInfo, - reachable: boolean, -): Promise { - if (info.version !== readVersion()) return `version mismatch (client v${readVersion()})`; + reachability: DaemonReachability, +): Promise { + const clientVersion = readVersion(); + if (info.version !== clientVersion) { + if ( + info.version && + isNewerVersion(info.version, clientVersion) && + (await reachability.onAnyAdvertisedTransport()) + ) { + return { kind: 'refuseNewer', daemonVersion: info.version, clientVersion }; + } + return { kind: 'replace', reason: `version mismatch (client v${clientVersion})` }; + } const localIdentity = await resolveLocalDaemonCodeIdentity(); const codeMismatch = resolveCodeIdentityMismatch(localIdentity, info); - if (codeMismatch) return codeMismatch; - if (!reachable) return 'unreachable'; - return undefined; + if (codeMismatch) return { kind: 'replace', reason: codeMismatch }; + if (!reachability.viaClientTransport) return { kind: 'replace', reason: 'unreachable' }; + return { kind: 'reuse' }; } /**