Files
deepseek-harness/packages/settings/settings/README.zh.md
Yichen Jiang e6483f0afc feat(settings): detect stale writers with a revision, and announce raw changes
The remaining P1 from the #939 review, plus the P2 it shares a mechanism with.

Nothing carried a version, so two tabs editing one namespace silently
overwrote each other — reproduced as tab B's `reasoning` lost to tab A's
older draft. The seam's per-namespace write queue orders writes; it cannot
tell a fresh writer from one replaying a snapshot a predecessor superseded.

Each namespace now carries a monotonic `revision` over its RAW section. A
write may send `expectedRevision`, checked at the FRONT of the queue (not at
call time, which would race the very predecessor it guards against); a
mismatch rejects with `SettingsConflictError` → `settings-conflict` on the
wire, carrying both revisions. The editor captures the revision it opened at
and, on conflict, asks the user to reopen rather than replaying its snapshot.

The same counter fixes the missing broadcast. `settings/updated` is gated on
the resolved value — correct for consumers, wrong for configuration surfaces:
storing an override equal to the composition base leaves the resolved value
alone while changing what the document says (the field is now overridden, not
inherited) and moving every open editor's revision. `settings/document-updated
(ns, revision)` fires on any raw-section change, in-process or external, and
`host/settings-changed` now rides it.

That event also closes the stale model picker: editing a provider's `models`
changes no route, so `llm/adapters-updated` never fired and an open picker
kept serving the old catalog. A change to an exposed provider namespace now
emits `host/models-changed` too — that namespace holds the catalog.

Docs: both sides of the five touched README pairs, a type-equiv block for
`SettingsPathOp`, and an Agent Note recording what the plane exposes and who
may overwrite what. The deferred wire-redaction gaps (secrets behind
union/intersection/transform, `.default(...)` in the served envelope, schema
text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are
recorded as TODO(settings-wire-redaction) and in Known Limitations rather than
half-fixed.
2026-07-30 19:24:21 +08:00

6.2 KiB
Raw Blame History

@deepseek-ai/dsh-settings

English | 中文

抽象用户设置 seamctx.settings)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值schema 默认值,然后注册方的组合 base(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。

服务 API

  • register(ns, schema, { base?, applies? }) — 返回 owner 的 SettingsScopeget/watch/update)。注册是调用方插件 fiber 上的 effectdispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
  • describe(options?) — 每个 namespace 一条描述(schema.toJSON() 信封、解析值、分离出的 base/user 层、applies),供配置界面使用;字段出现在 user 中即标记其被用户覆盖。describe({ redactSecrets: true }) 从每一层剥离 role('secret') 字段,并附加 secrets 槽位列表({ path, set });每个 wire 面都必须传入它,纯遍历器 redactSecrets(schema, value) 已导出,供其他 wire 使用。
  • get(ns) — 解析值;未注册时为 undefined
  • update(ns, patch) — 把普通对象 patch 深合并进用户分节(绝不合并进 base),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 $ 为根的路径拒绝YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 providerwritable: false)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
  • replace(ns, section) — 整体替换用户分节:这是刻意的重置(replace({}) 重新继承 base 与 schema 默认值)。
  • mutate(ns, ops) — 在写入排到队首那一刻的分节上,按序施加 { op: 'set' | 'unset', path } 编辑。这是任何持有不完整视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor据此重建分节再整体替换会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。
  • 每次写入都可携带可选的 expectedRevision。每个 descriptor 都带有该 namespace 的 revision——一个针对其原始分节的单调计数器;期望值不再匹配的写入会以 SettingsConflictErrorcode: 'SETTINGS_CONFLICT',并附上两个 revision被拒绝而不是覆盖先落地的那个写方。写队列只保证写入的先后次序它本身分辨不出一个新写方与一个持有过期快照的写方。
  • 解析值是深冻结快照。每次提交后观察者收到 (next, prev)同一回调的调用异步、逐次、按提交顺序执行慢的旧调用绝不会覆盖更新的结果异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。settings/updated 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener异步 listener 的拒绝会被隔离并记入日志,这正是 INVARIANT 编码的失败只从同步 listener 重新抛出的原因。
  • 服务卸载先拒绝新写入与观察者调用的启动再排干全部排队写入与已启动的观察者调用后才完成registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。

Provider 契约

子类实现 writableload()persist(ns, section),并通过受保护的 publish(doc) 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 initwatcher、连接的 provider 先经 yield* super[Service.init]() 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。

事件

settings/updated (ns, next, prev, source) 在每次提交后触发;sourceupdate(进程内写入)或 provider(外部变更)。解析值深相等时绝不触发——它面向消费方,而消费方只关心自己的值有没有变。

settings/document-updated (ns, revision)原始用户分节发生变化时触发,无论解析值是否随之改变。配置界面需要的是这一个:存入一个与组合 base 相同的覆盖值不会改变解析值,却改变了文档的说法(该字段从继承变成了覆盖),也推进了每个已打开编辑器所持有的 revision。监听器的收容方式与 settings/updated 相同。

Model Experience

间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。

KV Cache effect

无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。

Known Limitations and Deferred Work

  • 单一用户层 — 解析只认识 schema 默认值、一个组合 base 与一个用户文档;尚无 project/managed 分层或按值溯源。
  • redactSecrets 并非一条可被证明的协议边界walker 只跟随 object/dict/array,因此只能经由 union、intersection 或 transform 抵达的 role('secret') 会被原样返回,且 secrets 列表为空;而 schema.toJSON() 会把 secret 字段的 .default(...) 一并带给每个客户端。这两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema绝不可注册到暴露于协议的 namespace 上。真正的答案是一个 fail-closed 的 describeForWire()——它拒绝自己无法证明安全的 schema并对序列化信封与错误文本做净化——此项暂缓。
  • 跨进程并发由 provider 定义 — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。