From ca09e31cb72f6f98a5c0ae54fdcbefcaa1cdb98b Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 23:38:20 +0800 Subject: [PATCH] fix(kimi-code): exit 1 when a headless (-p) turn fails (#1483) Headless (`kimi -p`) failures could exit with code 0 when the event loop drained during the shutdown cleanup (e.g. telemetry's unref'd retry backoff when the network is blocked), because the rejection never reached the process.exit(1) call. Set the failure exit code before any await in both the run-prompt catch and the main catch, and keep the cleanup timeout ref'd so the loop stays alive long enough for the rejection to propagate. --- .changeset/fix-headless-exit-code.md | 5 +++++ apps/kimi-code/src/cli/run-prompt.ts | 7 +++++-- apps/kimi-code/src/main.ts | 10 +++++++++ apps/kimi-code/test/cli/main.test.ts | 31 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-headless-exit-code.md diff --git a/.changeset/fix-headless-exit-code.md b/.changeset/fix-headless-exit-code.md new file mode 100644 index 000000000..f2de03dd8 --- /dev/null +++ b/.changeset/fix-headless-exit-code.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `kimi -p` runs exiting with code 0 when a turn fails. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index e5374a079..7f7b5942f 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -44,7 +44,11 @@ import { createKimiCodeHostIdentity } from './version'; * * Used to bound shutdown so a wedged cleanup step can't keep a completed * headless run alive, without silently swallowing a cleanup that fails fast. The - * timer is unref'd so it never keeps the loop alive on its own. + * timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g. + * telemetry's retry backoff when the network is blocked) can't drain the event + * loop and exit 0 before the rejection propagates — the timer keeps the loop + * alive until it fires, then gives the rejection a chance to surface. A wedged + * cleanup is still bounded by `timeoutMs`, so this can't hang the run forever. */ async function raceWithTimeout(promise: Promise, timeoutMs: number): Promise { let timedOut = false; @@ -61,7 +65,6 @@ async function raceWithTimeout(promise: Promise, timeoutMs: number): Promi timedOut = true; resolve(); }, timeoutMs); - timer.unref?.(); }); try { await Promise.race([guarded, timedOutSignal]); diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index a54bfa2bd..860c4423d 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -172,6 +172,16 @@ export function main(): void { } }) .catch(async (error: unknown) => { + // Set the failure exit code synchronously, before any `await`. The + // terminal `process.exit(1)` below is our intended exit, but it sits + // behind `await logStartupFailure(...)`; by the time we reach that + // await, the failed run's `finally` cleanup has already torn down its + // ref'd handles (sockets, timers, background tasks). If the event loop + // drains during the await, Node exits on its own with the DEFAULT code + // 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures + // would then exit 0 nondeterministically. Setting `process.exitCode` + // up front makes that drain-exit report failure too. + process.exitCode = 1; const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; await logStartupFailure(operation, error); process.stderr.write( diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 04d6556cf..31411e8b1 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => { })), initializeCliTelemetry: vi.fn(), handleUpgrade: vi.fn(), + flushDiagnosticLogs: vi.fn(), finalizeHeadlessRun: vi.fn(), log: { info: vi.fn(), @@ -80,6 +81,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async () => { mocks.createKimiHarness(...args); return mocks.harness; }, + flushDiagnosticLogs: mocks.flushDiagnosticLogs, KimiHarness: MockKimiHarness, log: mocks.log, }; @@ -215,6 +217,7 @@ describe('main entry command handling', () => { mocks.harness.close.mockResolvedValue(undefined); mocks.shutdownTelemetry.mockResolvedValue(undefined); mocks.handleUpgrade.mockResolvedValue(0); + mocks.flushDiagnosticLogs.mockResolvedValue(undefined); }); it('runs update preflight before starting the shell', async () => { @@ -301,6 +304,34 @@ describe('main entry command handling', () => { expect(typeof forceExitArgs[2]).toBe('function'); }); + it('sets the failure exit code before awaiting startup failure logging', async () => { + const originalExitCode = process.exitCode; + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockRejectedValue(new Error('provider failed')); + mocks.flushDiagnosticLogs.mockImplementation(() => new Promise(() => {})); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + + try { + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.flushDiagnosticLogs).toHaveBeenCalledTimes(1); + }); + expect(process.exitCode).toBe(1); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + process.exitCode = originalExitCode; + } + }); + it('keeps shell mode update preflight interactive by default', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' });