Files
Agent-System/.doc/audit/2026-09-14-dev-runtime-data-root-split-brain.md
T

8.4 KiB
Raw Blame History

title, status, created, updated, severity
title status created updated severity
dev rebuild 从临时 worktree 挂载空数据根导致正文投影 409 done 2026-09-14 2026-09-14 critical

dev rebuild 从临时 worktree 挂载空数据根导致正文投影 409

现象

用户刷新项目 143 后,前端请求:

GET /api/v1/docgov/documents/doc_proj143_intake/content 409
DOCUMENT_CONTENT_PROJECTION_MISSING
docgov: document content projection missing

错误发生在 projectDocuments.ts → controlledDocHydrate.ts 的受控正文水合链。它不是并发编辑的 HEAD_CONFLICT,也不是此前已修过的 canonical hash drift。

本轮只做只读现场核对与整改设计:没有操作用户会话、没有修改数据库/正文、没有重建共享容器。

结论

直接根因是 dev 应用、PI Runner、Cursor Runner 被重建成挂载临时集成 worktree 的空 data/dev,而 PostgreSQL 仍沿用原 dev 持久卷,形成数据库与文件事实源的 split-brain:

PostgreSQL persistent volume
  document_records.work_dir=/app/data/workspaces/bizdev/intake_1789270446856290128
                     │
                     ├─ 主仓 data/dev:output.md 存在,SHA 与 DB/head 一致
                     │
                     └─ 当前容器 /app/data:绑定 /tmp/as-dev-wf409-merge/data/dev,文件不存在

loadControlledDocumentContent 先取得账本 head,再调用 InspectProjection;容器内看不到 output.md,因此正确分类为 MISSING 并返回 409。前端只是如实暴露后端错误,不是本次根因。

现场证据

E1 — 容器挂载源是临时 worktree

2026-09-14 08:54 UTC 的 docker inspect shuimu-agent-app-dev:

项 现场值
Compose working dir /tmp/as-dev-wf409-merge
Compose config /tmp/as-dev-wf409-merge/docker-compose.dev.yml
app /app/data source /tmp/as-dev-wf409-merge/data/dev
pi-agent /app/data/workspaces source /tmp/as-dev-wf409-merge/data/dev/workspaces
cursor-agent /app/data/workspaces source /tmp/as-dev-wf409-merge/data/dev/workspaces
app created 2026-09-14T08:40:44Z

数据库容器的数据仍来自命名卷 agent-dev-context-pgdata,不会随 Compose 工作目录切换。由此产生“旧数据库 + 新空文件根”的不一致组合。

E2 — 项目 143 数据本身当前完整

document_records:

字段 值
doc_id doc_proj143_intake
work_dir /app/data/workspaces/bizdev/intake_1789270446856290128
content_sha256 f283ba94c9c037897f021960f51abacee7242c3db8d10f9f2a5e32cde6acf039
updated_at 2026-09-14 05:14:10.302146+00

账本 open version 1 的 head snapshot 302 是人工修订,SHA 同为 f283...f039;主仓文件:

/home/weichen/projects/Agent-System/data/dev/workspaces/bizdev/intake_1789270446856290128/output.md
size=40075
sha256=f283ba94c9c037897f021960f51abacee7242c3db8d10f9f2a5e32cde6acf039

同一路径在当前 app 容器内 stat 返回 No such file or directory。所以无需再次对项目 143 做内容修复;需要恢复正确数据根挂载。

E3 — 影响不是单文档

对 dev 数据库 100 条“非模板且 work_dir 非空”的记录做只读比较:

根目录 aligned missing
主仓 /home/weichen/projects/Agent-System/data/dev 32 68
当前容器挂载 /tmp/as-dev-wf409-merge/data/dev 0 100

主仓下的 68 条 missing 包含历史测试/临时路径,不能直接等同于 68 个真实用户缺陷;但当前容器对本次抽样 100/100 都不可见,足以确认环境级事故。恢复前任何真实文档读取或 PI 写入都不可信。

错误根在容器启动后已出现 27 个文件,其中 9 个 CAS blob 的 mtime 为 08:40:45 UTC;虽然 08:54 UTC 尚未找到新的 output.md,错误根已经不是可直接丢弃的“绝对空目录”,恢复前仍须做完整差异保全。

只读核对显示 test/main 当前 app 与 PI Runner 仍挂主仓 data/test、data/main,本轮没有观察到同样的运行事故;但其 Compose 仍使用相对 bind,仍受同一设计缺陷影响。

E4 — 健康检查假阳性

同一时刻 /health 与 /ready 都返回 200,只检查数据库、schema 和 task store;未验证:

  • 容器绑定的数据根身份;
  • app 与 runner 是否挂载同一根;
  • 活跃项目的正文投影是否可达;
  • 数据库记录与文件根是否属于同一环境实例。

因此“healthy + schema 33”不能证明文档运行态可用。

根因分析

R1 — Compose 相对 bind mount 与调用 worktree 耦合(直接根因)

docker-compose.dev.yml 使用:

- ./data/dev:/app/data
- ./data/dev/workspaces:/app/data/workspaces

Compose 按 project working directory 解析相对路径。scripts/env-service.sh 又把 ROOT_DIR 定义为脚本所在 worktree 并 cd 进去,所以从任何复制/linked worktree 执行 dev rebuild,都会把该 worktree 自己的 data/dev 当成运行数据根。

R2 — 纪律是文档约束,不是可执行门禁

scripts/agent-worktree.sh 只打印“禁止在此目录运行 env-service”,AGENTS.md 也规定 worktree 不得操作共享 Docker;但 env-service.sh 没有检测 linked worktree、主 worktree、分支、数据根或集成槽位。一次误调用即可静默替换全套容器挂载。

R3 — 数据库与文件没有共享存储身份

数据库使用稳定 named volume;正文使用随调用目录变化的 bind mount。两者没有共同的 environment/storage UUID,也没有启动握手,因此任意一个被指向另一份状态时,进程仍可启动并连接成功。

R4 — 账本权威仍被兼容投影阻断

当前设计声明 ledger head 是权威正文,但 GET 在账本存在时仍要求 output.md 投影必须存在且 aligned。于是兼容投影丢失会阻断 UI 读取权威账本。该 fail-closed 对真实 fork 有价值,却把“可安全重建的 missing”与“内容分叉”按同样的可用性后果处理。

R5 — 正文更新仍是非原子双写

人工 PATCH 先提交 PostgreSQL 账本,再单独写 output.md;候选 commit/reject 也采用 DB 状态与文件投影分步更新。失败后只依赖同幂等键重试,没有持久 projection job/status 和后台补偿。即使修复本次挂载问题,进程崩溃、权限错误或磁盘故障仍可能再次留下 head 已更新、投影缺失的状态。

R6 — 发布验收没有“从不同源码目录 rebuild 后仍接回同一状态”的负例

既有真实 UI 验收验证过 restart 后恢复,但 restart 不会重新解析 Compose bind mount;本次事故由 rebuild/recreate 触发。现有门禁没有覆盖 linked worktree、临时集成目录或错误数据根的启动拒绝。

为什么会“每次修改后又出现”

修改本身并不会删除项目 143 正文。本次时间线表明:最新人工修订和主仓 output.md 在 05:14 UTC 已一致落盘;08:40 UTC 后续 dev rebuild 把运行容器切到另一份空数据根,刷新才出现 409。

因此用户感知的“改完再刷新就坏”来自两个事件经常相邻:开发修改 → Agent 在临时 worktree 合并/rebuild → Compose 改绑数据目录。只修某个文档、某个 hydrate effect 或某次 hash 漂移,都无法阻止它复发。

严重度与影响

critical:

  • dev 环境所有受控正文、工作流产物和 runner 工作区均可能不可见或写入错误根;
  • 错误根上的新输出可能与主仓状态分叉;
  • health/ready 仍为绿色,集成报告可能产生假 PASS;
  • test/main 使用同类相对 bind 写法,设计缺陷可横向复制,虽本轮未观察其被错挂载。

关联整改

开放问题

  • 恢复前需再次冻结并备份错误根,确认 08:40 UTC 后是否已有新会话、附件、runner 输出或 agent session 写入;08:54 UTC 抽样未发现 output.md,不能替代完整差异清单。
  • test/main 当前挂载源需在实施 Wave 0 只读核对;本轮未扩大操作范围。