Files
deepseek-harness/packages/settings/settings
Yichen Jiang 1010291fe6 fix(settings): close cross-namespace, dispatch, and lifecycle races from second review
Confirmed and fixed, each with a regression test that failed first:

- Concurrent writes to different namespaces lost whole sections on disk
  (each persist rendered the full document from a stale text): the local
  provider serializes render->write->rename->text-commit on one internal
  persist chain shared by every namespace queue.
- One throwing settings/updated listener starved the rest (cordis emit
  stops at the first throw): commit fans out per listener via
  events.dispatch, contains individual failures, and rethrows the first
  INVARIANT-coded error only after every listener ran.
- Write queues ignored fiber/service lifecycle: the base init now
  registers a teardown that refuses new writes and drains queued chains;
  queued tasks re-verify service liveness and namespace ownership before
  running and again before committing, so a registrant disposed
  mid-flight is never notified and a disposed service never commits.
- Async watcher invocations could interleave (a slow stale call applied
  last): each watcher carries a serialized invocation chain — one call
  at a time, in commit order; JSDoc/doc pages state the async timing.
- update/replace borrowed the caller's object until the queued task ran:
  inputs are structured-clone snapshotted at call time; non-cloneable
  plain objects reject with a typed error.
- Composition guard now proves the documented fallback: the consumer
  uses the optional scoped-inject shape and boots both with the settings
  entry (hot publish) and without it (entry-config resolution, no scope).
- core-data-structures index: settings.md row added to the sub-page
  table in core.md/core.zh.md.

Both packages hold per-file 100% coverage across repeated runs.
2026-07-29 10:19:33 +08:00
..

@deepseek-ai/dsh-settings

English | 中文

Abstract user-settings seam (ctx.settings). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition base (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings.

Service API

  • register(ns, schema, { base?, applies? }) — returns the owner SettingsScope (get/watch/update). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
  • describe() — one descriptor per namespace (schema.toJSON() envelope, resolved value, applies) for configuration surfaces.
  • get(ns) — resolved value, undefined while unregistered.
  • update(ns, patch) — deep-merges the plain-object patch into the user section only (never the base), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (writable: false) rejects every write. Writes to one namespace are serialized in call order.
  • replace(ns, section) — sets the user section wholesale: the removal/reset path a merge cannot express (replace({}) re-inherits base and schema defaults).
  • Resolved values are deep-frozen snapshots. Watchers receive (next, prev) after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. The settings/updated event fans out one listener at a time, so one throwing listener cannot starve the rest.
  • Service teardown refuses new writes and drains every queued write before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.

Provider contract

Subclasses implement writable, load(), and persist(ns, section), and push externally observed documents through the protected publish(doc). The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via yield* super[Service.init](). At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.

Events

settings/updated (ns, next, prev, source) fires after each commit; source is update (in-process write) or provider (external change). It never fires for a deep-equal resolved value.

Model Experience

Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.

KV Cache effect

No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.

Known Limitations and Deferred Work

  • Single user layer — resolution knows schema defaults, one composition base, and one user document; there is no project/managed layering or per-value provenance yet.
  • Cross-process concurrency is provider-defined — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider is last-write-wins).
  • No secret-field redactiondescribe() returns resolved values verbatim; a wire surface (RPC/UI) must redact role('secret') fields before exposure.