From 45d2cb1ed3e511ee1ba26e75a52dfd2602556c7d Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 10 Jun 2026 22:33:10 +0800 Subject: [PATCH] feat(cli): detect build-mismatched daemon and add --restart to kimi web/daemon The daemon lock now records the host CLI version and entry path. When kimi web / kimi daemon finds a healthy daemon started by a different build (e.g. an older pkg.pr.new install), it warns that the running daemon lags the invoked CLI instead of silently reusing it; the new --restart flag stops the managed daemon and starts a fresh one with the current build. A daemon whose lock does not match the requested port (foreign process on the port) is never stopped. --- apps/kimi-code/src/cli/sub/daemon.ts | 163 +++++++++++++++---- apps/kimi-code/src/cli/sub/web.ts | 8 + apps/kimi-code/test/cli/daemon.test.ts | 212 +++++++++++++++++++++++++ apps/kimi-code/test/cli/web.test.ts | 13 ++ packages/daemon/src/lock.ts | 21 ++- packages/daemon/src/start.ts | 9 +- 6 files changed, 391 insertions(+), 35 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/daemon.ts b/apps/kimi-code/src/cli/sub/daemon.ts index 60dfbfd04..c690527b3 100644 --- a/apps/kimi-code/src/cli/sub/daemon.ts +++ b/apps/kimi-code/src/cli/sub/daemon.ts @@ -43,6 +43,7 @@ export interface DaemonCliOptions { logLevel?: string; debugEndpoints?: boolean; foreground?: boolean; + restart?: boolean; } export interface ParsedDaemonOptions { @@ -50,6 +51,8 @@ export interface ParsedDaemonOptions { port: number; logLevel: DaemonLogLevel; debugEndpoints: boolean; + /** Stop a running managed daemon and start a fresh one with this CLI build. */ + restart: boolean; } export type EnsureDaemonResult = @@ -71,15 +74,20 @@ export type DaemonStartupReporter = (message: string) => void; export interface EnsureDaemonRunningDeps { isDaemonHealthy(origin: string, timeoutMs: number): Promise; waitForDaemonHealthy(origin: string, timeoutMs: number): Promise; - readLiveDaemonLock(): - | { - pid: number; - started_at: string; - port: number; - } - | undefined; + readLiveDaemonLock(): DaemonLockContents | undefined; startDaemonBackground(options: ParsedDaemonOptions): { pid: number; logPath: string }; daemonLogPath(): string; + /** Identity of the CLI build that is executing right now. */ + currentBuild(): BuildIdentity; + /** SIGTERM the daemon and wait for the pid to exit. Resolves false on timeout/EPERM. */ + stopDaemon(pid: number): Promise; +} + +/** What identifies "the same build": the host version PLUS the entry path — + two pkg.pr.new installs of the same base version live at different paths. */ +export interface BuildIdentity { + version: string; + entry: string | undefined; } export interface DaemonCommandDeps { @@ -116,6 +124,11 @@ export function registerDaemonCommand(parent: Command): void { 'Run the daemon in the foreground instead of starting a background daemon.', false, ) + .option( + '--restart', + 'Stop the running daemon (if this CLI manages it) and start a fresh one with this build.', + false, + ) .option( '--debug-endpoints', 'Mount /api/v1/debug/* routes for test introspection (per-session shadow + dispatch log). OFF by default; production callers leave this unset.', @@ -174,6 +187,7 @@ export function parseDaemonOptions(opts: DaemonCliOptions): ParsedDaemonOptions port: parsePort(opts.port, '--port', DEFAULT_DAEMON_PORT), logLevel: parseLogLevel(opts.logLevel), debugEndpoints: opts.debugEndpoints === true, + restart: opts.restart === true, }; } @@ -205,36 +219,75 @@ export async function ensureDaemonRunning( report(`checking requested daemon at ${origin}`); if (await deps.isDaemonHealthy(origin, 1000)) { const lock = deps.readLiveDaemonLock(); - report(`requested daemon is healthy; using ${origin}`); - return { + // Only a lock recorded for THIS port describes the daemon we just probed — + // a foreign healthy process on the port (or a daemon on another port + // holding the lock) is not ours to manage, so use it as-is. + const ownedLock = lock !== undefined && lock.port === options.port ? lock : undefined; + const reuse = (): EnsureDaemonResult => ({ status: 'already-running', origin, - pid: lock?.pid, + pid: ownedLock?.pid, logPath: deps.daemonLogPath(), - }; - } - - report('requested daemon is not healthy'); - report(`checking daemon lock at ${DEFAULT_LOCK_PATH}`); - const lock = deps.readLiveDaemonLock(); - if (lock !== undefined) { - report( - `found live daemon lock (pid ${lock.pid}, port ${lock.port}, started ${lock.started_at})`, - ); - const lockOrigin = daemonOrigin(options.host, lock.port); - report(`checking locked daemon at ${lockOrigin}`); - if (await deps.waitForDaemonHealthy(lockOrigin, 5000)) { - report(`locked daemon is healthy; reusing ${lockOrigin}`); - return { - status: 'already-running', - origin: lockOrigin, - pid: lock.pid, - logPath: deps.daemonLogPath(), - }; + }); + if (ownedLock === undefined) { + if (options.restart) { + report(`--restart: daemon at ${origin} is not managed by this CLI (no matching lock); using it as-is`); + } + report(`requested daemon is healthy; using ${origin}`); + return reuse(); } - report(`locked daemon did not become healthy at ${lockOrigin}`); + if (!options.restart) { + // A daemon from another build keeps serving its OWN bundled web UI/API — + // surface the mismatch so the user knows the running daemon lags the CLI + // they just invoked, but never replace it without an explicit --restart. + if (!lockMatchesBuild(ownedLock, deps.currentBuild())) { + report(describeBuildMismatch(ownedLock, deps.currentBuild())); + } + report(`requested daemon is healthy; using ${origin}`); + return reuse(); + } + report(`--restart: stopping daemon (pid ${ownedLock.pid})`); + if (!(await deps.stopDaemon(ownedLock.pid))) { + report(`could not stop daemon (pid ${ownedLock.pid}); using ${origin} as-is`); + return reuse(); + } + report(`stopped daemon (pid ${ownedLock.pid})`); } else { - report('no live daemon lock found'); + report('requested daemon is not healthy'); + report(`checking daemon lock at ${DEFAULT_LOCK_PATH}`); + const lock = deps.readLiveDaemonLock(); + if (lock !== undefined) { + report( + `found live daemon lock (pid ${lock.pid}, port ${lock.port}, started ${lock.started_at})`, + ); + const lockOrigin = daemonOrigin(options.host, lock.port); + report(`checking locked daemon at ${lockOrigin}`); + if (await deps.waitForDaemonHealthy(lockOrigin, 5000)) { + const reuseLocked = (): EnsureDaemonResult => ({ + status: 'already-running', + origin: lockOrigin, + pid: lock.pid, + logPath: deps.daemonLogPath(), + }); + if (!options.restart) { + if (!lockMatchesBuild(lock, deps.currentBuild())) { + report(describeBuildMismatch(lock, deps.currentBuild())); + } + report(`locked daemon is healthy; reusing ${lockOrigin}`); + return reuseLocked(); + } + report(`--restart: stopping daemon (pid ${lock.pid})`); + if (!(await deps.stopDaemon(lock.pid))) { + report(`could not stop daemon (pid ${lock.pid}); using ${lockOrigin} as-is`); + return reuseLocked(); + } + report(`stopped daemon (pid ${lock.pid})`); + } else { + report(`locked daemon did not become healthy at ${lockOrigin}`); + } + } else { + report('no live daemon lock found'); + } } report(`starting daemon in background at ${origin}`); @@ -368,10 +421,52 @@ async function isDaemonHealthy(origin: string, timeoutMs: number): Promise { + try { + process.kill(pid, 'SIGTERM'); + } catch (error) { + // ESRCH: already gone — that's a successful stop. EPERM etc.: not ours to kill. + return (error as NodeJS.ErrnoException).code === 'ESRCH'; + } + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (!pidAlive(pid)) return true; + await new Promise((resolve) => { + setTimeout(resolve, 150); + }); + } + return false; } function readLiveDaemonLock(): DaemonLockContents | undefined { @@ -433,4 +528,6 @@ const DEFAULT_ENSURE_DAEMON_RUNNING_DEPS: EnsureDaemonRunningDeps = { readLiveDaemonLock, startDaemonBackground, daemonLogPath, + currentBuild, + stopDaemon: stopDaemonProcess, }; diff --git a/apps/kimi-code/src/cli/sub/web.ts b/apps/kimi-code/src/cli/sub/web.ts index dd2b12a77..369c62ed4 100644 --- a/apps/kimi-code/src/cli/sub/web.ts +++ b/apps/kimi-code/src/cli/sub/web.ts @@ -25,6 +25,7 @@ export interface WebCliOptions { port?: string; daemonHost?: string; open?: boolean; + restart?: boolean; } export interface WebCommandDeps { @@ -33,6 +34,7 @@ export interface WebCommandDeps { port: number; logLevel: 'info'; debugEndpoints: false; + restart: boolean; }, report?: DaemonStartupReporter): Promise; ensureDaemonWebReady(origin: string): Promise; openUrl(url: string): void; @@ -56,6 +58,11 @@ export function registerWebCommand(parent: Command): void { '--daemon-host ', `Daemon URL to open instead of starting the local daemon (default ${DEFAULT_DAEMON_ORIGIN})`, ) + .option( + '--restart', + 'Stop the running daemon (if this CLI manages it) and start a fresh one with this build.', + false, + ) .option('--no-open', 'Do not open the web UI in the default browser.') .action(async (opts: WebCliOptions) => { try { @@ -83,6 +90,7 @@ export async function handleWebCommand( port, logLevel: 'info', debugEndpoints: false, + restart: opts.restart === true, }, (message) => { deps.stdout.write(`Daemon startup: ${message}\n`); diff --git a/apps/kimi-code/test/cli/daemon.test.ts b/apps/kimi-code/test/cli/daemon.test.ts index fbf6bc304..f7f6c82bd 100644 --- a/apps/kimi-code/test/cli/daemon.test.ts +++ b/apps/kimi-code/test/cli/daemon.test.ts @@ -58,6 +58,7 @@ describe('kimi daemon', () => { port: DEFAULT_DAEMON_PORT, logLevel: 'info', debugEndpoints: false, + restart: false, }, expect.any(Function), ); @@ -85,6 +86,7 @@ describe('kimi daemon', () => { port: DEFAULT_DAEMON_PORT, logLevel: 'info', debugEndpoints: false, + restart: false, }, expect.any(Function), ); @@ -105,6 +107,7 @@ describe('kimi daemon', () => { port: 7879, logLevel: 'debug', debugEndpoints: false, + restart: false, }); expect(stdout.join('')).toBe(''); expect(stderr.join('')).toBe(''); @@ -116,6 +119,7 @@ describe('kimi daemon', () => { port: DEFAULT_DAEMON_PORT, logLevel: 'info' as const, debugEndpoints: false, + restart: false, }; const deps: EnsureDaemonRunningDeps = { isDaemonHealthy: vi.fn(async () => false), @@ -130,6 +134,8 @@ describe('kimi daemon', () => { logPath: '/tmp/kimi-daemon.log', })), daemonLogPath: vi.fn(() => '/tmp/kimi-daemon.log'), + currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/cli/main.mjs' })), + stopDaemon: vi.fn(async () => true), }; await expect(ensureDaemonRunning(parsedOptions, deps)).rejects.toThrow( @@ -155,6 +161,7 @@ describe('kimi daemon', () => { port: 7999, logLevel: 'info' as const, debugEndpoints: false, + restart: false, }; const status: string[] = []; const deps: EnsureDaemonRunningDeps = { @@ -164,12 +171,16 @@ describe('kimi daemon', () => { pid: 4321, started_at: '2026-06-10T00:00:00.000Z', port: 7880, + host_version: '1.0.0', + entry: '/cli/main.mjs', })), startDaemonBackground: vi.fn(() => ({ pid: 9876, logPath: '/tmp/kimi-daemon.log', })), daemonLogPath: vi.fn(() => '/tmp/kimi-daemon.log'), + currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/cli/main.mjs' })), + stopDaemon: vi.fn(async () => true), }; await expect( @@ -190,4 +201,205 @@ describe('kimi daemon', () => { ]); expect(deps.startDaemonBackground).not.toHaveBeenCalled(); }); + + function makeEnsureDeps(overrides: Partial = {}): EnsureDaemonRunningDeps { + return { + isDaemonHealthy: vi.fn(async () => true), + waitForDaemonHealthy: vi.fn(async () => true), + readLiveDaemonLock: vi.fn(() => ({ + pid: 4321, + started_at: '2026-06-10T00:00:00.000Z', + port: DEFAULT_DAEMON_PORT, + host_version: '1.0.0', + entry: '/old/main.mjs', + })), + startDaemonBackground: vi.fn(() => ({ pid: 9876, logPath: '/tmp/kimi-daemon.log' })), + daemonLogPath: vi.fn(() => '/tmp/kimi-daemon.log'), + currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/old/main.mjs' })), + stopDaemon: vi.fn(async () => true), + ...overrides, + }; + } + + const parsedDefaults = { + host: DEFAULT_DAEMON_HOST, + port: DEFAULT_DAEMON_PORT, + logLevel: 'info' as const, + debugEndpoints: false, + restart: false, + }; + + it('reuses a healthy daemon that was started by the same build', async () => { + const deps = makeEnsureDeps(); + + await expect(ensureDaemonRunning(parsedDefaults, deps)).resolves.toMatchObject({ + status: 'already-running', + origin: DEFAULT_DAEMON_ORIGIN, + pid: 4321, + }); + expect(deps.stopDaemon).not.toHaveBeenCalled(); + expect(deps.startDaemonBackground).not.toHaveBeenCalled(); + }); + + it('warns but reuses a build-mismatched daemon without --restart', async () => { + const status: string[] = []; + const deps = makeEnsureDeps({ + currentBuild: vi.fn(() => ({ version: '2.0.0', entry: '/new/main.mjs' })), + }); + + await expect( + ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)), + ).resolves.toMatchObject({ + status: 'already-running', + origin: DEFAULT_DAEMON_ORIGIN, + pid: 4321, + }); + expect(deps.stopDaemon).not.toHaveBeenCalled(); + expect(deps.startDaemonBackground).not.toHaveBeenCalled(); + expect(status).toContain( + 'running daemon is a different build (running 1.0.0, current 2.0.0); rerun with --restart to replace it', + ); + }); + + it('warns about a different install of the same version without --restart', async () => { + const status: string[] = []; + const deps = makeEnsureDeps({ + currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/new/main.mjs' })), + }); + + await expect( + ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)), + ).resolves.toMatchObject({ status: 'already-running' }); + expect(deps.stopDaemon).not.toHaveBeenCalled(); + expect(status).toContain( + 'running daemon is a different install of 1.0.0 (/old/main.mjs); rerun with --restart to replace it', + ); + }); + + it('warns about a lock without build identity (older daemon) without --restart', async () => { + const status: string[] = []; + const deps = makeEnsureDeps({ + readLiveDaemonLock: vi.fn(() => ({ + pid: 4321, + started_at: '2026-06-10T00:00:00.000Z', + port: DEFAULT_DAEMON_PORT, + })), + currentBuild: vi.fn(() => ({ version: '2.0.0', entry: '/new/main.mjs' })), + }); + + await expect( + ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)), + ).resolves.toMatchObject({ status: 'already-running' }); + expect(deps.stopDaemon).not.toHaveBeenCalled(); + expect(status).toContain( + 'running daemon is a different build (running unknown build, current 2.0.0); rerun with --restart to replace it', + ); + }); + + it('stops the running daemon and starts fresh with --restart (even on the same build)', async () => { + const status: string[] = []; + const deps = makeEnsureDeps(); + const parsed = { ...parsedDefaults, restart: true }; + + await expect( + ensureDaemonRunning(parsed, deps, (m) => status.push(m)), + ).resolves.toMatchObject({ status: 'started', origin: DEFAULT_DAEMON_ORIGIN, pid: 9876 }); + + expect(deps.stopDaemon).toHaveBeenCalledWith(4321); + expect(deps.startDaemonBackground).toHaveBeenCalledWith(parsed); + expect(status).toContain('--restart: stopping daemon (pid 4321)'); + expect(status).toContain('stopped daemon (pid 4321)'); + }); + + it('keeps using the running daemon when --restart cannot stop it', async () => { + const status: string[] = []; + const deps = makeEnsureDeps({ + stopDaemon: vi.fn(async () => false), + }); + + await expect( + ensureDaemonRunning({ ...parsedDefaults, restart: true }, deps, (m) => status.push(m)), + ).resolves.toMatchObject({ + status: 'already-running', + origin: DEFAULT_DAEMON_ORIGIN, + pid: 4321, + }); + expect(deps.startDaemonBackground).not.toHaveBeenCalled(); + expect(status).toContain( + `could not stop daemon (pid 4321); using ${DEFAULT_DAEMON_ORIGIN} as-is`, + ); + }); + + it('never stops a daemon whose lock does not match the requested port', async () => { + // e.g. a stub daemon occupying the port while the lock belongs to a daemon + // on another port — --restart must not SIGTERM the unrelated lock pid. + const deps = makeEnsureDeps({ + readLiveDaemonLock: vi.fn(() => ({ + pid: 4321, + started_at: '2026-06-10T00:00:00.000Z', + port: 9999, + host_version: '1.0.0', + entry: '/old/main.mjs', + })), + }); + + await expect( + ensureDaemonRunning({ ...parsedDefaults, restart: true }, deps), + ).resolves.toMatchObject({ + status: 'already-running', + origin: DEFAULT_DAEMON_ORIGIN, + pid: undefined, + }); + expect(deps.stopDaemon).not.toHaveBeenCalled(); + }); + + it('restarts a daemon found via the lock on another port with --restart', async () => { + const deps = makeEnsureDeps({ + isDaemonHealthy: vi.fn(async () => false), + waitForDaemonHealthy: vi.fn(async () => true), + readLiveDaemonLock: vi.fn(() => ({ + pid: 4321, + started_at: '2026-06-10T00:00:00.000Z', + port: 9999, + host_version: '1.0.0', + entry: '/old/main.mjs', + })), + }); + const parsed = { ...parsedDefaults, restart: true }; + + await expect(ensureDaemonRunning(parsed, deps)).resolves.toMatchObject({ + status: 'started', + origin: DEFAULT_DAEMON_ORIGIN, + }); + expect(deps.stopDaemon).toHaveBeenCalledWith(4321); + expect(deps.startDaemonBackground).toHaveBeenCalledWith(parsed); + }); + + it('warns but reuses a build-mismatched daemon on another port without --restart', async () => { + const status: string[] = []; + const deps = makeEnsureDeps({ + isDaemonHealthy: vi.fn(async () => false), + waitForDaemonHealthy: vi.fn(async () => true), + readLiveDaemonLock: vi.fn(() => ({ + pid: 4321, + started_at: '2026-06-10T00:00:00.000Z', + port: 9999, + host_version: '1.0.0', + entry: '/old/main.mjs', + })), + currentBuild: vi.fn(() => ({ version: '2.0.0', entry: '/new/main.mjs' })), + }); + + await expect( + ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)), + ).resolves.toMatchObject({ + status: 'already-running', + origin: 'http://127.0.0.1:9999', + pid: 4321, + }); + expect(deps.stopDaemon).not.toHaveBeenCalled(); + expect(status).toContain( + 'running daemon is a different build (running 1.0.0, current 2.0.0); rerun with --restart to replace it', + ); + }); }); diff --git a/apps/kimi-code/test/cli/web.test.ts b/apps/kimi-code/test/cli/web.test.ts index e71786c0c..0bfae72ed 100644 --- a/apps/kimi-code/test/cli/web.test.ts +++ b/apps/kimi-code/test/cli/web.test.ts @@ -58,6 +58,7 @@ describe('kimi web', () => { port: DEFAULT_DAEMON_PORT, logLevel: 'info', debugEndpoints: false, + restart: false, }, expect.any(Function), ); @@ -94,6 +95,7 @@ describe('kimi web', () => { port: 8899, logLevel: 'info', debugEndpoints: false, + restart: false, }, expect.any(Function), ); @@ -101,6 +103,17 @@ describe('kimi web', () => { expect(deps.openUrl).toHaveBeenCalledWith('http://127.0.0.1:8899'); }); + it('forwards --restart to the daemon resolver', async () => { + const { deps } = makeDeps(); + + await handleWebCommand({ restart: true }, deps); + + expect(deps.ensureDaemonRunning).toHaveBeenCalledWith( + expect.objectContaining({ restart: true }), + expect.any(Function), + ); + }); + it('prints daemon startup trace from the local daemon resolver', async () => { const { deps, stdout } = makeDeps({ ensureDaemonRunning: vi.fn(async (_options, report) => { diff --git a/packages/daemon/src/lock.ts b/packages/daemon/src/lock.ts index a0efafd29..ab37fdc6d 100644 --- a/packages/daemon/src/lock.ts +++ b/packages/daemon/src/lock.ts @@ -45,6 +45,15 @@ export interface LockContents { pid: number; started_at: string; port: number; + /** Host CLI version that started this daemon (e.g. kimi-code package version). + Lets `kimi web`/`kimi daemon` detect a build-mismatched daemon and restart + it instead of silently serving stale code. Absent in locks written by + older builds. */ + host_version?: string; + /** Absolute path of the CLI entry that spawned the daemon. Distinguishes two + installs that share a version string (e.g. two pkg.pr.new builds of the + same base version living in different npx cache dirs). */ + entry?: string; } export interface AcquireLockOptions { @@ -52,6 +61,10 @@ export interface AcquireLockOptions { lockPath?: string; /** Port the daemon will bind to. Recorded in the lock file for diagnostics. */ port: number; + /** Host CLI version, recorded as `host_version` for build-mismatch detection. */ + hostVersion?: string; + /** CLI entry path that spawned this daemon, recorded as `entry`. */ + entry?: string; /** Override `new Date().toISOString()` — used in tests for deterministic output. */ nowIso?: string; /** @@ -156,7 +169,13 @@ export function acquireLock(opts: AcquireLockOptions): AcquireLockResult { const lockPath = opts.lockPath ?? DEFAULT_LOCK_PATH; const pid = opts.pid ?? process.pid; const startedAt = opts.nowIso ?? new Date().toISOString(); - const contents: LockContents = { pid, started_at: startedAt, port: opts.port }; + const contents: LockContents = { + pid, + started_at: startedAt, + port: opts.port, + ...(opts.hostVersion !== undefined ? { host_version: opts.hostVersion } : {}), + ...(opts.entry !== undefined ? { entry: opts.entry } : {}), + }; mkdirSync(dirname(lockPath), { recursive: true }); diff --git a/packages/daemon/src/start.ts b/packages/daemon/src/start.ts index a165b51d7..9e1a47c5e 100644 --- a/packages/daemon/src/start.ts +++ b/packages/daemon/src/start.ts @@ -102,7 +102,14 @@ export async function startDaemon(opts: DaemonStartOptions): Promise