Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/config-torn-read-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core-v2": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Attribute the user-visible fix to the CLI package

This user-perceivable fix is shipped through the CLI, so the repository's changeset rules require listing @moonshot-ai/kimi-code; attributing it only to the private internal @moonshot-ai/agent-core-v2 package omits the CLI bump and changelog entry for the fix. Change the frontmatter package to @moonshot-ai/kimi-code.

AGENTS.md reference: AGENTS.md:L85-L87

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6171d28: the changeset frontmatter now lists @moonshot-ai/kimi-code.

---

Fix models and providers transiently disappearing when config.toml is saved non-atomically by an external editor while the daemon reloads it.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,11 +37,23 @@ export class FileStorageService implements IFileSystemStorageService {

async read(scope: string, key: string): Promise<Uint8Array | undefined> {
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry partial snapshots even when their sizes match

When an in-place writer truncates the file and pauses, or writes one chunk and pauses, readFile and the following stat can both observe the same transient zero or partial length, so this equality returns the incomplete contents immediately without performing any retry. Empty or valid partial TOML is then accepted by ConfigService.load, allowing models and providers to disappear exactly as before; the guard must not treat an immediate size match alone as proof of a stable snapshot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6171d28: a size match alone no longer settles the read for a hot file. When the mtime is within 100ms of now, acceptance requires a second stat 15ms later reporting identical size+mtime; a change between the two stats loops back into the retry. This narrows the truncate-and-pause case to a writer that stays paused across the confirmation gap — a read-side guard cannot be fully airtight against an in-place writer paused indefinitely mid-write (only atomic tmp+rename writes are, which is what the test-side change enforces); the guard remains best-effort defense for external editors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: the double-stat confirmation from 6171d28 is reverted in 01ba720. CI showed it caused real regressions: delaying every read of a recently-written file by 15ms (45ms for files under append) shifted server boot/reload timing enough to break the config changedFields baseline test and the fork-parity test. Since no read-side guard can be airtight against an in-place writer paused mid-write, and the extended guard demonstrably breaks real behavior to narrow a scenario that has never been observed, the PR keeps the simple size-verify guard (which eliminates the race that was actually reproduced) and relies on atomic tmp+rename writes as the real contract — now enforced in the test.

await new Promise((resolve) => setTimeout(resolve, TORN_READ_RETRY_DELAY_MS));
}
}

Expand Down
25 changes: 17 additions & 8 deletions packages/kap-server/test/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -179,14 +179,23 @@ async function expectSessionMedia(
return path;
}

let configTomlSeq = 0;

async function writeConfigToml(dir: string, content: string): Promise<void> {
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;
let base: string;

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}`;
});
Expand Down Expand Up @@ -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);

Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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);

Expand Down Expand Up @@ -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<null>('POST', `/api/v1/sessions/${id}/prompts`, {
content: [{ type: 'text', text: 'hello' }],
Expand All @@ -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' }],
Expand Down
Loading