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.
This commit is contained in:
qer 2026-06-10 22:33:10 +08:00 committed by haozhe.yang
parent 7448a225a7
commit 45d2cb1ed3
6 changed files with 391 additions and 35 deletions

View file

@ -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<boolean>;
waitForDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean>;
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<boolean>;
}
/** 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<boole
}
}
interface DaemonLockContents {
export interface DaemonLockContents {
pid: number;
started_at: string;
port: number;
/** Host CLI version that started the daemon (absent in locks from older builds). */
host_version?: string;
/** CLI entry path that spawned the daemon (absent in locks from older builds). */
entry?: string;
}
/** True when the locked daemon was started by the SAME build as this CLI.
Locks from older builds lack the identity fields mismatch restart. */
function lockMatchesBuild(lock: DaemonLockContents, build: BuildIdentity): boolean {
return lock.host_version === build.version && lock.entry === build.entry;
}
function describeBuildMismatch(lock: DaemonLockContents, build: BuildIdentity): string {
const running = lock.host_version ?? 'unknown build';
if (running !== build.version) {
return `running daemon is a different build (running ${running}, current ${build.version}); rerun with --restart to replace it`;
}
return `running daemon is a different install of ${running} (${lock.entry ?? 'unknown entry'}); rerun with --restart to replace it`;
}
function currentBuild(): BuildIdentity {
return { version: getVersion(), entry: process.argv[1] };
}
/** SIGTERM the daemon and wait (5s) for the pid to exit. The daemon's signal
handler closes the server and releases the lock file, so a fresh background
start can take over cleanly. */
async function stopDaemonProcess(pid: number): Promise<boolean> {
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,
};

View file

@ -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<EnsureDaemonResult>;
ensureDaemonWebReady(origin: string): Promise<void>;
openUrl(url: string): void;
@ -56,6 +58,11 @@ export function registerWebCommand(parent: Command): void {
'--daemon-host <url>',
`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`);

View file

@ -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> = {}): 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',
);
});
});

View file

@ -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) => {

View file

@ -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 });

View file

@ -102,7 +102,14 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<RunningDaem
const pinoLogger: DaemonLogger =
opts.logger ?? createDaemonLogger({ level: opts.logLevel ?? 'info' });
const lockHandle = acquireLock({ port: opts.port, lockPath: opts.lockPath });
const lockHandle = acquireLock({
port: opts.port,
lockPath: opts.lockPath,
// Record the host build identity so `kimi web`/`kimi daemon` can detect a
// build-mismatched daemon and restart it instead of serving a stale build.
hostVersion: opts.coreProcessOptions?.identity?.version,
entry: process.argv[1],
});
const app = Fastify({
loggerInstance: pinoLogger,