Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-headless-exit-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix `kimi -p` runs exiting with code 0 when a turn fails.
7 changes: 5 additions & 2 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>, timeoutMs: number): Promise<void> {
let timedOut = false;
Expand All @@ -61,7 +65,6 @@ async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promi
timedOut = true;
resolve();
}, timeoutMs);
timer.unref?.();
});
try {
await Promise.race([guarded, timedOutSignal]);
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-code/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions apps/kimi-code/test/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => {
})),
initializeCliTelemetry: vi.fn(),
handleUpgrade: vi.fn(),
flushDiagnosticLogs: vi.fn(),
finalizeHeadlessRun: vi.fn(),
log: {
info: vi.fn(),
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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' });
Expand Down
Loading