# ADR 0018：快照读取路径从不等待计算

- 状态：Accepted
- 日期：2026-10-24

## 背景

一台装有 20 个工作区的 Windows 机器上，打开或刷新 webui 后整个界面会在一段时间内无响应：DevTools 里约 8 条 `POST /snapshots` 分别停在 30 s 至 1.6 min，宿主对浏览器只开放 6 条 HTTP/1.1 连接，长请求占满连接池后，连 harness 自己的请求与其他插件的取数一起排队。

实测的成因不是"子进程并发过高"：

1. **并发本来就有上限**。`lib/git.js` 的限流器是 8 槽 FIFO，所有 `runGit` 必经其内，峰值是 8 个 `git.exe`，200+ 是**排队深度**而不是并发。
2. **真正的成本在授权**。一个 cwd 的首次计算约 42–48 个 spawn，其中 27 个来自授权：每次 `authorize` 跑 `detectRepo`（3 个 spawn，本分支合并为 1 个），`validateManagedWorktree` 里还会再跑一次（3 个），而 `ensureTarget` 授权两次、每次计算前后又各授权一次。
3. **`git fetch` 抢的是同一条车道**。新 target 一出现就以 0 延迟排一条 fetch，下一个 5 s tick 每个仓库各起一条、120 s 超时，于是最多 8 条网络型 fetch 排在 `/snapshots` 的去重队列前面。
4. **请求合并并不缩短请求**。客户端把 20 条请求合成 1 条之后，服务端仍 `await Promise.all` 20 个计算，那一条请求的墙钟就是 20 个计算的总和。
5. **稳态是同一机制**。`tools/result` 与 `api-session/status` 都触发全量 `refreshActive()`；20 个工作区 × ~45 spawns ≈ 25 s 车道时间，长于 5 s 节流，所以 agent 工作期间车道永远排不空。

## 决策

1. **读取与计算分离**。`POST /snapshots` 只回答已有的事实：有快照就原样返回（`at` 已经表达新鲜度，不新增字段），从未计算过的 cwd 列入响应顶层 `pending`，被调度到后台，由 SSE 推送补齐。响应的代价与**计算**数量无关；**授权仍在响应路径上**——冷进程上每个尚未见过的选择器仍要付一次发现，见后果「冷进程仍有 O(N) 次发现」。所以本决策承诺的是「永不等待计算」，不是「在任何状态下都与工作区数量无关」。
2. **只有显式刷新才等待**。客户端动作后的刷新发 `{ fresh: true }`，服务端保留原来的"算完再答"语义；启动与侧栏路径一律不等待。
3. **车道分级**（`lib/lane.js`）：前台=有请求在等，后台=无人等（重算、周期 fetch）。前台永远先开工，后台在无人排队时仍可用满容量；后台等待超过 10 s 会被提升到前台队首，避免持续请求把 fetch 饿死。分类看的是**有没有 HTTP 响应在等这份工作**，不是操作类型——同一次计算，徽章波次踢出来的算后台，用户点出来的算前台。由此：不等待计算的读取路径（`/snapshots` 的默认形）**从授权到调度全程后台类**，而 `{ fresh: true }`、`/snapshot`、`/actions` 这些真的在等的请求全程前台类。
4. **读取路径的成本要降到与"不等待"相称**：发现字符串缓存（ADR 0013 修订 2）、单次 `status --porcelain=v2 --branch` 取代 HEAD/status 两个探测、per-repo 事实 TTL（默认分支、origin URL 5 min；比较基 ref 60 s，fetch 完成即失效）、fetch 首见延迟 10 s 且每 tick 最多起 2 条（**只限速率，不硬限并发**，见后果最后一条）。
5. **稳态按仓库族收窄**：`tools/result`/`api-session/status` → Session → cwd → 仓库族，只重算该族（`invalidateFamily`）；解析不出时退回带节流的全量刷新，另加每分钟最多 4 个 target 的轮转扫描兜住「watcher 静默失效」这一类不可观测事件。
6. **只读查询不进变更门**：`GET /worktrees/deferred` 只枚举墓碑文件，移出 `mutations.run`；`finalize-delete` 仍在门内并在门内复验 token，所以与归档并发时列表只是过期，不会不可靠（ADR 0011 串行化的是变更，不是读）。

## 后果

- **读取路径的承诺变成"立刻回答"**，代价是"立刻正确"由 SSE 保证：`/snapshots` 的响应只对已缓存的工作区权威，未缓存的靠推送补齐。客户端把缺失键当作"尚未知道"而不是"非 git"，因此徽章不会先消失再出现。
- **冷进程仍有 O(N) 次发现**。发现缓存是**进程内**的（TTL 30 s，见 ADR 0013 修订 2），客户端轮询 15 s，于是同一台打开的界面每 30 s 会重发现一轮；插件刚加载、或页面关得比 TTL 更久之后第一次打开时，缓存是空的。N 个工作区因此是约 N 个 spawn 的冷代价和每 30 s 约 N 个 spawn 的稳态代价（合并前各是 3N——`detectRepo` 曾经是三个子进程）。它们全部落在车道上，且读取路径仍不等待计算；但"响应时间与工作区数量无关"这个说法只在缓存是热的时候成立，本 ADR 与 PR 描述此前把它写成了无条件结论，已改正。
- **不等待计算的路径全程后台类**。页面加载那一批的授权也不再抢前台，代价是用户持续交互时这一批可能等到 10 s 提升上限才推进——这与"读完即答、剩余靠推送"的模型一致，也是 `lane.js` 提升机制存在的理由。
- **发现缓存引入 ≤30 s 的过期窗口**（见 ADR 0013 修订 2）：路径、身份与所有权证明仍是实时的，过期只影响"这个目录属于哪个仓库"的字符串。写入路径一律绕开它。目录被删除后在同一拼写重建、且文件系统把同一个 inode 还回来时，缓存仍可能在 TTL 内按旧答案描述它——这是一次过期的读，写入会以 409 收敛。
- **默认分支与 origin URL 有 ≤5 min 的过期窗口**：改分支名或改远端后，徽章上的默认分支／forge 身份最多滞后一轮 TTL，或在任意一次动作后的 `invalidate()` 立刻纠正。
- **首次 fetch 不再在启动瞬间发生**，因此新打开的工作区可能先显示上一次 fetch 的 ↑/↓，最多滞后一个 fetch 周期。
- **后台不再全量重算**：watcher 是主信号，分钟级轮转扫描是兜底；watcher 静默失效的检出最多滞后一分钟。
- **后台 fetch 只限速率，不硬限并发**：实现是"每 5 s tick 最多起步 `MAX_FETCHES_PER_TICK=2` 条"加 30 s 超时，所以冷启动最坏约 12 条同时在途，**不是 2 条**。真正承诺的是另外两件事：它们全部是后台类（永远排在交互式读取之后，不会像修复前那样先占住车道）、并且共用同一条 8 槽车道（`git.exe` 峰值不越过 8）。把手头在途数也硬限到 2 需要额外一个在途计数器（约 5 行：起步前加一、`finally` 里减一、循环里作闸），本次未做，文档在此也不声称——先前 PR 描述与验收标准里"fetch 并发 ≤2"的说法是错的，已按代码改正。
- 两处可观测契约变化：`/snapshots` 新增可选 `{ fresh: true }` 与响应顶层 `pending: string[]`；`/worktrees/deferred` 不再占写事务车道。快照对象与 SSE 帧的字段语义未变。

## 未验证的数字

本 ADR 与委托书里的 spawn 账（每 cwd ~42–48 个、其中授权占 27 个；修复后冷 ~12、热 ~5）是**读调用链推导**出来的，没有用计数器或 ETW 实测过。方向由验收结果佐证（响应时间与并发行为都按预期变化），但具体比例不应被当成实测数据引用。

按构造可以引用的只有三条：每个未命中的选择器 = **1 个 spawn**（`git rev-parse --path-format=absolute --show-toplevel --git-common-dir --git-dir` 一次问完，`lib/git.js`）；并发 `git.exe` ≤ 8（单车道容量）；`/snapshots` 的响应里不含任何计算。冷启动的绝对秒数、以及"在 20 个工作区的主机上冷响应要多久"，本机都**没有测**——要下结论得在真实宿主上量一次，别用这里的比例去推。

## 被否决的替代方案

- **只把并发上限从 8 降到 4–6**：并发本来就不是问题（见背景 1），降低上限只会拉长总墙钟，读请求反而更慢。
- **保留同步 `/snapshots`，只降成本**：降本后 20 个工作区仍需 ~10–16 s 车道时间，"单条 < 2 s"无法达成，界面的排队感只是变短。
- **有界阻塞（等 1.5 s 再返回部分结果）**：在 20 工作区的冷启动上仍然是"先卡 1.5 s，然后大部分还是 pending"，换不到确定性。
- **只为侧栏"可见行"首次计算**：侧栏在 DOM 中渲染全部行（注入器遍历每个 `_projectRow`），"可见"≈全部，这个分层在现有界面上不成立。
- **把发现缓存 TTL 拉长到远大于轮询间隔**（例如 5 min）：TTL 是"读到过期几何"的窗口上限，用它换 spawn 是拿读取时效换量级；`rev-parse` 合并已经拿到同量级收益，且不延长任何过期窗口。
- **冷启动时先让 `/snapshots` 返回、授权放到下一次请求**：授权是安全边界（ADR 0010），未授权的路径连"是否 pending"都不该出现在响应里，拆分它等于把门挪到读之后。
