# 节点模型 · 定稿 > 这份文档定的是**概念形状**(字段、状态机、面)。它取代 `README.md` 里 > 那套"两盏灯 + 断言谓词"的模型 —— 那套是过渡形态,讨论下来收敛到了这里。 > > 实现状态:**已实现**(见 README)。 > > 定稿时有两处留白,实现时取的是: > > | 留白 | 取法 | > |---|---| > | 已完成节点记在哪 | **权威在 `.bg/nodes.json`**,commit trailer 是**证据**(可重放 + 防篡改) | > | `base` 不给时 | **报错,必须显式给** —— 它是节点属性,默认值会让 diff 的语义变模糊 | > ## 权限:凭证(capability) > > 正文那张表只写了"根靠机器、中间靠可见性"。它没回答 > **"不同的 agent 怎么分"** —— 中间节点对任何 agent 都是随意改的。 > > 补上的形状是**凭证,像目录权限,而且向下包含**: > > ``` > 持有 X 的凭证 -> 能改 X 的**所有后代** > 改 X 自己 -> 需要 parent(X) 的凭证 <- 这就是「向创建者提权」 > 根节点 -> 没有父,只能由人签发 > 已达成 -> **冻结,任何凭证都改不动** > ``` > > 为什么是凭证而不是身份:工具核验不了一个 subagent 报上来的 id。 > **谁拿出凭证谁有权**,不问你是谁。库里只存 hash,明文只在创建时给一次。 > > **它防的是误伤,不是恶意** —— 和"绕过只能发现不能禁止"是同一条线。 --- ## 一句话 **节点是「意图」,commit 是「证据」。** 节点先存在(你要达成什么),commit 后出现(你做完了什么)。 两者不是相等关系,而是**达成关系**:节点达成时,留下一个 commit 作为证据。 --- ## 为什么不是"节点 = commit" 我们一度以为可以。它不成立,而且原因很干净: ``` 节点 有"目标"意味 -> 一开始就该有(根节点在写任何代码之前就存在) commit 是提交结果 -> 最后才出现 ``` 如果让节点等于 commit,那节点在**做完之前不存在** —— 而"目标"恰恰是做完之前的东西。 **但绑定本身是对的。** 只需要把"相等"改成"达成": ``` 节点达成 -> 产出一个结果 commit 这个 commit 就是它的证据,永久、可重放 ``` --- ## 一个绿灯就够 之前需要两盏灯,是因为**"绿"有歧义**:它说的是"当时验过",还是"现在还对"? 现在没有歧义了: ``` 绿 = 一个关于【某个 commit】的事实 那个 commit 不可变 -> 这个事实永远不会过时、也不会被推翻 ``` ``` checkout 那个 commit 跑 (Δ, P) -> 结果永远一样 ``` **绿变成可重放的。** 第二盏灯因此没有存在理由。 > **绿只对 commit 说,不对工作区说。** > 工作区有未提交改动时,**没有任何绿能代表它** —— 这时要明说 > "当前状态没被验过",不许拿最后一个绿来顶。 --- ## 节点 ```yaml id: n7 parent: n3 # 树结构;根节点的 parent = null owner: user | model # 见「权限」 expect: "让导出支持非 ASCII" # 目标;也是 commit message base: # 【初始 commit】 delta: # 预期变化:精确集合,可空 - { code: M, path: src/export/csv.py } - { code: A, path: src/export/encoding.py } - { code: D, path: src/export/legacy.py } delta_source: before-work | at-commit # 是预测还是转录(见下) verify: "pytest -q" # 【检测程序】必须有 result: | null # 达成后填 state: todo | done | abandoned # 达成时才有: verified_tree: # P 是在**这个树**上跑过的 accepted_at: ``` ### `delta` 的四个性质 **① 它是精确集合,带方向。** ``` M / A / D / R 改了 / 新增 / 删除 / 重命名 ``` 一次比较同时回答三件事: ``` diff(base, result) 和 Δ 比: 少了 -> 漏做 多了 -> **预期外的改动** 类型不符 -> 做错方向(该删的改了、该加的删了) ``` **② `Δ` 不是"作用域"。** 精确集合本身就说了"必须发生的"和"允许发生的",所以不需要另一个 `allow`。 **③ 它可以为空,而且空 = 没有预测。** ``` Δ 有值 -> 约束:实际必须**精确等于**它 Δ 为空 -> 只有 P 在承重,没有任何东西防止意外改动 ``` **④ 它可以是"预测",也可以是"转录" —— 但必须标出来。** ``` before-work 动手前声明的 -> 真的能拦住方向性错误 at-commit 提交时填的 -> 只能拦住"忘了说",因为它就是照着 git status 抄的 ``` **两者都不禁止,但强度不同,而且不同要可见。** `delta_source` 进 trailer,渲染时标出来。 --- ## 门禁 **一次提交 = 一次门禁。不通过,不许 commit。** ``` node_commit(n): 1. 固定工作区:算它的树 Y 2. 查 Δ:diff(base, Y) 精确匹配 n.delta(为空的场合跳过) 3. 跑 P:在 Y 上跑 n.verify 全部通过 -> commit,trailer 记下证据 任何一条不过 -> **不提交**,逐条给出"要求" ``` **顺序很重要**:必须**先固定树、再跑 P**,否则 ``` 跑 P -> 通过 [这中间工作区又变了] commit -> 提交的是另一个状态,而 trailer 说它通过了 ``` ### 三种结果,不是两种 ``` ok 确实满足 fail 确实不满足(附一条"要求",模型能照着修) unknown **不知道** —— 观测能力受限(比如读不到进程命令行) ``` `unknown` **不算通过**。它必须能被表达出来,否则"看不见"会被读成"没问题"。 --- ## 权限 | | 谁定 | 模型能否改 | 靠什么保证 | |---|---|---|---| | **根节点的 `Δ` / `P`** | 人和模型**约定** | **不能** | **机器**(冻结 + owner=user) | | **子节点的 `Δ` / `P`** | 生成它的那个 | **随意改**(操作自由) | **可见性** | | 废弃的尝试 | —— | 扔掉 / 留着走弱 P | 可见性 | 两个机制各管一段: ``` 机器保证 -> 每个节点自己的 P(过了才算数,谁写的都得过) 可见性 -> 一切(谁生成的、P 是什么、Δ 是什么) ``` **所以中间节点的绿不承诺任何东西 —— 承诺在根上。** 这是设计,不是缺陷。 ### 冻结 **达成(`done`)之后,门禁冻结,连人也不能直接改。** 要变,起一个**新节点**把它做出来 —— 历史不改写,旧节点保持绿。 它的绿是对**它那个 commit** 的事实,不该被后来的事推翻。 ### 可见性是唯一的问责机制,所以它得真的可见 ``` ① P 必须跟着版本走(进 trailer),不能只活在工具的运行时 ② 弱 P 要能被机器判出来的那种就标出来: P 在基线时就通过 -> ⚠ 它区分不了你做没做 ③ 展示时 P 的**原文**要看得见,不能只显示"通过" ``` 不拦,但不隐瞒。这是"模型自决"能站住的唯一方式 —— **自决不等于无痕**。 --- ## 所有节点一视同仁 > **这一节取代了原稿的「向根负责」。** 那套模型是: > > 节点的验证 = 自己的 (Δ, P) + 【根门禁的 P】 > > 它被删掉了。理由见下。 **根 P 和子节点的 P 是同一种东西** —— 都只是"这件事做对了"的证明: ``` 根节点 P = "整体可用" 子节点 n1 P = "n1 这件事做对了" ``` 根不特殊,只是它恰好验的是"整体"。**它只在自己提交时跑,不下来压每个子节点。** 原稿的毛病: ① **把"整体可用"变成了每个子任务的义务。** 子任务只该对它自己那件事负责 —— 拿整体去卡一个局部改动,不是这套东西的哲学。 ② **"根"被迫特殊化。** 取哪个根?`rootNode()` 只能 `find` 第一个 —— 而一个仓库可以有任意多个任务根(每个任务一个)。 现在: > **任何节点达成时,只跑它自己的 P。根也一样。** ### 代价 **"整体可用"不再是自动保证的。** 它变成根的 P 的责任:根拿到一个好用的 base, 它的 P 保证整体可用。想要整体保证,就建一个 P 是整体验证的节点。 这不是退化,是把那个决定权交回给声明者。 > 连带:`bg-root-verify` trailer 字段不再写(老节点保持原样,历史不改写); > `bg recheck` 里那段"复查根 P"也删了 —— 它本来就不约束任何节点, > 却只挑某一个节点的 P 来跑,自相矛盾。 --- ## 合并 合并**也是一次提交**,所以它**也是一个节点**。但它和普通节点有两处不同: ``` 没有单一的 base -> 它有多个父 不用 Δ -> 无冲突时 Δ 可推导;有冲突时解决是刻意的 ``` 所以合并节点的门禁是: ``` node_commit(合并节点): 1. 结果版本 Y_M 是**所有**父结果版本的后代 2. 每个父的 Δ,在**它自己碰过的路径上**,合并后仍然成立 3. P 在 Y_M 上通过 ``` **第 2 条是必需的,而且不是 Δ。** 因为: ``` git merge-base --is-ancestor X_A X_M -> ✓ 通过 git diff X_A X_M -- feature.py -> M feature.py ← A 的成果没了 ``` **"X_A 是祖先"只证明历史里包含 A,不证明 A 的内容还在。** 解决冲突时可以把一个分支的成果整个撤销掉,而祖先检查看不出来。 第 2 条就是防这个:**子节点的成果不许在合并里静默消失。** ### 语义冲突没有机械解 ``` A:把 total() 改成返回字符串 B:加了个 report(),依赖 total() 返回数字 -> 0 个冲突文件、两边各自的门禁都过、合并后 report([1,2]) == "33" ``` **这个只能靠第 3 条兜(P 在合并结果上通过),而且 P 得够全。** 合并出的绿是**新的**绿,不是两个绿的相加。 **语义正确性由拆解任务的那个模型负责** —— 是它选择这么切的。 系统能做的只有:要求合并节点有 P,而且 P 要在结果上真跑。 --- ## 状态机 ``` node_plan | v ┌────────┐ │ todo │ 声明了,没有 commit └───┬────┘ node_commit | (Δ 匹配 且 P 通过) v ┌────────┐ │ done │ 有一个结果 commit;绿;门禁冻结 └────────┘ 任何时刻(未 done): node_abandon -> abandoned(从图里移出) ``` **派生出来的显示状态**(不存储,算出来的): ``` todo + 工作区相对 base 无改动 -> 待办 todo + 工作区相对 base 有改动 -> 进行中 done -> 绿 abandoned -> (不在图里,但报个数) ``` --- ## commit trailer(证据的形状) 达成时写进 commit message 的 trailer: ``` 让导出支持非 ASCII bg-node: n7 bg-owner: model bg-parent: n3 bg-base: 9330fc81… bg-delta: M:src/export/csv.py A:src/export/encoding.py D:src/export/legacy.py bg-delta-source: before-work bg-verify: pytest -q bg-verified-tree: 4db1466a… ``` **为什么要记 `verified-tree`**:commit 是不可变的,**但可以被 amend**。 ``` 走门禁 commit 了 V(带 trailer:"P 通过") git commit --amend 改内容(保留 message 和 trailer) -> V' 内容变了,trailer 还写着"P 通过" -> 一个伪造的绿 ``` 校验时比对 `commit^{tree} == bg-verified-tree`,不等就是改过。 (不能记 commit 自己的哈希 —— 鸡生蛋。但树哈希可以在 commit **之前**算出来。) **这一条同时让证据可重放**: ``` checkout 跑 bg-verify 里那条命令 -> 结果应当一致 ``` --- ## 绕过:只能发现,但很好发现 模型有 bash,**没法禁止它裸 `git commit`**。所以不是"禁止",而是"**发现**": ``` 有 bg-node trailer -> 走门禁的 没有 -> **绕过的** ``` ``` node_tree 报: ⚠ 有 2 个提交不在图里(a1b2c3d, e4f5a6b) 它们改了 src/x.py,但没有 trailer —— 绕过了门禁 ``` **统一面 ≠ 强制面,但"绕过会被看见"就够强了。** --- ## 面(给模型的工具) **四个。** 上一版 25 个工具是负担 —— 模型会挑一个差不多的用,或者开始乱试。 | 工具 | 干什么 | 跑 P 吗 | |---|---|---| | `node_plan` | 声明一个任务(base / expect / Δ? / P) | 否 | | `node_status` | 相对 base 改了什么;**和 Δ 比差在哪** | **否**(和 `git status` 一样便宜) | | `node_commit` | **提交即门禁**:Δ + P + 根 P,过了才产生版本 | 是 | | `node_abandon` | 把一个**声明了但没做**的任务从图里移除 | 否 | ### 为什么不是"看树"和"合并"两个单独的工具 **看树** —— 不需要: ``` 状态就是版本 -> git log 就是树 模型要看历史,它本来就有 bash ``` 而且"看树"是**人的面**。人的面(树 / 复查 / 心跳)不该占模型的工具位 —— 这和旧设计的分工一致:25 个命令是给人的,模型只拿到少数几个。 **合并** —— 折进 `node_commit`: ``` 模型: git merge --no-commit <父们> 有冲突就自己解决(用 bash) 工具: node_commit 1. 检测到合并状态(MERGE_HEAD 在) 2. 祖先检查:结果必须是**所有**父的后代 3. 每个父的 Δ,在它自己碰过的路径上,合并后仍成立 4. P 在结果上通过 5. 产生一个**真正的 merge commit** ``` 实测:模型自己 `git merge --no-commit` 之后,由普通 `git commit` 产出的提交 **有两个父**,是一个真正的 merge commit。所以这一步不需要单独的工具。 ### 但 `node_abandon` 砍不掉 ``` node_plan 声明一个意图 -> 它必须被记下来(否则人看不见计划) 声明了不做 -> 那条记录必须**能被移除** ``` 否则图里永远挂着一个"待办",而图会看起来像还在做这件事 —— 正是前面修过的那个毛病(BUG-7)。 **但它只做一件事:从图里移除声明。** "把工作区撤回去"**不是它的职责** —— 那是普通的 `git reset --hard ` / `git checkout -- .`,模型有 bash,自己就能做。 工具只管"图",不管"工作区" —— 这条分工让 `node_abandon` 小到不能再小。 ### `node_status` 是这套东西的使用体验所在 因为它同时服务三件事:**写 Δ、自查、理解为什么没过**。 它的输出要**直接对着 Δ 的形状**: ``` 当前版本 4db1466 "让导出支持非 ASCII" 基准版本 9330fc81 (父节点 n3) 相对基准改了 3 个文件 M src/export/csv.py A src/export/encoding.py D src/export/legacy.py 这些改动会成为一个节点。 node_commit -m "让导出支持非 ASCII" -p "pytest -q" ``` **声明过 Δ 时**,同一个视图多一段对比: ``` 和声明的 Δ 比 ✓ M src/export/csv.py ✓ A src/export/encoding.py ✗ D src/export/legacy.py 声明了,但**没发生** + M src/util.py **多出来的 —— 预期外的改动** ``` **最后一行就是"防止预期外的改动"落地的地方。** ### 人的面(不占模型的工具位) ``` node_tree 看整棵树(灯 + 状态 + 弱 P 标记 + 图外的提交) node_tree --recheck 对当前 commit 复查各历史节点 —— 诊断,不是灯 node_health 心跳一行 node_log 从 commit trailer 读出来的历史 ``` ### `node_tree` 的样子 ``` n7 ● 改导出编码 P: pytest -q n8 ◐ 重构 loader P: go test ./... n9 ○ 更新文档 P: true ⚠ 恒真 —— 验不了任何东西 n5 ● 加编码检测 P: pytest -q ⚠ 有 2 个提交不在图里(a1b2c3d, e4f5a6b) ``` ``` ● tab 已完成(有 commit,已验证) ◐ 进行中(有工作区改动,未提交) ○ 待办 ``` ### 复查不是灯,是一次查询 一个灯丢失了"回归可见性": ``` node 5 弄坏了 node 2 的功能 node 5 的 P 如果没覆盖 -> 它绿 node 2 的灯是对它那个 commit 的事实 -> 它**还是绿** ``` 补法(和"一个绿灯"不冲突): ``` node_tree --recheck -> 对【当前 commit】重跑各历史节点的 (Δ, P) -> 指出谁现在会不通过 ``` **它是"看的时候才算"的诊断,不是节点的一个状态。** 所以灯还是一个。 --- ## 已知边界(诚实的部分) 1. **`P` 够不够全,判不了。** 那是语义问题。机器只能判"有没有"、"是不是恒真"。 2. **`Δ` 为空时,没有任何东西防止意外改动。** 只有 P 在承重。 3. **`.gitignore` 的路径树看不见。** 需要的话用 `git hash-object`(它对 ignored 路径成立)。 4. **合并的语义冲突没有机械解。** 靠合并节点的 P 兜。 5. **进程侧没有基线快照。** `proc_present` 判不了"基线时是否已经在跑"。 6. **宿主必须有可用的 `ps`。** 读不到命令行时,进程侧退化成"不知道"。 7. **网络副作用完全在观测之外。** --- ## 还没定的 **P 什么时候跑** —— 定稿取的是:**每个节点达成时跑自己的 P**,没有例外。 > 早期版本在这里多跑一层【根节点的 P】,已于后面「所有节点一视同仁」 > 一节废弃。现在没有"某个节点的 P 特殊"这回事。