Repository navigation
fix(agent-core-v2): guard config persistence against lossy writes #3121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
a525f35
f59526d
0514b76
18f3866
a73f93c
9797481
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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, | ||
|
|
@@ -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]); | ||
| }); | ||
| } | ||
|
|
@@ -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]; | ||
|
|
@@ -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]; | ||
|
|
@@ -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; | ||
| } | ||
| this.tainted = false; | ||
| const nextRawSnake = cloneRecord(fileData); | ||
| for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) { | ||
| this.pushDiagnostic(diagnostic); | ||
|
|
@@ -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)); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an environment overlay masks a persisted value, this rebase supplies only the transformed document while Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an external edit changes the same domain being updated, this writes the already-merged stale Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When another process or editor changes Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Add AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L85-L85 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
config.tomlis malformed during the initial load, this early return leavesvalidatedandeffectiveempty instead of rebuilding them from the registry defaults as the previous flow did. Consequently, consumers that rely on a registered default can crash even thoughreadyresolves; for example,SessionCronServiceImpl.start()obtainsundefinedfor the defaultedcronsection and dereferencescfg.manualTick. Preserve the last-known-good state on reload failures, but initialize the default effective configuration when the first load fails.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
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.