8.3 KiB
Agent Note:存储根目录落点与派生介质恢复
Status: proposed
English | 中文
Problem
持久投影缓存(RFC,已作为 dsh-session-projection-cache 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈(设计)的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个派生介质。
文件到底存在哪。 出厂 Web overlay 给 json 后端的是相对根目录——root: './.storages'(apps/cli/config/web.cordis.yml)——而共享 base 将会话存储默认为全局 harness home($DSH_HOME/sessions,默认 ~/.dsh/sessions);storage-json 没有对应的全局根目录。JsonStorageBackend 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 process.cwd() 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 workspace.json 和 session_projcache.json 落在 <启动目录>/.storages/ 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
现在是怎么恢复的。 在健康介质内部,缓存按设计完全自愈:stateVersion 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在介质层面完全没有恢复:被截断、被手改或版本被 bump 的 session_projcache.json 会让 openJsonUnit 以 malformed-medium/version-mismatch 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 invalid-record 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 SessionProjectionCache[Service.init],在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 workspace.json 却是正确的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
Proposal
两个独立改动,一个缺口一个。
全局唯一存储根,构造时 resolve 一次
AppCLIEntry.composePatches的 Source 0 追加把storage-json.rootpatch 到join(resolveDshHome(), 'storages')——默认~/.dsh/storages,与~/.dsh/sessions并肩——并且PROFILE_MAPPINGS增加storageRoot→(storage-json,root),与persistenceRoot完全镜像。yml 保留./.storages作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。JsonStorageBackend在构造时对配置根resolve一次,原样采纳 JSONL 后端已记录的理由:后续process.cwd()变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。- 适用 pre-release 立场:不做迁移垫片。曾在
<cwd>/.storages下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
声明派生介质:损坏时重置而非拒绝
DomainSpec增加recovery?: 'reject' | 'reset'(默认'reject')。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。session_projcache声明'reset';workspace保持默认。KvFacet增加一个原语:destroy(descriptor): Promise<void>——整体移除该 unit 的介质(json:删文件;sqlite:drop 该 unit 的表)。与open一样,它是后端存储原语,不是策略。DomainFacility.open在 spec 声明'reset'且 open 恰以损坏类错误失败时——StorageError('version-mismatch' | 'malformed-medium')或DomainError('invalid-record')——记一条命名该域和被丢弃介质的警告,调用destroy,再空开一次。其余一切失败(backend-not-found、facet-unsupported、already-open、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。- 有了这个,缓存域 spec 的 version 字段才获得其本意:bump
version(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。
Alternatives considered
保持按启动目录的 .storages(现状)——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
只把投影缓存的 route 指到全局根,workspace.json 留在 per-cwd——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 workspace.json 旁边——一个 hub 根让介质同址、心智模型单一。
缓存插件本地恢复(在 SessionProjectionCache[Service.init] 捕获损坏错误、删文件、重开)——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。
损坏时退到内存态临时域——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。
把损坏介质改名旁置(<unit>.json.corrupt-<ts>)而非删除——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个权威域想要重置语义,旁置改名才是对的——这正是 recovery 按 spec 声明的理由。
所有域一律自动重置(不加 spec 字段)——断然拒绝:workspace.json 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。
Acceptance criteria
- 从任意目录启动
dsh都读写同一份$DSH_HOME/storages/*.json(默认~/.dsh/storages);profile 键storageRoot可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的./.storages,并在后端构造时 resolve 一次。 session_projcache.json被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。- 同样的损坏发生在
workspace.json上仍大声拒绝启动。 - facility 测试覆盖:每个损坏类恰好重置一次
'reset'域;非损坏失败在'reset'域上保持大声;'reject'域传播一切失败;destroy在两个出厂后端上都移除介质。
Risks
- 错误分类失误导致自动删除健康文件。 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
- 根迁移改变既有 checkout 的查找位置。 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd
workspace.json内容的人记录了一次性手动搬移。 destroy是存储 seam 上新增的破坏性原语。 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。