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