mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 16:46:17 +00:00
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:
parent
7448a225a7
commit
45d2cb1ed3
6 changed files with 391 additions and 35 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue