diff --git a/.changeset/config-torn-read-guard.md b/.changeset/config-torn-read-guard.md new file mode 100644 index 00000000000..740ae0fde0e --- /dev/null +++ b/.changeset/config-torn-read-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix models and providers transiently disappearing when config.toml is saved non-atomically by an external editor while the daemon reloads it. diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index 8e560eff65e..c9e23a854fa 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -17,6 +17,8 @@ import type { import { toStorageIoError } from '#/persistence/interface/storage'; const WATCH_DEBOUNCE_MS = 150; +const TORN_READ_RETRIES = 3; +const TORN_READ_RETRY_DELAY_MS = 15; function isEnoent(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === 'ENOENT'; @@ -35,11 +37,23 @@ export class FileStorageService implements IFileSystemStorageService { async read(scope: string, key: string): Promise { const filePath = this.pathFor(scope, key); - try { - return await readFile(filePath); - } catch (error) { - if (isEnoent(error)) return undefined; - throw toStorageIoError(error, { path: filePath, op: 'read' }); + for (let attempt = 0; ; attempt += 1) { + let bytes: Uint8Array; + try { + bytes = await readFile(filePath); + } catch (error) { + if (isEnoent(error)) return undefined; + throw toStorageIoError(error, { path: filePath, op: 'read' }); + } + if (attempt >= TORN_READ_RETRIES) return bytes; + let size: number | undefined; + try { + size = (await stat(filePath)).size; + } catch { + size = undefined; + } + if (size === undefined || size === bytes.length) return bytes; + await new Promise((resolve) => setTimeout(resolve, TORN_READ_RETRY_DELAY_MS)); } } diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 3b9b8294fee..a305f871295 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { deflateSync } from 'node:zlib'; @@ -179,6 +179,15 @@ async function expectSessionMedia( return path; } +let configTomlSeq = 0; + +async function writeConfigToml(dir: string, content: string): Promise { + configTomlSeq += 1; + const tmpPath = join(dir, `config.toml.${process.pid}.${configTomlSeq}.tmp`); + await writeFile(tmpPath, content, 'utf-8'); + await rename(tmpPath, join(dir, 'config.toml')); +} + describe('server-v2 /api/v1 prompts', () => { let server: RunningServer | undefined; let home: string | undefined; @@ -186,7 +195,7 @@ describe('server-v2 /api/v1 prompts', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-prompts-')); - await writeFile(join(home, 'config.toml'), PROMPT_TOML, 'utf-8'); + await writeConfigToml(home, PROMPT_TOML); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; }); @@ -272,7 +281,7 @@ describe('server-v2 /api/v1 prompts', () => { }); it('accepts a prompt-carried model when default_model is not configured', async () => { - await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_NO_DEFAULT, 'utf-8'); + await writeConfigToml(home as string, PROMPT_TOML_NO_DEFAULT); const id = await createSession(home as string); await createMainAgent(id); @@ -284,7 +293,7 @@ describe('server-v2 /api/v1 prompts', () => { }); it('accepts the session-bound model when default_model is not configured', async () => { - await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_NO_DEFAULT, 'utf-8'); + await writeConfigToml(home as string, PROMPT_TOML_NO_DEFAULT); const id = await createSession(home as string); await createMainAgent(id); await setSessionModel(id, 'stub'); @@ -296,7 +305,7 @@ describe('server-v2 /api/v1 prompts', () => { }); it('accepts the session-bound model when default_model dangles', async () => { - await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_DANGLING_DEFAULT, 'utf-8'); + await writeConfigToml(home as string, PROMPT_TOML_DANGLING_DEFAULT); const id = await createSession(home as string); await createMainAgent(id); await setSessionModel(id, 'stub'); @@ -308,7 +317,7 @@ describe('server-v2 /api/v1 prompts', () => { }); it('rejects when neither prompt, session, nor default_model resolves a model', async () => { - await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_NO_DEFAULT, 'utf-8'); + await writeConfigToml(home as string, PROMPT_TOML_NO_DEFAULT); const id = await createSession(home as string); await createMainAgent(id); @@ -336,7 +345,7 @@ describe('server-v2 /api/v1 prompts', () => { const id = await createSession(home as string); await createMainAgent(id); await setSessionModel(id, 'stub'); - await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_OTHER_DEFAULT, 'utf-8'); + await writeConfigToml(home as string, PROMPT_TOML_OTHER_DEFAULT); const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { content: [{ type: 'text', text: 'hello' }], @@ -350,7 +359,7 @@ describe('server-v2 /api/v1 prompts', () => { const id = await createSession(home as string); await createMainAgent(id); await setSessionModel(id, 'stub'); - await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_OTHER_DEFAULT, 'utf-8'); + await writeConfigToml(home as string, PROMPT_TOML_OTHER_DEFAULT); const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { content: [{ type: 'text', text: 'hello' }],