Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md
T
Tianyi Cui 25dcd7293c docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 21:10:59 +08:00

7.5 KiB
Raw Blame History

Agent Note: settings 写路径完整性与观察者生命周期

Status: implemented

English | 中文

范围:dsh-settings-local 的写路径数据完整性(操作链、读-改-写、跨进程写锁、diff 形态的 YAML 编辑)与 dsh-settings 的观察者生命周期(watch 的 dispose(资源释放)、异步监听器收容、JSON 形态写入边界)。本 note 推翻了用户设置 seam note所记录的一项延后决定:跨进程锁文件现已交付。

问题

提供方的写路径可能销毁它从未观察到的状态,而 Service Definition 的观察者生命周期会泄漏到 dispose 之后。具体而言:watcher 重载与文档写入跑在两条相互独立的 promise 链上,而每次写入都从缓存文本渲染出完整的下一份文档,于是仍处于防抖窗口内的外部编辑会被覆盖——随后的重载又因 rename 后的内容与缓存一致而成为空操作,这次编辑就被无痕抹去。初始 load() 与 watcher 自身的建立过程存在竞态,留下一个启动窗口:落在这个窗口内的变更永远不会触发事件。共享同一 harness home 的两个进程各自从独立的缓存渲染,后写者以整个 namespace 为单位胜出。

在 Service Definition 一侧,watch() 的释放器只把观察者从集合中移除——已经接到 watcher 链尾的调用在 dispose 之后照常运行,服务 dispose 时也没有任何环节排空已启动的调用;settings/updated 的手动扇出只捕获同步抛错,异步监听器的 rejection 会以 unhandled rejection 的形式逃逸;structuredClone 则放行 Date、Map、BigInt 与循环引用,而 YAML/JSON 存储会在重载往返中悄悄扭曲这些值(Date 会变成时间戳字符串,BigInt 会变成普通数字)。

YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删掉——而这个保注释的提供方承诺过要保住它们。

决策

**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;persistSection 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 reconcileFromDisk 抛错,各调用方自选策略)。watcher 的 ready 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。

**写入持有以 wx 创建的同目录 <file>.lock。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。

**观察者 dispose 达到完全停稳。**watcher 携带一个 active 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 pendingTails 集合,dispose 排空除了等待各写队列,还会等待该集合。settings/updated 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件约定现已写明 INVARIANT 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。

**写入边界只放行 JSON 数据。**调用时刻的快照就是一次 cloneJsonShaped 遍历:它把 patch 从调用方分离出来,并在任何内容持久化之前拒绝一切非 JSON 值——Date、Map、BigInt、非有限数值、函数、symbol、类实例、值为 undefined 的数组元素、循环引用——拒绝时附带该值以 $ 为根的路径。显式为 undefined 的对象条目仍会跳过(稀疏 patch 约定),这一约定如今在边界处强制执行,而不再放在 mergeLayers 内部。

YAML 编辑是叶子级 diff。renderYaml 对比已存储分节与下一份分节,只对变化的值应用 setIn、对移除的键应用 deleteIn,并沿 map 递归。注释、锚点与格式在每个未触碰节点上、以及每个被改键值对的键节点上全部保留;数组等非 map 值在不相等时整体替换(deepEqualJson 是共享的判定谓词),其内部注释随之一并被带走。

曾考虑的替代方案

  • 用 proper-lockfile 取代手写锁——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其所有权与重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个小型独占创建循环,带确定性的竞争测试。该政策偏向能删除自有代码的依赖;这个依赖只会把一个窄协议换成不透明的等价物。
  • 用修订号/CAS 取代锁——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。
  • 把外部编辑合并进正在进行的写入自身的分节——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。
  • 宣布不支持异步 settings/updated 监听器——类型签名是 void,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;约定里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。
  • 保留 structuredClone、在提供方里做校验——Service Definition 才是持久化边界的所有者(每个提供方存储的都是 JSON 形态文档),而且在调用时刻拒绝能把违规值的路径给到调用方;提供方侧的检查要到合并之后才拒绝,归咎的是合并后的分节,而不是调用方传入的值。

后果

update() 对锁获取期限与磁盘文档非法都有成文的失败模式,rejection 消息携带以 $ 为根的路径。持有者崩溃后可能留下锁,需要操作者核实后移除;若按锁龄自动接管,则会允许多个写入方重叠。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。

用户设置 seam note里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷曾存在于 dsh-credentials-local(两条链共用一个 .env、按缓存整文件写回、持久化之后才发事件)与 llm/adapters-updated 扇出;credential-boundaries note 在那里套用了本模板。