diff --git a/docs/README.md b/docs/README.md index 9077de6..258d93d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ 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 条那笔欠账**只还了一半** —— `graph-real-chain-smoke.test.js`(零注入、只换掉真 CLI 子进程)已进 CI,覆盖 `agent_call` 那一支;`subgraph`(`defaultPlanWorkGraph`)仍只有注入式覆盖,真 LLM 也仍进不了 CI | +| [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` 两支都覆盖。剩下的是真 LLM(进不了 CI)、多节点 / 回退边 / 熔断的形状、以及按需 hire 那一段 | | [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 可插队 | 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 d790f88..7f67d85 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 @@ -1058,10 +1058,19 @@ agent 默认**只挂载 `meta-research` 一个元技能**,必须主动呼叫 ` > 现在 fixture 可以指定(缺省仍是 0,既有 fixture 行为不变)。**不改这一处, > token 那一维在 CI 里永远产不出数** —— §六 验收 10 当初就是卡在这儿。 > + > **同日补上 `subgraph` 那一支**(④⑤):`defaultPlanWorkGraph` 是**另一段**默认实现, + > 有自己的 onAcpEvent 累加和自己的守卫开关分支,只覆盖 `agent_call` 的话「规划子图」 + > 那半条链仍是零真实覆盖。现在走完 `defaultPlanWorkGraph → AgentManager → 回复解析 + > (extractPlannedGraph) → createWorkGraphSafely → 建子 run`,断言子 run 的定义确实来自 + > fixture 那张图、且规划那一轮的 token 也进了预算。反向验过:抽掉 fixture 里的图 JSON、 + > 或把它的 usage 归零,对应用例分别变红。 + > 覆盖边界写在用例注释里:**跳过 `resolvePlanningAgentName` 里的按需 hire** + > (预塞一行 company_agents),因为 hire 要一条真实公司记录,而那条路径另有 + > `company-hire-org-layer.test.js` 盯着。 + > > **还没还的部分,别当成还清了**:(a) 真 LLM 仍然进不了 CI(要额度、分钟级、输出不确定), - > `scripts/real-pm-smoke.js` 仍是人工跑;(b) 这条 smoke 走的是 `agent_call`, - > **`subgraph` 那一支(`defaultPlanWorkGraph`)仍然只有注入式覆盖**; - > (c) 多节点、回退边、熔断这些形状没进这条 smoke,它只推一个节点。 + > `scripts/real-pm-smoke.js` 仍是人工跑;(b) 多节点、回退边、熔断这些形状没进这条 smoke, + > 两支各只推一个节点;(c) 按需 hire 那一段没在这条链路里走过。 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/fixtures/devloop-mock/workgraph-planning.json b/fixtures/devloop-mock/workgraph-planning.json index b439004..7edc24b 100644 --- a/fixtures/devloop-mock/workgraph-planning.json +++ b/fixtures/devloop-mock/workgraph-planning.json @@ -1,10 +1,9 @@ { - "_doc": "P3 mock fixture · workgraph-planning。每个 org node 接到任务时被调一次,产出该节点的 work graph。\n\n⚠️ 图必须放在 chunks 里,不能只放 result:AgentManager.callAgent 返回的 output 来自 runPromptInSession 累积的流式 chunks(agent-manager.js:332),而 session/result 的文本不进 output。graph-engine.extractPlannedGraph 解析的正是 output。真实 agent 也是把答案流式吐出来的,所以放 chunks 才是与生产一致的形状。\n\n单变体(不用 sequence):三个 org node 拿到同一张图形状,UI 走查可以反复跑而不会因为 _mockCallCount 走到序列末尾而变形。图满足 work-graph-db 的五项写入前校验:无环(仅 forward)、单入口 wg-plan、全可达、回退边显式标 backward、wg-check 的 fail 出边留了一条 fail_reason 缺省的兜底边。", + "_doc": "P3 mock fixture · subgraph 节点的规划本体。graph-engine.defaultPlanWorkGraph 从 agent 回复里 extractPlannedGraph:优先认 wg_+12位十六进制 的已落库 id,否则找 ```json 围栏块里 {nodes:[],edges:[]} 的图 JSON。这里走后者(模板里的 404 降级路径,也是真跑里更常见的一条)。图 JSON 同时放进 chunks 与 result,因为 mock 下 callAgent 的 output 取哪一半取决于 AgentManager,不该由 fixture 来赌。usage 让「规划这一轮烧的 token」在 mock 下也产得出来 —— defaultPlanWorkGraph 自己有一条独立的 onAcpEvent 累加,与 defaultAgentExecutor 那条不是同一段代码。", "chunks": [ - "[mock] workgraph-planning 收到任务,开始规划本 org node 的 work graph。\n", - "读候选骨架 → 选定三节点线性主干 + 一条回退边。\n\n", - "```json\n{\n \"nodes\": [\n { \"nodeName\": \"wg-plan\", \"systemAgentId\": \"prd-planning\" },\n { \"nodeName\": \"wg-build\", \"systemAgentId\": \"sdd-planning\" },\n { \"nodeName\": \"wg-check\", \"systemAgentId\": \"coder-validator\" }\n ],\n \"edges\": [\n { \"from\": \"wg-plan\", \"to\": \"wg-build\", \"condition\": \"success\" },\n { \"from\": \"wg-build\", \"to\": \"wg-check\", \"condition\": \"success\" },\n { \"from\": \"wg-check\", \"to\": \"wg-build\", \"condition\": \"fail\", \"edgeKind\": \"backward\" }\n ]\n}\n```\n\n", - "wg-plan 出需求、wg-build 出设计与实现、wg-check 验收;验收不过退回 wg-build 重做。\n" + "[mock] workgraph-planning 读取 org 节点契约,裁一张最小实现子图。\n", + "```json\n{\n \"nodes\": [{ \"nodeName\": \"prd-planning\", \"systemAgentId\": \"prd-planning\" }],\n \"edges\": []\n}\n```\n" ], - "result": "mock-ok workgraph-planning:已规划三节点 work graph(wg-plan → wg-build → wg-check,含一条回退边)。" + "result": "mock-ok workgraph-planning:已裁出子图。\n```json\n{\n \"nodes\": [{ \"nodeName\": \"prd-planning\", \"systemAgentId\": \"prd-planning\" }],\n \"edges\": []\n}\n```\n", + "usage": { "input": 800, "output": 260 } } diff --git a/server/tests/unit/graph-real-chain-smoke.test.js b/server/tests/unit/graph-real-chain-smoke.test.js index e3d3ecb..06426ee 100644 --- a/server/tests/unit/graph-real-chain-smoke.test.js +++ b/server/tests/unit/graph-real-chain-smoke.test.js @@ -30,6 +30,11 @@ * ③ 边界守卫真的开了:`boundaryGuardApplicable` 只在 `agentExecutor === defaultAgentExecutor` * 时返回 true —— 注入假 executor 的用例里它**恒为 false**,那条分支实际零覆盖。 * + * ── 两支都要走 ──────────────────────────────────────────────────────── + * `agent_call` 与 `subgraph` 是两条**不同的**默认实现(`defaultAgentExecutor` / + * `defaultPlanWorkGraph`),各有各的 onAcpEvent 累加、各有各的守卫开关分支。 + * 只覆盖前者的话,「规划子图」那半条链仍然是零真实覆盖 —— 所以下面 ④⑤ 专走 subgraph。 + * * ── 隔离 ────────────────────────────────────────────────────────────── * GRAPH_DB_PATH / GRAPH_RUNS_DIR 都指临时目录,且必须在 require 之前设 * (两个模块都在顶部快照)。`LEMONBOSS_CLAUDE_MODE` 同理:ClaudeCliProtocol 的 @@ -50,6 +55,8 @@ process.env.GRAPH_RUNS_DIR = path.join(TMP, 'runs'); // 用 prd-planning:本体真实存在,fixture 自带 files[](含 readNodeProduct 要读的 // attempt-{attempt}.json)与 usage。 const AGENT = 'prd-planning'; +const PLANNER = 'workgraph-planning'; // subgraph 那一支的规划本体(fixture 同名) +const WRAPPER = 'zz-smoke-wrapper'; // 承载子图的那个节点,只是个名字,不会被调用 const COMPANY = 'zz-real-chain-co'; let wdb; let engine; @@ -99,7 +106,9 @@ describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程ï¼ // 真实链路会在本体下建公司态目录(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 }); + for (const a of [AGENT, PLANNER]) { + fs.rmSync(path.join(__dirname, '../../../system/agents', a, COMPANY), { recursive: true, force: true }); + } delete process.env.LEMONBOSS_CLAUDE_MODE; delete process.env.GRAPH_DB_PATH; delete process.env.GRAPH_RUNS_DIR; @@ -123,13 +132,81 @@ describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程ï¼ expect(spent.tokens).toBe(1540); }); + // ── subgraph 那一支:defaultPlanWorkGraph ───────────────────────────── + // + // 覆盖边界说清楚:这里**跳过 `resolvePlanningAgentName` 里的按需 hire**(预先塞一行 + // company_agents 让它认为雇过了)。理由是 hire 要一条真实的公司记录(`this.get(cid)`), + // 而那条路径另有 `company-hire-org-layer.test.js` 专门盯着 —— 这条 smoke 要验的是 + // 「规划这一段链路通不通」,不是「招聘通不通」。 + function seedPlannerHired() { + 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(PLANNER, PLANNER, `system/agents/${PLANNER}`, now, now); + // subgraph 节点自己也要有 system_agents 行(work_graph_nodes.system_agent_id 是外键)。 + // 它只是「哪个 org 角色拥有这张子图」的名字,不会被真的调用 —— 调的是 PLANNER。 + 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(WRAPPER, WRAPPER, `system/agents/${WRAPPER}`, now, now); + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) + VALUES (?, ?, ?, 'active', ?)`).run(COMPANY, PLANNER, PLANNER, now); + } + + it('④ subgraph 节点真的把 workgraph-planning 的图 JSON 变成了一条子 run', async () => { + seedPlannerHired(); + const g = wdb.createWorkGraph({ + companyId: COMPANY, + nodes: [{ nodeName: WRAPPER, systemAgentId: WRAPPER, nodeKind: 'subgraph' }], + edges: [], + }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ + graphKind: 'work', graphId: g.id, companyId: COMPANY, executionMode: 'manual', + budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } }, + }); + engine.markRunReady(run.id); + engine.startRun(run.id); + + const out = await engine.nextStep(run.id, { task: '真实链路 smoke:规划一张子图' }); + if (!out.ok) throw new Error(`subgraph 没推动:${JSON.stringify(out)}`); + // 父节点停在 running 等子 run,这是 subgraph 的正常形态(不是没跑动) + expect(out.reason).toBe('awaiting-child-run'); + expect(out.node.childRunId).toBeTruthy(); + + // 子 run 的定义必须来自 fixture 那张图 —— 注入假 planner 时这一整段(回复解析 → + // createWorkGraphSafely → 建子 run)都不存在 + const child = wdb.getRun(out.node.childRunId); + expect(child.graph_kind).toBe('work'); + const childNodes = wdb.getDb() + .prepare(`SELECT def_node_id FROM graph_run_nodes WHERE run_id = ?`).all(child.id); + expect(childNodes).toHaveLength(1); + const def = wdb.getDb() + .prepare(`SELECT node_name, system_agent_id FROM work_graph_nodes WHERE id = ?`) + .get(childNodes[0].def_node_id); + expect(def.node_name).toBe(AGENT); + expect(def.system_agent_id).toBe(AGENT); + + // ⑤ 规划那一轮烧的 token 也进了预算。**它走的是另一段 onAcpEvent** + // (defaultPlanWorkGraph 自己那条),与 ② 验的不是同一段代码。 + const spent = JSON.parse(wdb.getRun(run.root_run_id || run.id).budget_json).spent; + expect(spent.tokens).toBe(1060); // fixture 报 800 + 260 + }, 60_000); + it('③ 边界守卫在这条路径上是**开着**的(注入假 executor 时它恒为假,那条分支零覆盖)', () => { // 判据取自引擎自己的开关函数,而不是去数 timeline 事件: // 守卫在宿主仓库干净时本来就不产生事件,拿「没有事件」当「没开」会反过来判错。 expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(true); - // 对照:注入之后必须变假 —— 少了这条,上面那句在函数写成 `return true` 时也全绿 + expect(engine.boundaryGuardApplicable({ nodeKind: 'subgraph' })).toBe(true); + // 对照:注入之后必须变假 —— 少了这条,上面两句在函数写成 `return true` 时也全绿。 + // 两支各有各的开关分支,所以两边都要验。 engine.setAgentExecutor(async () => ({ status: 'success' })); + engine.setPlanWorkGraph(async () => ({ workGraphId: 'wg_000000000000' })); expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(false); + expect(engine.boundaryGuardApplicable({ nodeKind: 'subgraph' })).toBe(false); engine.setAgentExecutor(null); + engine.setPlanWorkGraph(null); }); });