Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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/config-persist-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix config.toml entries being lost when the file had a syntax error or was edited outside the app.
86 changes: 69 additions & 17 deletions packages/agent-core-v2/src/app/config/configService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { Emitter, type Event } from '#/_base/event';
import { BugIndicatingError, onUnexpectedError } from '#/errors';
import { BugIndicatingError, Error2, ErrorCodes, onUnexpectedError } from '#/errors';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { ILogService } from '#/_base/log/log';
import {
Expand Down Expand Up @@ -309,6 +309,7 @@ export class ConfigService extends Disposable implements IConfigService {
private readonly diagnosticsList: ConfigDiagnostic[] = [];
private lastDiagnosticsSnapshot = '[]';
private readonly configKey: string;
private tainted = false;

constructor(
@IConfigRegistry private readonly registry: IConfigRegistry,
Expand Down Expand Up @@ -402,17 +403,19 @@ export class ConfigService extends Disposable implements IConfigService {
return;
}
await this.enqueueStateTransition(async () => {
const base = this.raw[domain];
const next = this.registry.merge(domain, base, patch);
const validated = this.registry.validate(domain, next);
const stripped = this.stripEnv(domain, validated);
if (stripped === undefined) {
delete this.raw[domain];
} else {
this.registry.validate(domain, stripped);
this.raw[domain] = stripped;
}
await this.persist(domain);
this.assertPersistable();
await this.persist(domain, (freshRaw) => {
this.raw = freshRaw;
const next = this.registry.merge(domain, freshRaw[domain], patch);
const validated = this.registry.validate(domain, next);
const stripped = this.stripEnv(domain, validated);
if (stripped === undefined) {
delete this.raw[domain];
} else {
this.registry.validate(domain, stripped);
this.raw[domain] = stripped;
}
});
this.rebuildEffective('set', [domain]);
});
}
Expand All @@ -434,6 +437,7 @@ export class ConfigService extends Disposable implements IConfigService {
return;
}
await this.enqueueStateTransition(async () => {
this.assertPersistable();
const stripped = this.stripEnv(domain, effectiveValue);
if (stripped === undefined) {
delete this.raw[domain];
Expand Down Expand Up @@ -467,6 +471,7 @@ export class ConfigService extends Disposable implements IConfigService {
return;
}
await this.enqueueStateTransition(async () => {
this.assertPersistable();
const staged: ResolvedConfig = { ...this.raw };
for (const domain of domains) {
const value = sections[domain] === null ? undefined : sections[domain];
Expand Down Expand Up @@ -526,7 +531,11 @@ export class ConfigService extends Disposable implements IConfigService {
: describeUnknownError(error);
this.pushDiagnostic({ severity: 'error', message });
this.log.warn('config load failed', { error: describeUnknownError(error) });
this.tainted = true;
this.emitDiagnosticsIfChanged();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Initialize effective defaults before returning on load failure

When config.toml is malformed during the initial load, this early return leaves validated and effective empty instead of rebuilding them from the registry defaults as the previous flow did. Consequently, consumers that rely on a registered default can crash even though ready resolves; for example, SessionCronServiceImpl.start() obtains undefined for the defaulted cron section and dereferences cfg.manualTick. Preserve the last-known-good state on reload failures, but initialize the default effective configuration when the first load fails.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in a73f93c — a failed initial load now falls through with an empty document so registered section defaults (e.g. cron) are still built and validated, while the taint keeps blocking persisted writes. Only reload failures early-return to preserve last-known-good state. The persistence-guard test now asserts defaults are available after a broken initial load.

}
this.tainted = false;
const nextRawSnake = cloneRecord(fileData);
for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) {
this.pushDiagnostic(diagnostic);
Expand Down Expand Up @@ -732,15 +741,58 @@ export class ConfigService extends Disposable implements IConfigService {
this.commit('reload', [domain]);
}

private async persist(domain: string): Promise<void> {
await this.persistDomains([domain]);
private assertPersistable(): void {
if (!this.tainted) return;
throw new Error2(
ErrorCodes.CONFIG_PERSIST_BLOCKED,
`Refusing to persist config: ${this.bootstrap.configPath} could not be read; fix the file and reload before writing.`,
);
}

private async persistDomains(domains: readonly string[]): Promise<void> {
private async persist(domain: string, rebase?: (onDiskRaw: ResolvedConfig) => void): Promise<void> {
await this.persistDomains([domain], rebase);
}

private async persistDomains(
domains: readonly string[],
rebase?: (onDiskRaw: ResolvedConfig) => void,
): Promise<void> {
this.assertPersistable();
let onDisk: ResolvedConfig = {};
try {
const data = await this.documentStore.get<ResolvedConfig>(CONFIG_SCOPE, this.configKey);
onDisk = data !== undefined && isPlainObject(data) ? data : {};
} catch (error) {
const message =
error instanceof TomlError
? `Failed to parse ${this.bootstrap.configPath}: ${describeTomlSyntaxError(error)}`
: describeUnknownError(error);
this.pushDiagnostic({ severity: 'error', message });
this.emitDiagnosticsIfChanged();
this.log.warn('config persist aborted: re-read failed', {
error: describeUnknownError(error),
});
this.tainted = true;
throw new Error2(
ErrorCodes.CONFIG_PERSIST_BLOCKED,
`Refusing to persist config: ${this.bootstrap.configPath} could not be read; fix the file and reload before writing.`,
{ cause: error },
);
}
if (rebase !== undefined) {
rebase(transformTomlData(onDisk, this.registry));
}

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 Rebase environment stripping onto the fresh document

When an environment overlay masks a persisted value, this rebase supplies only the transformed document while stripEnv still reads the stale this.rawSnake; replace and replaceSections perform stripping before the re-read entirely. For example, with KIMI_MODEL_NAME set, if an external editor changes default_model and a write supplies the effective __kimi_env_model__ value, kimiModelEnvOverlay.strip restores the old default_model from the stale snapshot and overwrites the external edit. Refresh both raw snapshots before stripping, or pass the freshly read TOML into the stripping operation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18f3866 — strip/merge/validate for set/replace/replaceSections all moved into the persist rebase callback, and both snapshots (raw + rawSnake) are synced from the re-read before any stripping runs, so overlay strip handlers restore file values (e.g. env-masked default_model) from the fresh document. The snapshot sync also made the separate absorbed-external reload redundant. Covered by a new env-masked default_model test.

const absorbedExternal = JSON.stringify(onDisk) !== JSON.stringify(this.rawSnake);
const nextRawSnake = cloneRecord(onDisk);
for (const domain of domains) {
applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry);
applySectionToToml(nextRawSnake, domain, this.raw[domain], this.registry);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Rebase merge patches onto the re-read section

When an external edit changes the same domain being updated, this writes the already-merged stale this.raw[domain] over the freshly read value. For example, if [providers.beta] is added after startup and config.set(PROVIDERS_SECTION, { gamma: ... }) runs, this.raw[providers] was built from the old provider set, so applying it here deletes beta despite the new re-read. Preserve the original patch and merge it against the transformed onDisk section before writing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f59526d — set() now rebases its merge onto the freshly re-read file content (refreshing the in-memory snapshot from the same read), so same-section external edits like an added [providers.beta] survive the write. Covered by a new same-section merge test.

}
await this.documentStore.set(CONFIG_SCOPE, this.configKey, this.rawSnake);
await this.documentStore.set(CONFIG_SCOPE, this.configKey, nextRawSnake);

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 Make the re-read and write a single transaction

When another process or editor changes config.toml after the get completes but before this set, the later write still overwrites that change. Atomic replacement only prevents a partially written file; it does not make this read-modify-write sequence atomic. Use locking/CAS or revalidate the observed version immediately before committing so concurrent edits cannot still be lost.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — re-read + write is still not atomic; this change only shrinks the lost-update window from stale-since-load to the re-read-to-write gap. Proper CAS or locking (mtime revalidation before commit, or a lockfile behind the existing IAtomicDocumentStore.acquire seam) is intentionally left to a follow-up PR to keep this one focused.

if (absorbedExternal) {
await this.load('reload');
return;
}
this.rawSnake = nextRawSnake;
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/app/config/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors';
export const ConfigErrors = {
codes: {
CONFIG_INVALID: CONFIG_INVALID_ERROR_CODE,
CONFIG_PERSIST_BLOCKED: 'config.persist_blocked',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Register the new error code in the wire protocol

Add config.persist_blocked to the canonical KimiErrorCode union and kimiErrorCodeSchema in packages/protocol/src/events.ts. As written, toErrorPayload can emit this registered domain code, but protocol validation rejects it and protocol consumers cannot type or narrow it, so the new failure mode cannot safely cross RPC/SDK boundaries.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L85-L85

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0514b76 — config.persist_blocked is now registered in the KimiErrorCode union and kimiErrorCodeSchema.

},
} as const satisfies ErrorDomain;

Expand Down
158 changes: 158 additions & 0 deletions packages/agent-core-v2/test/app/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createDecorator, type ProvideHandle } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { Service } from '#/_base/di/service';
import { TestInstantiationService } from '#/_base/di/test';
import { Event } from '#/_base/event';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import {
type ConfigSchema,
Expand Down Expand Up @@ -2596,3 +2597,160 @@ describe('ConfigService replaceSections', () => {
disposables.dispose();
});
});

describe('ConfigService persistence guards', () => {
class SilentStorage extends InMemoryStorageService {
override watch(): Event<void> {
return Event.None as Event<void>;
}
}

async function createGuardedConfig(toml: string) {
const disposables = new DisposableStore();
const ix = disposables.add(new TestInstantiationService());
const storage = new SilentStorage();
await storage.write('', 'config.toml', new TextEncoder().encode(toml));
ix.stub(ILogService, stubLog());
ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg-guards'));
ix.stub(IFileSystemStorageService, storage);
ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore));
ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
ix.set(IConfigService, new SyncDescriptor(ConfigService));
const config = ix.get(IConfigService);
await config.ready;
return { config, disposables, storage };
}

async function overwrite(storage: InMemoryStorageService, toml: string): Promise<void> {
await storage.write('', 'config.toml', new TextEncoder().encode(toml));
}

async function stored(storage: InMemoryStorageService): Promise<string> {
const bytes = await storage.read('', 'config.toml');
return new TextDecoder().decode(bytes);
}

async function expectPersistBlocked(promise: Promise<unknown>): Promise<void> {
const error = await promise.then(
() => undefined,
(e: unknown) => e,
);
expect(isError2(error)).toBe(true);
expect((error as Error2).code).toBe(ErrorCodes.CONFIG_PERSIST_BLOCKED);
}

it('refuses to persist when the initial load fails and keeps the file untouched', async () => {
const broken = '[providers\nbroken';
const { config, disposables, storage } = await createGuardedConfig(broken);

expect(config.diagnostics().some((d) => d.severity === 'error')).toBe(true);

await expectPersistBlocked(config.set(THINKING_SECTION, { enabled: true }));
await expectPersistBlocked(config.replace(THINKING_SECTION, { enabled: true }));
await expectPersistBlocked(config.replaceSections({ [THINKING_SECTION]: { enabled: true } }));

expect(await stored(storage)).toBe(broken);

await config.set(THINKING_SECTION, { enabled: true }, ConfigTarget.Memory);
expect(config.get<ThinkingConfig>(THINKING_SECTION)).toEqual({ enabled: true });

disposables.dispose();
});

it('keeps last-known-good values when a reload hits a broken file, and recovers after the file is fixed', async () => {
const { config, disposables, storage } = await createGuardedConfig(
'[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n',
);
expect(config.get<Record<string, unknown>>(PROVIDERS_SECTION)).toEqual({
acme: { type: 'openai', apiKey: 'sk-acme' },
});

await overwrite(storage, '= broken =');
await config.reload();

expect(config.get<Record<string, unknown>>(PROVIDERS_SECTION)).toEqual({
acme: { type: 'openai', apiKey: 'sk-acme' },
});
await expectPersistBlocked(config.set(THINKING_SECTION, { enabled: true }));
expect(await stored(storage)).toBe('= broken =');

await overwrite(storage, '[providers.beta]\ntype = "openai"\napi_key = "sk-beta"\n');
await config.reload();

expect(config.get<Record<string, unknown>>(PROVIDERS_SECTION)).toEqual({
beta: { type: 'openai', apiKey: 'sk-beta' },
});
await config.set(THINKING_SECTION, { enabled: true });
expect(config.get<ThinkingConfig>(THINKING_SECTION)).toEqual({ enabled: true });

disposables.dispose();
});

it('merges external edits observed at persist time instead of clobbering them', async () => {
const { config, disposables, storage } = await createGuardedConfig(
'default_model = "acme/m1"\n\n[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n',
);

await overwrite(
storage,
'default_model = "acme/m1"\n\n[providers.acme]\ntype = "openai"\napi_key = "sk-acme-2"\n\n[providers.beta]\ntype = "openai"\napi_key = "sk-beta"\n',
);

const changed: string[] = [];
config.onDidSectionChange((e) => changed.push(e.domain));
await config.set(THINKING_SECTION, { enabled: true });

const doc = await stored(storage);
expect(doc).toContain('sk-acme-2');
expect(doc).toContain('[providers.beta]');
expect(doc).toContain('[thinking]');
expect(config.get<Record<string, unknown>>(PROVIDERS_SECTION)).toEqual({
acme: { type: 'openai', apiKey: 'sk-acme-2' },
beta: { type: 'openai', apiKey: 'sk-beta' },
});
expect(config.get<ThinkingConfig>(THINKING_SECTION)).toEqual({ enabled: true });
expect(changed).toContain(PROVIDERS_SECTION);
expect(changed).toContain(THINKING_SECTION);

disposables.dispose();
});

it('honors an external delete instead of resurrecting the in-memory copy', async () => {
const { config, disposables, storage } = await createGuardedConfig(
'[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n',
);

await storage.delete('', 'config.toml');
await config.set(THINKING_SECTION, { enabled: true });

const doc = await stored(storage);
expect(doc).toContain('[thinking]');
expect(doc).not.toContain('[providers.acme]');
expect(config.inspect(PROVIDERS_SECTION).userValue).toBeUndefined();

disposables.dispose();
});

it('rebases a set() merge onto external edits of the same section', async () => {
const { config, disposables, storage } = await createGuardedConfig(
'[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n',
);

await overwrite(
storage,
'[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n\n[providers.beta]\ntype = "openai"\napi_key = "sk-beta"\n',
);
await config.set(PROVIDERS_SECTION, { gamma: { type: 'openai', apiKey: 'sk-gamma' } });

const doc = await stored(storage);
expect(doc).toContain('[providers.beta]');
expect(doc).toContain('[providers.gamma]');
expect(config.get<Record<string, unknown>>(PROVIDERS_SECTION)).toEqual({
acme: { type: 'openai', apiKey: 'sk-acme' },
beta: { type: 'openai', apiKey: 'sk-beta' },
gamma: { type: 'openai', apiKey: 'sk-gamma' },
});

disposables.dispose();
});
});
2 changes: 2 additions & 0 deletions packages/protocol/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export interface GoalChange {

export type KimiErrorCode =
| 'config.invalid'
| 'config.persist_blocked'
| 'session.not_found'
| 'session.already_exists'
| 'session.id_invalid'
Expand Down Expand Up @@ -1259,6 +1260,7 @@ export const goalChangeSchema = z.object({

export const kimiErrorCodeSchema = z.enum([
'config.invalid',
'config.persist_blocked',
'session.not_found',
'session.already_exists',
'session.id_invalid',
Expand Down
Loading