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

Status: implemented

[English](2026-07-30-settings-write-path-integrity.md) | 中文

> 范围：对 `packages/settings/` 的第三轮评审——`dsh-settings-local` 的写路径数据完整性（操作链、读-改-写、跨进程写锁、diff 形态的 YAML 编辑）与 `dsh-settings` 的观察者生命周期（watch 的 dispose（资源释放）、异步监听器收容、JSON 形态写入边界）。本 note 推翻了[用户设置 seam note](2026-07-28-user-settings-seam.md)所记录的一项延后决定：跨进程锁文件现已交付。

## 问题

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

在 seam 一侧，`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`、在提供方里做校验**——seam 才是持久化边界的所有者（每个提供方存储的都是 JSON 形态文档），而且在调用时刻拒绝能把违规值的路径给到调用方；提供方侧的检查要到合并之后才拒绝，归咎的是合并后的分节，而不是调用方传入的值。

## 后果

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

[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`（两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件）与堆叠分支上的 `llm/adapters-updated` 扇出；这些修复归引入相应包的那些 PR（Pull Request）所有，向上合并时按本模板处理。
