Commit 0f22ddab132fdb466d29e6cf7ba22dcc410272e2

Authored by yibo
1 parent 54ee14eb

test(graph): 真实链路 smoke 进 CI;顺带修一段把人引向已修缺陷的过期注释

## 一、W7「零边图」不是待办 —— 它早修了,是注释在骗人

`company-service.js` 的判据注释仍写着「判据用**有没有边**」,并留着一段
「已知残留:单 org node 的最小图没有边,判据③不生效……真跑到那一步再看」。

**那个残留在 `58a2865`(08-17)就修掉了**:`orgGraphMembership` 现在的主判据是
`authored_by='planner'` 的节点存在(「orggraph-planning 提交过一张图」的直接证据,
零边也认),边数降为存量行的回落判据;守卫 `company-hire-org-layer.test.js` 的
`CO_ZEROEDGE` + `W7-fix` 两条盯着。

注释没跟着改,于是它把读的人**引向一个已经修好的缺陷**去重修一遍 —— 我就是这么被
带偏的:读到那段注释就没再往 `orgGraphMembership` 里跟,还据此往待办清单里写了一条。
这是「指路牌失效」那一族,和 CLAUDE.md 死链同型,只是方向相反(那边指向不存在的东西,
这边描述已不存在的洞)。

判据的措辞也改了:**「成型」的判据在 graph-service.orgGraphMembership(),不在这里**,
免得下一个人照注释理解。P3B 文档与 docs/README.md 的对应条目一并更正。

## 二、还上「真实路径 smoke 进 CI」那笔欠账的一半(27-P3/P4 §十 硬规矩 2)

原话是「凡是靠注入点验的能力,都要有一条真实路径的 smoke 进 CI 才算验收完成 ——
这条欠账仍未还」。此前唯一走真实路径的 `scripts/real-pm-smoke.js` 要真 API 额度、
人工跑、而且验的是 DevLoop 不是图执行。

做法是把「真 LLM」和「真代码路径」拆开:**只把最外层的 CLI 子进程换成 fixture 回放**,
setAgentExecutor / setPlanWorkGraph 一个都不调。链路完整走
advanceOne → defaultAgentExecutor → AgentManager → ClaudeCliProtocol
→ onAcpEvent → 落盘 → readNodeProduct → settleNode → spendBudget → 边界守卫。

三条断言各盯一处**假 executor 天然绕开**的接线:
· ① mock/files 真的落到节点产物目录(假 executor 直接 return,这一环整个不存在)
· ② token 用量真的从 session/result.usage 数进预算 —— **就是 §六 验收 10 卡了两轮的那一维**
· ③ boundaryGuardApplicable 在这条路径上为真(注入假 executor 时它恒为假,
     那条唯一有实际后果的分支实际零覆盖);带反向对照,注入后必须变假

反向验过:断掉①的落盘、断掉②的累加,对应用例分别变红。

为此改了一处 mock:session/result 的 usage 原来硬编码 {input:0,output:0},
现在 fixture 可指定(缺省仍是 0,既有 fixture 行为一个字不变)。
**不改这一处,token 那一维在 CI 里永远产不出数** —— 验收 10 当初就是卡在这儿。

**没还清的部分写在文档里,别当成还清了**:真 LLM 仍进不了 CI;
subgraph 那一支(defaultPlanWorkGraph)仍只有注入式覆盖;
多节点 / 回退边 / 熔断没进这条 smoke,它只推一个节点。

afterAll 清掉真实链路建出的公司态目录(gitignore 挡着不进库,但留着会让别的
用例遍历 system/agents/ 时多扫一份)。

全量:139 文件 / 1295 测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/README.md
@@ -41,8 +41,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -41,8 +41,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
41 | [27-naming-conventions.md](./todo/system-design/27-naming-conventions.md) | **命名规范**。`{作用域}-{职能}`;图节点按层级轴命名;节点名 ≠ 本体名;三种 benchmark 消歧;OKR 正名。新增 agent id / 表名 / 字段名前必读 | 41 | [27-naming-conventions.md](./todo/system-design/27-naming-conventions.md) | **命名规范**。`{作用域}-{职能}`;图节点按层级轴命名;节点名 ≠ 本体名;三种 benchmark 消歧;OKR 正名。新增 agent id / 表名 / 字段名前必读 |
42 | [27-e2e-task-execution-flow.md](./todo/system-design/27-e2e-task-execution-flow.md) | 端到端流程总览:建公司 → 下达任务 → org graph 逐节点执行 → work graph 规划与循环 → 验收收敛。含与 DevLoop 既有实现的对应关系分析 | 42 | [27-e2e-task-execution-flow.md](./todo/system-design/27-e2e-task-execution-flow.md) | 端到端流程总览:建公司 → 下达任务 → org graph 逐节点执行 → work graph 规划与循环 → 验收收敛。含与 DevLoop 既有实现的对应关系分析 |
43 | [27-example-dev-company-walkthrough.md](./todo/system-design/27-example-dev-company-walkthrough.md) | 参考场景走查(虚构的「配图工厂」),把架构跑一遍具体的数:各阶段 JSON 形态、成本估算、断点清单 | 43 | [27-example-dev-company-walkthrough.md](./todo/system-design/27-example-dev-company-walkthrough.md) | 参考场景走查(虚构的「配图工厂」),把架构跑一遍具体的数:各阶段 JSON 形态、成本估算、断点清单 |
44 -| [27-p3-p4-work-graph-console.md](./todo/system-design/27-p3-p4-work-graph-console.md) | P3+P4(原 P4 回退+Loop-Validator 已并入)图执行引擎与执行控制台 — ✅ 已实现(`c3abf4a`)。**仍留 todo 的原因**:§十 末「四条硬规矩」第 2 条那笔欠账未还 —— 引擎用例一律注入假 executor / 假 planner,**没有一条真实路径的 smoke 进 CI** |  
45 -| [27-p3b-real-run-runway.md](./todo/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— 📝 **仍在推进**。验收 7 ⛔ 真跑不成立(`readme-writer` 混进 org graph,未修);2/4/8/9 未在干净环境验。缺陷② 的根因(边界守卫误判并行会话)已于 2026-08-20 修掉,但**根因修了不等于验收点验了** | 44 +| [27-p3-p4-work-graph-console.md](./todo/system-design/27-p3-p4-work-graph-console.md) | P3+P4(原 P4 回退+Loop-Validator 已并入)图执行引擎与执行控制台 — ✅ 已实现(`c3abf4a`)。**仍留 todo 的原因**:§十 末「四条硬规矩」第 2 条那笔欠账**只还了一半** —— `graph-real-chain-smoke.test.js`(零注入、只换掉真 CLI 子进程)已进 CI,覆盖 `agent_call` 那一支;`subgraph`(`defaultPlanWorkGraph`)仍只有注入式覆盖,真 LLM 也仍进不了 CI |
  45 +| [27-p3b-real-run-runway.md](./todo/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— 📝 **仍在推进**。验收 7(work 层本体混进 org graph)✅ 已修;缺陷② 的根因(边界守卫误判并行会话)2026-08-20 修掉。**剩下的是 2/4/8/9 —— 都不是「没修」,是「没在干净环境重验」**。⚠️ 这份文档里 ⛔/🟡 的段落多为**当时的现场记录**,读之前先看头部状态行 |
46 | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) | 46 | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) |
47 | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | 47 | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 |
48 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(只有口述、无设计)、任务态清理(源码树无限增长)、`source_kind`/`legacy_path` 删列、三项「已实施未验证」。§五 是**已立起来的守卫清单**(别重复造,也别删) | 48 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(只有口述、无设计)、任务态清理(源码树无限增长)、`source_kind`/`legacy_path` 删列、三项「已实施未验证」。§五 是**已立起来的守卫清单**(别重复造,也别删) |
docs/todo/system-design/27-p3-p4-work-graph-console.md
@@ -1040,7 +1040,28 @@ agent 默认**只挂载 `meta-research` 一个元技能**,必须主动呼叫 ` @@ -1040,7 +1040,28 @@ agent 默认**只挂载 `meta-research` 一个元技能**,必须主动呼叫 `
1040 1040
1041 1. **新增任何跨环节的字段/计数,四环各写一句话在 PR 描述里**(谁产生、经谁传递、落到哪张表/哪个文件、谁读回)。十三处里有九处只要问一句「谁产生它」就能当场发现;第 10、11、12 三处则是问「**谁读回它**」才发现的——**四环要问满,不能只问产生端**。而且「读回」不止是代码读,**还包括读给人/agent 看的那一层**:第 12 处的数据一直好好躺在 verdict JSON 里,只是 CLI 没打出来,消费它的 agent 就等于没有。 1041 1. **新增任何跨环节的字段/计数,四环各写一句话在 PR 描述里**(谁产生、经谁传递、落到哪张表/哪个文件、谁读回)。十三处里有九处只要问一句「谁产生它」就能当场发现;第 10、11、12 三处则是问「**谁读回它**」才发现的——**四环要问满,不能只问产生端**。而且「读回」不止是代码读,**还包括读给人/agent 看的那一层**:第 12 处的数据一直好好躺在 verdict JSON 里,只是 CLI 没打出来,消费它的 agent 就等于没有。
1042 **第 13 处又把这一条推进了一步**:产生端**是 agent 时,「谁产生它」的答案不是一段代码,而是一份提示词**——所以问完还要再问一句「**那份提示词现在到底怎么写的**」。第 13 处的引擎侧四环无懈可击,`describeArtifactContract` 也确实注入了写法,坏就坏在 agent 自己的 `agent.md` 里有一段**返工前写的、方向相反的**粒度说明,两份指令打架时 agent 照哪份写没人知道。 1042 **第 13 处又把这一条推进了一步**:产生端**是 agent 时,「谁产生它」的答案不是一段代码,而是一份提示词**——所以问完还要再问一句「**那份提示词现在到底怎么写的**」。第 13 处的引擎侧四环无懈可击,`describeArtifactContract` 也确实注入了写法,坏就坏在 agent 自己的 `agent.md` 里有一段**返工前写的、方向相反的**粒度说明,两份指令打架时 agent 照哪份写没人知道。
1043 -2. **测试全绿 ≠ 能跑**。P3 的引擎用例(第六轮 R-17.1 返工后 19 条)至今仍一律注入假 executor / 假 planner,第 7、8、9、10 号缺陷和当时的「`planWorkGraph` 未接线」全部在覆盖范围之外——**夹具正好绕开了缺失的那一环**。第五轮补了一条不注入假实现的端到端(§六 验收 17)才把这个洞照出来,**但它是人工脚本、没进 CI**,随时会腐坏。**凡是靠注入点验的能力,都要有一条真实路径的 smoke 进 CI 才算验收完成**——这条欠账仍未还。 1043 +2. **测试全绿 ≠ 能跑**。P3 的引擎用例(第六轮 R-17.1 返工后 19 条)至今仍一律注入假 executor / 假 planner,第 7、8、9、10 号缺陷和当时的「`planWorkGraph` 未接线」全部在覆盖范围之外——**夹具正好绕开了缺失的那一环**。第五轮补了一条不注入假实现的端到端(§六 验收 17)才把这个洞照出来,**但它是人工脚本、没进 CI**,随时会腐坏。**凡是靠注入点验的能力,都要有一条真实路径的 smoke 进 CI 才算验收完成**。
  1044 +
  1045 + > **2026-08-20:这条欠账已部分还上** —— `server/tests/unit/graph-real-chain-smoke.test.js`
  1046 + > 进了 `npm run test:unit`。做法是把「真 LLM」和「真代码路径」拆开:**只把最外层的
  1047 + > Claude CLI 子进程换成 fixture 回放**(`LEMONBOSS_CLAUDE_MODE=mock`),
  1048 + > `setAgentExecutor` / `setPlanWorkGraph` 一个都不调,链路完整走
  1049 + > `advanceOne → defaultAgentExecutor → AgentManager → ClaudeCliProtocol → onAcpEvent
  1050 + > → 落盘 → readNodeProduct → settleNode → spendBudget → 边界守卫`。
  1051 + >
  1052 + > 三条断言各盯一处「假 executor 天然绕开」的接线:① `mock/files` 真的落到节点产物目录;
  1053 + > ② token 用量真的从 `session/result.usage` 数进预算(**就是本文档 §六 验收 10 卡了两轮的那一维**);
  1054 + > ③ `boundaryGuardApplicable` 在这条路径上为真(注入假 executor 时它恒为假,那条分支实际零覆盖)。
  1055 + > 反向验过:断掉①的落盘、断掉②的累加,对应用例分别变红。
  1056 + >
  1057 + > 为此改了一处 mock:`session/result.usage` 原来硬编码 `{input:0,output:0}`,
  1058 + > 现在 fixture 可以指定(缺省仍是 0,既有 fixture 行为不变)。**不改这一处,
  1059 + > token 那一维在 CI 里永远产不出数** —— §六 验收 10 当初就是卡在这儿。
  1060 + >
  1061 + > **还没还的部分,别当成还清了**:(a) 真 LLM 仍然进不了 CI(要额度、分钟级、输出不确定),
  1062 + > `scripts/real-pm-smoke.js` 仍是人工跑;(b) 这条 smoke 走的是 `agent_call`,
  1063 + > **`subgraph` 那一支(`defaultPlanWorkGraph`)仍然只有注入式覆盖**;
  1064 + > (c) 多节点、回退边、熔断这些形状没进这条 smoke,它只推一个节点。
1044 3. **回填对账的段落必须在本轮代码定稿之后重跑一遍核实**。**这条被连续犯了三轮**:第四轮代码把 `total_tokens` 接通了、同一轮回填的 §四.A.3 却还写着「死维度」(§八.11 / §六 验收 10 / §五 T8 三处一起错);第五轮真接了四处生产链路,文档头部的「第五轮修订」却只列改名与纠偏、四处接线一个字没提,于是 §五 T8/T11、§六 验收 1/12/17、§八.10/12/14、§十 表全部停在第四轮结论上(**第六轮清出至少 12 处过期**)。**「本轮开工时 grep 的结论」不等于「本轮交付时的事实」;改了行为就在同一轮把对应小节改对,别留给下一轮。** 1065 3. **回填对账的段落必须在本轮代码定稿之后重跑一遍核实**。**这条被连续犯了三轮**:第四轮代码把 `total_tokens` 接通了、同一轮回填的 §四.A.3 却还写着「死维度」(§八.11 / §六 验收 10 / §五 T8 三处一起错);第五轮真接了四处生产链路,文档头部的「第五轮修订」却只列改名与纠偏、四处接线一个字没提,于是 §五 T8/T11、§六 验收 1/12/17、§八.10/12/14、§十 表全部停在第四轮结论上(**第六轮清出至少 12 处过期**)。**「本轮开工时 grep 的结论」不等于「本轮交付时的事实」;改了行为就在同一轮把对应小节改对,别留给下一轮。**
1045 **第六轮的形态换了个方向,同样要记**:这一轮的过期不再是「代码改了、文档停在上一轮」,而是**「文档提前写成了完成态、代码只做了一半」**——§五 T11 写着「六个本体已建齐**并注册**」,实际只建了目录;§六 验收 10 / §五 T8 写着「实测 `spent.tokens` 递增且能单独触发 `exhausted`」,而 mock 协议把 `usage` 硬编码成 0,那个数用这套终验手法根本产不出来。**「我打算这样做」和「我做完了」在文档里长得一模一样,所以回填时要逐条问「这句话我是怎么验的」,验不出来的就不要写成实测。** 1066 **第六轮的形态换了个方向,同样要记**:这一轮的过期不再是「代码改了、文档停在上一轮」,而是**「文档提前写成了完成态、代码只做了一半」**——§五 T11 写着「六个本体已建齐**并注册**」,实际只建了目录;§六 验收 10 / §五 T8 写着「实测 `spent.tokens` 递增且能单独触发 `exhausted`」,而 mock 协议把 `usage` 硬编码成 0,那个数用这套终验手法根本产不出来。**「我打算这样做」和「我做完了」在文档里长得一模一样,所以回填时要逐条问「这句话我是怎么验的」,验不出来的就不要写成实测。**
1046 1067
docs/todo/system-design/27-p3b-real-run-runway.md
@@ -6,7 +6,8 @@ @@ -6,7 +6,8 @@
6 > **起因**: [27-REQ](./27-requirement.md) R-18(招聘归 `workgraph-planning`;停用 mock 改真 CLI) 6 > **起因**: [27-REQ](./27-requirement.md) R-18(招聘归 `workgraph-planning`;停用 mock 改真 CLI)
7 > **前置**: P3 主体已实现并提交(`c3abf4a` 起七轮),测试 719/719 全绿 7 > **前置**: P3 主体已实现并提交(`c3abf4a` 起七轮),测试 719/719 全绿
8 > **状态**(2026-08-20 校准): 📝 **仍在推进**。第一批验收 1/3 已验、2 的后半与 4 未在干净环境重验; 8 > **状态**(2026-08-20 校准): 📝 **仍在推进**。第一批验收 1/3 已验、2 的后半与 4 未在干净环境重验;
9 -> 第二批验收 7 ⛔ 真跑不成立(§六bis 缺陷③ `readme-writer` 混进 org graph,**未修**); 9 +> 第二批验收 7 ✅ **已修**(`58a2865`「W7 零边图」,守卫 `company-hire-org-layer.test.js` 的
  10 +> `CO_ZEROEDGE` + `W7-fix`)—— 下面 §六bis 缺陷③ 那段是**当时的现场记录**,不是当前状态;
10 > 第三批 8/9 真 CLI 未验。 11 > 第三批 8/9 真 CLI 未验。
11 > §六bis 缺陷② 的**根因**(边界守卫误判并行会话的改动)已于 2026-08-20 修掉, 12 > §六bis 缺陷② 的**根因**(边界守卫误判并行会话的改动)已于 2026-08-20 修掉,
12 > 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md) —— 但**根因修了不等于验收点验了**,那几条仍要重跑一遍。 13 > 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md) —— 但**根因修了不等于验收点验了**,那几条仍要重跑一遍。
@@ -776,7 +777,12 @@ agent 读的是 `agent.md`,那一侧不改,模型就会继续以为「前三 @@ -776,7 +777,12 @@ agent 读的是 `agent.md`,那一侧不改,模型就会继续以为「前三
776 5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。 777 5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。
777 6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 778 6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。
778 7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。 779 7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。
779 - ⛔ **真跑不成立**(2026-08-17 第三次真 CLI 终验,见 §六bis 缺陷③):三条判据都盖不住 780 + ✅ **已修**(`58a2865`「W7 零边图」,2026-08-17 当天)。判据加了主判据
  781 + `authored_by='planner'` 的节点存在 —— 「orggraph-planning 提交过一张图」的直接证据,
  782 + 零边也认;边数降为存量行的回落判据。守卫 `company-hire-org-layer.test.js` 的
  783 + `CO_ZEROEDGE`(零边图上 work 层本体不入图)+ `W7-fix`(零边图上图里点名的 org 节点
  784 + 重复雇仍入图,P2 语义不被误伤)。
  785 + **以下是修之前的现场记录,留档不是当前状态**:三条判据都盖不住
780 「零条边的最小 org 图 + 一个既非 `meta-*`、又没在 work graph 骨架里点过名的本体」, 786 「零条边的最小 org 图 + 一个既非 `meta-*`、又没在 work graph 骨架里点过名的本体」,
781 `readme-writer` 被真的写进了 `org_graph_nodes`,`GET /org-graph` 返回两个节点, 787 `readme-writer` 被真的写进了 `org_graph_nodes`,`GET /org-graph` 返回两个节点,
782 下一次任务的左栏并排显示两张 org 卡片。 788 下一次任务的左栏并排显示两张 org 卡片。
fixtures/devloop-mock/prd-planning.json
1 { 1 {
2 - "_doc": "P3 mock fixture · work graph 里的 wg-plan 节点本体。files 经 mock/files 事件落到该节点的产物目录(graph-engine.defaultAgentExecutor 接收),attempt-{attempt}.json 是引擎 readNodeProduct 要读的那份。verdict=no_prior_failure 表示「本轮没有前序失败要归因」,节点判 success。", 2 + "_doc": "P3 mock fixture · work graph 里的 wg-plan 节点本体。files 经 mock/files 事件落到该节点的产物目录(graph-engine.defaultAgentExecutor 接收),attempt-{attempt}.json 是引擎 readNodeProduct 要读的那份。verdict=no_prior_failure 表示「本轮没有前序失败要归因」,节点判 success。|usage 是给 graph-real-chain-smoke 用的:让「token 用量」这一维在 mock 下也产得出来(缺省 0 时那条链只有真 CLI 才验得了)。",
3 "chunks": [ 3 "chunks": [
4 "[mock] prd-planning 读取 task-goal 与共享产物。\n", 4 "[mock] prd-planning 读取 task-goal 与共享产物。\n",
5 "输出需求要点 3 条,写 prd.md。\n" 5 "输出需求要点 3 条,写 prd.md。\n"
@@ -14,5 +14,9 @@ @@ -14,5 +14,9 @@
14 "path": "prd.md", 14 "path": "prd.md",
15 "content": "# PRD(mock)\n\n- 要点 1:提供一个可运行的最小接口\n- 要点 2:错误输入要有明确响应\n- 要点 3:验收以测试脚本为准\n" 15 "content": "# PRD(mock)\n\n- 要点 1:提供一个可运行的最小接口\n- 要点 2:错误输入要有明确响应\n- 要点 3:验收以测试脚本为准\n"
16 } 16 }
17 - ] 17 + ],
  18 + "usage": {
  19 + "input": 1200,
  20 + "output": 340
  21 + }
18 } 22 }
packages/acp-core/protocol/ClaudeCliProtocol.js
@@ -632,7 +632,11 @@ class ClaudeCliProtocol extends EventEmitter { @@ -632,7 +632,11 @@ class ClaudeCliProtocol extends EventEmitter {
632 cost_usd: 0, 632 cost_usd: 0,
633 session_id: this.sessionId, 633 session_id: this.sessionId,
634 stop_reason: 'end_turn', 634 stop_reason: 'end_turn',
635 - usage: { input: 0, output: 0 }, 635 + // usage 允许 fixture 指定。**这一维原来在 mock 下恒为 0**,于是
  636 + // 「谁来数 tokens」那条链(ClaudeCliProtocol → onAcpEvent → spendBudget)
  637 + // 只有真起 CLI 才验得了 —— 27-P3/P4 §六 验收 10 就卡在这儿。
  638 + // 缺省仍是 0,既有 fixture 行为一个字不变。
  639 + usage: fixture.usage || { input: 0, output: 0 },
636 model: 'mock', 640 model: 'mock',
637 mock: true, 641 mock: true,
638 files_produced: Array.isArray(fixture.files) ? fixture.files.length : 0, 642 files_produced: Array.isArray(fixture.files) ? fixture.files.length : 0,
server/services/company-service.js
@@ -119,22 +119,30 @@ function buildAgentDefinitionBrief({ agentId, bodyPath, description, intro }) { @@ -119,22 +119,30 @@ function buildAgentDefinitionBrief({ agentId, bodyPath, description, intro }) {
119 // (planner-validator / validator-triager / benchmark-validator / testdata-validator, 119 // (planner-validator / validator-triager / benchmark-validator / testdata-validator,
120 // 这些 type='validator' 的本体判据①盖不住,而 org-validator 只当 ownerOrgNode 出现、 120 // 这些 type='validator' 的本体判据①盖不住,而 org-validator 只当 ownerOrgNode 出现、
121 // 不是 nodes[] 里的 systemAgentId,所以不会被误伤)。 121 // 不是 nodes[] 里的 systemAgentId,所以不会被误伤)。
122 -// ③ 公司已经有一张**成型的**组织工作流图(存在 active 的 org_graph_edges),  
123 -// 而这次雇的角色不在图上 → 不入图:图一旦成型,作者就是 orggraph-planning  
124 -// (POST /org-graph),hire() 不再往上追加节点。这条兜住 ①② 都不认识的、  
125 -// 按 executor.json 的 {{implAgent}} / {{validatorAgent}} 动态选出来的本体。  
126 -// 判据用「有没有边」而不是「有没有节点」,因为 P1/P2 的 hire 自己就会造节点,  
127 -// 拿节点当信号会把「同一家公司连雇两个人,第二个不入图」这条 P2 语义废掉  
128 -// (server/tests/integration/graph-agent-p1-hire.test.js 就是这么用的)。 122 +// ③ 公司已经有一张**成型的**组织工作流图,而这次雇的角色不在图上 → 不入图:
  123 +// 图一旦成型,作者就是 orggraph-planning(POST /org-graph),hire() 不再往上追加节点。
  124 +// 这条兜住 ①② 都不认识的、按 executor.json 的 {{implAgent}} / {{validatorAgent}}
  125 +// 动态选出来的本体。
  126 +//
  127 +// **「成型」的判据在 graph-service.orgGraphMembership(),不在这里** —— 改判据去那儿改,
  128 +// 别照这段注释理解。它现在是两条:
  129 +// · 主判据 `authored_by='planner'` 的节点存在(`writeGraph` 是唯一写它的地方)
  130 +// —— 这是「orggraph-planning 提交过一张图」的**直接**证据;
  131 +// · 回落判据 active 的 `org_graph_edges` 存在 —— 只为该列之前的存量行(authored_by 为 NULL)。
  132 +//
  133 +// ⚠️ **回落判据曾经是唯一判据,那是个真的洞**(P3B W7):单 org node 的最小图零条边,
  134 +// 三条判据全落空 → `readme-writer` 这类占位符现填的本体被真的写进 `org_graph_nodes`,
  135 +// 控制台左栏并排显示两张 org 卡片。修在 `58a2865`,守卫
  136 +// `company-hire-org-layer.test.js` 的 `CO_ZEROEDGE` + `W7-fix` 两条。
  137 +// **别拿节点数当信号**:P1/P2 的 hire 自己就会造节点,那会把「同一家公司连雇两个人,
  138 +// 第二个不入图」这条 P2 语义废掉(`graph-agent-p1-hire.test.js` 就是这么用的)——
  139 +// `authored_by` 能分开「planner 画的」和「hire 顺手记的」,节点数不能。
  140 +//
129 // 顺带修掉一个老问题:org 节点 role_name='org-coder' 绑定本体 'coder' 时, 141 // 顺带修掉一个老问题:org 节点 role_name='org-coder' 绑定本体 'coder' 时,
130 // 在成型图上再雇一次 'coder' 以前会多出一行同义节点,现在不会了。 142 // 在成型图上再雇一次 'coder' 以前会多出一行同义节点,现在不会了。
131 // 143 //
132 // 默认行为(都不命中)= 写。P1/P2 既有语义「雇一个角色 → org graph 出现这个节点」 144 // 默认行为(都不命中)= 写。P1/P2 既有语义「雇一个角色 → org graph 出现这个节点」
133 // 在「公司还没有成型的图」这个前提下原样保住(graph-agent-p1-acceptance.test.js 判据 1)。 145 // 在「公司还没有成型的图」这个前提下原样保住(graph-agent-p1-acceptance.test.js 判据 1)。
134 -//  
135 -// 已知残留:单 org node 的最小图没有边,判据③不生效,这时只有 ①② 认识的本体会被拦下;  
136 -// 一个既不是 meta-* 又没在骨架里点过名的本体(executor.json 的占位符现填)仍会入图。  
137 -// 真跑到那一步再看,别为它提前加规则。  
138 const NON_ORG_AGENT_TYPES = new Set(['meta-bootstrap', 'meta-planning']); 146 const NON_ORG_AGENT_TYPES = new Set(['meta-bootstrap', 'meta-planning']);
139 const WORK_GRAPH_TEMPLATES_DIR = path.resolve(__dirname, '../../system/work-graph-templates'); 147 const WORK_GRAPH_TEMPLATES_DIR = path.resolve(__dirname, '../../system/work-graph-templates');
140 148
server/services/graph-engine.js
@@ -3935,6 +3935,9 @@ module.exports = { @@ -3935,6 +3935,9 @@ module.exports = {
3935 // executor 与注入点 3935 // executor 与注入点
3936 executeSubgraph, 3936 executeSubgraph,
3937 defaultAgentExecutor, 3937 defaultAgentExecutor,
  3938 + // 导出只为让真实链路 smoke 能直接问一句「守卫在这条路径上开着没有」
  3939 + // (graph-real-chain-smoke.test.js ③)。生产里它只被 advanceOne 调。
  3940 + boundaryGuardApplicable,
3938 defaultPlanWorkGraph, 3941 defaultPlanWorkGraph,
3939 setAgentExecutor, 3942 setAgentExecutor,
3940 setPlanWorkGraph, 3943 setPlanWorkGraph,
server/tests/unit/graph-real-chain-smoke.test.js 0 → 100644
  1 +/**
  2 + * 图执行的**真实链路 smoke** —— 全程零注入,唯一被替换的是最外层的 Claude CLI 子进程。
  3 + *
  4 + * ── 这条测试还的是哪笔债 ──────────────────────────────────────────────
  5 + * [27-P3/P4](../../../docs/todo/system-design/27-p3-p4-work-graph-console.md) §十 末
  6 + * 「四条硬规矩」第 2 条:
  7 + *
  8 + * > **测试全绿 ≠ 能跑**。P3 的引擎用例至今仍一律注入假 executor / 假 planner,
  9 + * > 第 7、8、9、10 号缺陷和当时的「planWorkGraph 未接线」全部在覆盖范围之外——
  10 + * > **夹具正好绕开了缺失的那一环**。……**凡是靠注入点验的能力,都要有一条真实路径的
  11 + * > smoke 进 CI 才算验收完成**——这条欠账仍未还。
  12 + *
  13 + * 在这之前,唯一走真实路径的是 `scripts/real-pm-smoke.js`(人工跑、要真 API 额度、
  14 + * 而且验的是 DevLoop 不是图执行),**没进 CI,随时会腐坏**。
  15 + *
  16 + * ── 「零注入」的边界画在哪 ────────────────────────────────────────────
  17 + * 不调 `setAgentExecutor` / `setPlanWorkGraph`。链路完整走:
  18 + * advanceOne → defaultAgentExecutor → AgentManager → ClaudeCliProtocol
  19 + * → onAcpEvent(mock/files, session/result) → 落盘 → readNodeProduct
  20 + * → settleNode → spendBudget → 边界守卫
  21 + * 只有最后那层真子进程被 `LEMONBOSS_CLAUDE_MODE=mock` 换成 fixture 回放 ——
  22 + * 真 LLM 进不了 CI(要额度、要分钟级时间、输出不确定),但**「真 LLM」和「真代码路径」
  23 + * 是两件事**,这里换掉前者、保住后者。
  24 + *
  25 + * ── 三条断言各盯一处「假 executor 天然绕开」的接线 ────────────────────
  26 + * ① 产物落盘:`mock/files` → `defaultAgentExecutor` 的 onAcpEvent → 节点产物目录。
  27 + * 假 executor 直接返回 `{status:'success'}`,这一环整个不存在。
  28 + * ② token 计数:`session/result.usage` → onAcpEvent 累加 → `spendBudget`。
  29 + * 这是 §六 验收 10 卡了两轮的那一维(「三个维度写在一起,只有它没有产出方」)。
  30 + * ③ 边界守卫真的开了:`boundaryGuardApplicable` 只在 `agentExecutor === defaultAgentExecutor`
  31 + * 时返回 true —— 注入假 executor 的用例里它**恒为 false**,那条分支实际零覆盖。
  32 + *
  33 + * ── 隔离 ──────────────────────────────────────────────────────────────
  34 + * GRAPH_DB_PATH / GRAPH_RUNS_DIR 都指临时目录,且必须在 require 之前设
  35 + * (两个模块都在顶部快照)。`LEMONBOSS_CLAUDE_MODE` 同理:ClaudeCliProtocol 的
  36 + * MOCK_MODE 是模块级 const。
  37 + */
  38 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  39 +
  40 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  41 +import fs from 'fs';
  42 +import os from 'os';
  43 +import path from 'path';
  44 +
  45 +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-real-chain-'));
  46 +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite');
  47 +process.env.GRAPH_RUNS_DIR = path.join(TMP, 'runs');
  48 +
  49 +// fixture 名 = 本体 id(图执行侧的 agent 名带公司前缀,mock 会先剥掉)。
  50 +// 用 prd-planning:本体真实存在,fixture 自带 files[](含 readNodeProduct 要读的
  51 +// attempt-{attempt}.json)与 usage。
  52 +const AGENT = 'prd-planning';
  53 +const COMPANY = 'zz-real-chain-co';
  54 +
  55 +let wdb; let engine;
  56 +
  57 +describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程)', () => {
  58 + let runId; let nodeRunId; let rootRunId;
  59 +
  60 + beforeAll(async () => {
  61 + for (const m of ['../../services/graph-db', '../../services/work-graph-db',
  62 + '../../services/graph-run-sse', '../../services/graph-engine']) {
  63 + delete require.cache[require.resolve(m)];
  64 + }
  65 + wdb = require('../../services/work-graph-db');
  66 + engine = require('../../services/graph-engine');
  67 +
  68 + const db = wdb.getDb();
  69 + const now = new Date().toISOString();
  70 + db.prepare(`INSERT OR IGNORE INTO system_agents
  71 + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
  72 + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)`)
  73 + .run(AGENT, AGENT, `system/agents/${AGENT}`, now, now);
  74 +
  75 + const g = wdb.createWorkGraph({ companyId: COMPANY, nodes: [{ nodeName: AGENT, systemAgentId: AGENT }], edges: [] });
  76 + expect(g.errors || []).toEqual([]);
  77 + const run = wdb.createRun({
  78 + graphKind: 'work', graphId: g.id, companyId: COMPANY, executionMode: 'manual',
  79 + // 额度给足:这条 smoke 要验的是「有没有数」,不是「够不够花」
  80 + budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } },
  81 + });
  82 + engine.markRunReady(run.id);
  83 + runId = run.id;
  84 + // createRun 的返回值不一定带 root_run_id,从库里读回来(这条 run 自己就是 root)
  85 + rootRunId = wdb.getRun(run.id).root_run_id;
  86 + expect(rootRunId).toBe(run.id);
  87 +
  88 + // ready → running(「下一步」只对 running/paused 可用)
  89 + engine.startRun(runId, { mode: 'manual' });
  90 +
  91 + const out = await engine.nextStep(runId, { task: '真实链路 smoke:产出一份 PRD' });
  92 + if (!out.ok) throw new Error(`nextStep 没推动:${JSON.stringify(out)}`);
  93 + nodeRunId = out.node.id;
  94 + // 节点必须真的跑完 —— 停在 running / fail 的话下面三条断言验的是残局
  95 + expect(out.node.status).toBe('success');
  96 + }, 60_000);
  97 +
  98 + afterAll(() => {
  99 + // 真实链路会在本体下建公司态目录(AgentManager 的 cwd 是 system/agents/{aid}/{cid}/{taskId})。
  100 + // 它 gitignore 挡着、不进版本库,但留着会让别的用例遍历 system/agents/ 时多扫一份,
  101 + // 也让 `ls system/agents/prd-planning/` 出现一个没人认领的公司。
  102 + fs.rmSync(path.join(__dirname, '../../../system/agents', AGENT, COMPANY), { recursive: true, force: true });
  103 + delete process.env.LEMONBOSS_CLAUDE_MODE;
  104 + delete process.env.GRAPH_DB_PATH;
  105 + delete process.env.GRAPH_RUNS_DIR;
  106 + fs.rmSync(TMP, { recursive: true, force: true });
  107 + });
  108 +
  109 + it('① mock/files 真的落到了节点产物目录(假 executor 下这一环整个不存在)', () => {
  110 + const dir = path.join(process.env.GRAPH_RUNS_DIR, rootRunId, 'nodes', nodeRunId);
  111 + expect(fs.existsSync(path.join(dir, 'attempt-0.json'))).toBe(true);
  112 + expect(fs.readFileSync(path.join(dir, 'prd.md'), 'utf8')).toContain('PRD');
  113 + // attempt-0.json 是引擎 readNodeProduct 要读的那份 —— 它读不到就判不出 verdict,
  114 + // 所以这条同时也证明了 `{attempt}` 占位符替换那一步没坏
  115 + const product = JSON.parse(fs.readFileSync(path.join(dir, 'attempt-0.json'), 'utf8'));
  116 + expect(product.verdict).toBe('no_prior_failure');
  117 + });
  118 +
  119 + it('② token 用量真的从 session/result.usage 数进了预算(§六 验收 10 那一维)', () => {
  120 + const spent = JSON.parse(wdb.getRun(rootRunId).budget_json).spent;
  121 + expect(spent.agent_calls).toBe(1);
  122 + // fixture 报 1200 + 340;断言等值而不是 >0 —— 「大于 0」在累加写成 `=` 而非 `+=` 时也成立
  123 + expect(spent.tokens).toBe(1540);
  124 + });
  125 +
  126 + it('③ 边界守卫在这条路径上是**开着**的(注入假 executor 时它恒为假,那条分支零覆盖)', () => {
  127 + // 判据取自引擎自己的开关函数,而不是去数 timeline 事件:
  128 + // 守卫在宿主仓库干净时本来就不产生事件,拿「没有事件」当「没开」会反过来判错。
  129 + expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(true);
  130 + // 对照:注入之后必须变假 —— 少了这条,上面那句在函数写成 `return true` 时也全绿
  131 + engine.setAgentExecutor(async () => ({ status: 'success' }));
  132 + expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(false);
  133 + engine.setAgentExecutor(null);
  134 + });
  135 +});