Commit c9b96374ab7faf1e3be5c0837cf235c0df75b3d1
1 parent
9a16416a
fix(workgraph-planning): 规划记录文件名带宿主 org node,补 memory.md 桥接原料通道
两个独立的小修复,都是这次 e2e 回归 + 用户讨论"workgraph-planning 该怎么训练"时发现的: 1. workgraph-plan-<taskId>.md 撞名:一个任务里 org-planner/org-coder/org-validator 各自调 workgraph-planning 一次、各自的规划记录却共用同一个文件名,落在同一个项目根下, 后写的原样覆盖先写的——真实撞见过(org-planner 当时的规划记录被 org-validator 覆盖, 事后一个字都找不回来)。改成 workgraph-plan-<taskId>-<宿主orgNode节点名>.md。 2. subgraph 节点结算 → workgraph-planning 自己的 memory.md 桥接:阶段一的 recordNodeOutcomeMemory 明确排除了 subgraph 节点(那是给 agent_call 记的),导致 workgraph-planning 每次规划出的图最终跑得好不好,完全没有可积累的历史——这正是它 "允许裁剪却没有具体判据"这个问题一直无法通过真实反馈自我改进的原因之一。新增 recordWorkgraphPlanningMemory,在 subgraph 节点结算时把结果记进 workgraph-planning (全局唯一规划本体,不是宿主 org node 自己的身份)的 memory.md,只在真实规划 (planWorkGraph===defaultPlanWorkGraph)时记,避免测试夹具污染真实目录。 新增 3 条回归测试,graph-engine 相关测试文件全绿。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
210 additions
and
4 deletions
server/services/graph-engine.js
| ... | ... | @@ -1632,6 +1632,34 @@ function recordNodeOutcomeMemory(ctx, nodeSnapshot) { |
| 1632 | 1632 | } |
| 1633 | 1633 | |
| 1634 | 1634 | /** |
| 1635 | + * subgraph 节点(org 层)结算时,把「这次规划出的图最终跑得怎么样」记进 | |
| 1636 | + * `workgraph-planning` 自己的 memory.md——36 号文档追加发现:它的图形状裁剪判据 | |
| 1637 | + * 现在只有"允许裁剪"没有"具体判据"(真实撞见:单页静态网站任务原样照抄 | |
| 1638 | + * planner.json 全部 5 节点,一个都没砍),而它自己完全没有可积累的历史——这是补上 | |
| 1639 | + * 这个原料通道的桥接点,跟 recordNodeOutcomeMemory 记 agent_call 节点是同一个精神。 | |
| 1640 | + * | |
| 1641 | + * 不能直接复用 recordNodeOutcomeMemory:subgraph 节点的 `ctx.systemAgentId` 是**宿主 | |
| 1642 | + * org node 自己的身份**(如 "org-coder"),不是真正执行规划的那个 agent——规划本体 | |
| 1643 | + * 全局唯一,恒为 `PLANNING_AGENT_ID`(R-15.2)。 | |
| 1644 | + * | |
| 1645 | + * 只在真实规划(`planWorkGraph === defaultPlanWorkGraph`)时记:测试夹具几乎总会注入 | |
| 1646 | + * 假 planWorkGraph(defaultPlanWorkGraph 会真的起 CLI 调 workgraph-planning),这道闸门 | |
| 1647 | + * 挡住的正是"把假地址写进真实 system/agents/workgraph-planning/ 目录"这类污染 | |
| 1648 | + * (与 runClaimedNode 里 agentExecutor === defaultAgentExecutor 同一个判据)。 | |
| 1649 | + */ | |
| 1650 | +function recordWorkgraphPlanningMemory(companyId, nodeName, status, failReason) { | |
| 1651 | + if (planWorkGraph !== defaultPlanWorkGraph) return; | |
| 1652 | + try { | |
| 1653 | + const addr = resolveAgentName(companyId, PLANNING_AGENT_ID); | |
| 1654 | + const task = `为 ${nodeName} 规划的 work graph`; | |
| 1655 | + const result = status === 'success' ? 'success' : `fail${failReason ? `: ${failReason}` : ''}`; | |
| 1656 | + memoryService.appendToMemoryMd(addr, task, result, { stats: { toolCallCount: 1 } }); | |
| 1657 | + } catch (err) { | |
| 1658 | + console.error('[graph-engine] workgraph-planning 结果写入 memory.md 失败:', err.message); | |
| 1659 | + } | |
| 1660 | +} | |
| 1661 | + | |
| 1662 | +/** | |
| 1635 | 1663 | * 批量推进(27-P4 T4/T6):一次拾取至多 `maxConcurrency` 个 ready 节点,并发执行。 |
| 1636 | 1664 | * |
| 1637 | 1665 | * `maxConcurrency <= 1` 时**直接委托给 `advanceOne`**,不走下面的批量路径——零行为差异, |
| ... | ... | @@ -1847,6 +1875,13 @@ function settleNode(runId, nodeRunId, outcome = {}) { |
| 1847 | 1875 | reset: [], infraRerun: false, |
| 1848 | 1876 | }; |
| 1849 | 1877 | |
| 1878 | + // 36 号文档:subgraph 节点结算 = 「这次规划出的图最终跑得怎么样」这个信号第一次齐全的时刻 | |
| 1879 | + // (outcome 是 deriveChildOutcome 派生的,代表子 work graph run 的真实收敛结果)。 | |
| 1880 | + const settledDefNode = def.nodes.get(nodeRun.def_node_id); | |
| 1881 | + if (settledDefNode && settledDefNode.nodeKind === 'subgraph') { | |
| 1882 | + recordWorkgraphPlanningMemory(run.company_id, settledDefNode.name || nodeRun.def_node_id, to, failReason); | |
| 1883 | + } | |
| 1884 | + | |
| 1850 | 1885 | if (to === 'success') { |
| 1851 | 1886 | // R-19 产出侧:把这次落盘的文件登记进产物索引,下游节点靠它拿到真实路径。 |
| 1852 | 1887 | // 只在 success 时记 —— 失败节点的半成品不该被下游当输入读。 |
| ... | ... | @@ -4605,6 +4640,10 @@ module.exports = { |
| 4605 | 4640 | // 导出只为让"success 那一行怎么格式化"不必靠真起 CLI 触发 isRealDispatch 才能验 |
| 4606 | 4641 | // (fail 分支可以借道 body_not_ready 走端到端,success 分支目前只有这条直调路径)。 |
| 4607 | 4642 | recordNodeOutcomeMemory, |
| 4643 | + // 36 号文档追加:subgraph 节点(workgraph-planning 规划出的图)结算 → memory.md 桥接。 | |
| 4644 | + // 真实调用方是 settleNode;导出只为不必真起 CLI 触发 planWorkGraph===defaultPlanWorkGraph | |
| 4645 | + // 就能验证格式化与闸门逻辑(同 recordNodeOutcomeMemory 的导出理由)。 | |
| 4646 | + recordWorkgraphPlanningMemory, | |
| 4608 | 4647 | |
| 4609 | 4648 | // 选边 / 熔断 / 透传 |
| 4610 | 4649 | selectFailEdge, | ... | ... |
server/tests/unit/graph-engine-workgraph-planning-memory.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — subgraph 节点结算 → workgraph-planning 自己的 memory.md 桥接(36 号文档追加) | |
| 3 | + * | |
| 4 | + * 背景:36 号文档真实回归发现 `workgraph-planning` 的图形状裁剪判据现在只有"允许裁剪" | |
| 5 | + * 没有"具体判据"(单页静态网站任务原样照抄 planner.json 全部 5 节点,一个都没砍),而它 | |
| 6 | + * 自己完全没有可积累的历史——阶段一的 `recordNodeOutcomeMemory` 桥接明确排除了 subgraph | |
| 7 | + * 节点(那是给 agent_call 节点记的,subgraph 节点没有对应的"真实执行"概念)。 | |
| 8 | + * | |
| 9 | + * 本文件锁的是补上的那半:subgraph 节点结算(= 它规划出的子 work graph 最终收敛)时, | |
| 10 | + * 把"这次规划的图跑得怎么样"记进 `workgraph-planning` 自己的 memory.md(不是宿主 org node | |
| 11 | + * 的 `ctx.systemAgentId`,那是宿主自己的身份,不是真正执行规划的 agent——规划本体全局唯一)。 | |
| 12 | + * | |
| 13 | + * ① 真实规划(`planWorkGraph === defaultPlanWorkGraph`)+ success → 记 "success" | |
| 14 | + * ② 真实规划 + fail(reason) → 记 "fail: <reason>" | |
| 15 | + * ③ 注入假 planWorkGraph(全仓库现有 subgraph 测试的常态,见 graph-engine-revive-parent.test.js | |
| 16 | + * 的 buildParentWithFailingChild)→ 不记,不能因为这次改动就把假地址写进真实 | |
| 17 | + * system/agents/workgraph-planning/ 目录 | |
| 18 | + * | |
| 19 | + * ①②借道直调(不必真起 CLI 触发 planWorkGraph===defaultPlanWorkGraph 才能验证格式化), | |
| 20 | + * ③走完整的 subgraph 结算流程(复用 graph-engine-revive-parent.test.js 的建图手法), | |
| 21 | + * 确认现有"全仓库 subgraph 测试都注入假 planWorkGraph"这条约定下这个新桥接确实按兵不动。 | |
| 22 | + */ | |
| 23 | +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; | |
| 24 | +import fs from 'fs'; | |
| 25 | +import os from 'os'; | |
| 26 | +import path from 'path'; | |
| 27 | + | |
| 28 | +describe('subgraph 节点结算桥接进 workgraph-planning 的 memory.md(36 号文档)', () => { | |
| 29 | + let wdb; | |
| 30 | + let engine; | |
| 31 | + let memoryService; | |
| 32 | + let runsDir; | |
| 33 | + const TEST_DB_PATH = '/tmp/test-graph-engine-workgraph-planning-memory.sqlite'; | |
| 34 | + let origAppendToMemoryMd; | |
| 35 | + let calls; | |
| 36 | + | |
| 37 | + beforeAll(() => { | |
| 38 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | |
| 39 | + try { fs.unlinkSync(f); } catch {} | |
| 40 | + } | |
| 41 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 42 | + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-wgplan-memory-runs-')); | |
| 43 | + process.env.GRAPH_RUNS_DIR = runsDir; | |
| 44 | + for (const m of [ | |
| 45 | + '../../services/graph-db', | |
| 46 | + '../../services/graph-service', | |
| 47 | + '../../services/org-graph-service', | |
| 48 | + '../../services/work-graph-db', | |
| 49 | + '../../services/graph-run-sse', | |
| 50 | + '../../services/graph-engine', | |
| 51 | + '../../services/memory-service', | |
| 52 | + ]) { | |
| 53 | + delete require.cache[require.resolve(m)]; | |
| 54 | + } | |
| 55 | + wdb = require('../../services/work-graph-db'); | |
| 56 | + engine = require('../../services/graph-engine'); | |
| 57 | + memoryService = require('../../services/memory-service'); | |
| 58 | + origAppendToMemoryMd = memoryService.appendToMemoryMd; | |
| 59 | + }); | |
| 60 | + | |
| 61 | + afterAll(() => { | |
| 62 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | |
| 63 | + try { fs.unlinkSync(f); } catch {} | |
| 64 | + } | |
| 65 | + delete process.env.GRAPH_DB_PATH; | |
| 66 | + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} | |
| 67 | + delete process.env.GRAPH_RUNS_DIR; | |
| 68 | + }); | |
| 69 | + | |
| 70 | + beforeEach(() => { | |
| 71 | + const db = wdb.getDb(); | |
| 72 | + db.pragma('foreign_keys = OFF'); | |
| 73 | + db.exec(` | |
| 74 | + DELETE FROM graph_run_loop_records; | |
| 75 | + DELETE FROM graph_run_nodes; | |
| 76 | + DELETE FROM graph_runs; | |
| 77 | + DELETE FROM work_graph_edges; | |
| 78 | + DELETE FROM work_graph_nodes; | |
| 79 | + DELETE FROM work_graphs; | |
| 80 | + DELETE FROM system_agents; | |
| 81 | + `); | |
| 82 | + db.pragma('foreign_keys = ON'); | |
| 83 | + engine.setAgentExecutor(null); | |
| 84 | + engine.setPlanWorkGraph(null); | |
| 85 | + memoryService.appendToMemoryMd = origAppendToMemoryMd; | |
| 86 | + }); | |
| 87 | + | |
| 88 | + function spyMemory() { | |
| 89 | + calls = []; | |
| 90 | + memoryService.appendToMemoryMd = (...args) => { calls.push(args); }; | |
| 91 | + } | |
| 92 | + | |
| 93 | + it('① 真实规划(直调,不必起真 CLI)+ success → 记一行 "success"', () => { | |
| 94 | + spyMemory(); | |
| 95 | + engine.recordWorkgraphPlanningMemory('co-wgplan-1', 'org-coder', 'success', null); | |
| 96 | + | |
| 97 | + expect(calls.length).toBe(1); | |
| 98 | + const [addr, task, result] = calls[0]; | |
| 99 | + expect(addr).toBe('co-wgplan-1:workgraph-planning'); | |
| 100 | + expect(task).toContain('org-coder'); | |
| 101 | + expect(result).toBe('success'); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it('② 真实规划(直调)+ fail(reason) → 记一行 "fail: <reason>"', () => { | |
| 105 | + spyMemory(); | |
| 106 | + engine.recordWorkgraphPlanningMemory('co-wgplan-2', 'org-validator', 'fail', 'impl_bug'); | |
| 107 | + | |
| 108 | + expect(calls.length).toBe(1); | |
| 109 | + const [addr, , result] = calls[0]; | |
| 110 | + expect(addr).toBe('co-wgplan-2:workgraph-planning'); | |
| 111 | + expect(result).toBe('fail: impl_bug'); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + // ③:复用 graph-engine-revive-parent.test.js 的建图手法——父 run 一个 subgraph 节点 | |
| 115 | + // (宿主 org node)+ 一张会失败/成功的子图,全程注入假 setPlanWorkGraph(全仓库 subgraph | |
| 116 | + // 测试的常态:defaultPlanWorkGraph 会真的起 CLI 调 workgraph-planning)。 | |
| 117 | + function ensureAgents(ids) { | |
| 118 | + const db = wdb.getDb(); | |
| 119 | + const now = new Date().toISOString(); | |
| 120 | + const ins = db.prepare(` | |
| 121 | + INSERT OR IGNORE INTO system_agents | |
| 122 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 123 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) | |
| 124 | + `); | |
| 125 | + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); | |
| 126 | + } | |
| 127 | + | |
| 128 | + it('③ 注入假 planWorkGraph(全仓库现有 subgraph 测试的常态)→ 不记,不污染真实目录', async () => { | |
| 129 | + spyMemory(); | |
| 130 | + const companyId = 'co-wgplan-3'; | |
| 131 | + ensureAgents(['org-coder', 'wg-only']); | |
| 132 | + | |
| 133 | + const childG = wdb.createWorkGraph({ companyId, nodes: ['wg-only'], edges: [] }); | |
| 134 | + expect(childG.errors || []).toEqual([]); | |
| 135 | + | |
| 136 | + const parentG = wdb.createWorkGraph({ | |
| 137 | + companyId, | |
| 138 | + nodes: [{ nodeName: 'org-coder', systemAgentId: 'org-coder', nodeKind: 'subgraph' }], | |
| 139 | + edges: [], | |
| 140 | + }); | |
| 141 | + expect(parentG.errors || []).toEqual([]); | |
| 142 | + | |
| 143 | + const parentRun = wdb.createRun({ graphKind: 'work', graphId: parentG.id, companyId, executionMode: 'manual' }); | |
| 144 | + engine.markRunReady(parentRun.id); | |
| 145 | + engine.startRun(parentRun.id, { mode: 'manual' }); | |
| 146 | + | |
| 147 | + engine.setPlanWorkGraph(() => ({ workGraphId: childG.id })); | |
| 148 | + engine.setAgentExecutor(async () => ({ status: 'success', output: '子节点成功' })); | |
| 149 | + | |
| 150 | + for (let i = 0; i < 12; i += 1) { | |
| 151 | + const r = await engine.advanceTree(parentRun.id); | |
| 152 | + if (!r || r.ok === false) break; | |
| 153 | + } | |
| 154 | + | |
| 155 | + expect(wdb.getRunState(parentRun.id).run.run_status).toBe('completed'); | |
| 156 | + expect(calls.length).toBe(0); | |
| 157 | + }); | |
| 158 | +}); | ... | ... |
system/agents/workgraph-planning/agent.md
| ... | ... | @@ -149,13 +149,22 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents |
| 149 | 149 | |
| 150 | 150 | ### Step 5 · 落盘规划记录 |
| 151 | 151 | |
| 152 | -把图和**为什么这么设计的理由**写成一份 `workgraph-plan-<taskId>.md`,供事后人工审计。 | |
| 152 | +把图和**为什么这么设计的理由**写成一份 `workgraph-plan-<taskId>-<宿主orgNode节点名>.md`, | |
| 153 | +供事后人工审计。 | |
| 154 | + | |
| 155 | +> ⚠️ **文件名必须带宿主 org node 名字,不能只用 `<taskId>`。** 一个任务里 `org-planner` / | |
| 156 | +> `org-coder` / `org-validator` 各自都会调你一次、各自规划一张图,都落在**同一个项目根**下—— | |
| 157 | +> 三次写入如果共用 `workgraph-plan-<taskId>.md` 这一个文件名,后写的会把先写的**原样覆盖**, | |
| 158 | +> 事后审计只剩最后一个 org node 的记录(2026-08-23 真实撞见过:`org-planner` 当时的规划记录 | |
| 159 | +> 被后跑的 `org-validator` 覆盖,事后一个字都找不回来)。你的 `nodeName` | |
| 160 | +> (首轮消息「① 宿主 org node 的角色定义」段里的「节点名(org graph 内)」)就是这里要用的 | |
| 161 | +> `<宿主orgNode节点名>`,例如 `workgraph-plan-T-mt5d3mi9-57cf-org-coder.md`。 | |
| 153 | 162 | |
| 154 | 163 | **落点看首轮消息的「写入边界」段**,不要凭习惯写相对路径: |
| 155 | 164 | |
| 156 | 165 | | 边界段说了什么 | 你写哪儿 | |
| 157 | 166 | |---|---| |
| 158 | -| 给了项目根 | `<项目根>/workgraph-plan-<taskId>.md`,**用绝对路径** | | |
| 167 | +| 给了项目根 | `<项目根>/workgraph-plan-<taskId>-<宿主orgNode节点名>.md`,**用绝对路径** | | |
| 159 | 168 | | 明说没分到项目根 | **一个文件都不落**,把这份记录直接写进回复正文 | |
| 160 | 169 | |
| 161 | 170 | ⚠️ 相对路径(`workspace/...`)会落到**引擎源码仓库**里——那正是边界段禁止写的位置。 |
| ... | ... | @@ -376,7 +385,7 @@ ls data/companies/<公司ID>/agents/ |
| 376 | 385 | 1. **不要放弃**——被打回一轮是设计里预期的交互,不是失败。 |
| 377 | 386 | 2. **不要原样重交**——不许把同一份图再贴一遍指望它这次能过。 |
| 378 | 387 | 3. **不要绕过校验**——不许直接写数据库、不许把违规节点删光来"通过"。 |
| 379 | -4. 每次重交前,**先在规划记录 `workgraph-plan-<taskId>.md` 里追加一行**:撞了哪条规则、怎么改的。 | |
| 388 | +4. 每次重交前,**先在规划记录 `workgraph-plan-<taskId>-<宿主orgNode节点名>.md` 里追加一行**:撞了哪条规则、怎么改的。 | |
| 380 | 389 | 5. **引擎只给你 3 轮**(`PLAN_MAX_ROUNDS`,比这份模板从前写的 5 轮更紧——每一轮都是一次真花钱的调用)。 |
| 381 | 390 | 第 3 轮还不过,这个节点就按 fail 结算走兜底边。所以**第一轮就把约束九和五项校验自查做完**, |
| 382 | 391 | 不要把校验当成免费的往返。 |
| ... | ... | @@ -390,7 +399,7 @@ ls data/companies/<公司ID>/agents/ |
| 390 | 399 | - 节点数 / 边数,其中 backward 边几条; |
| 391 | 400 | - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3); |
| 392 | 401 | - **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误); |
| 393 | -- 规划记录的绝对路径(`<项目根>/workgraph-plan-<taskId>.md`;没分到项目根就把记录写在正文里)。 | |
| 402 | +- 规划记录的绝对路径(`<项目根>/workgraph-plan-<taskId>-<宿主orgNode节点名>.md`;没分到项目根就把记录写在正文里)。 | |
| 394 | 403 | |
| 395 | 404 | **不要报 `workGraphId`**——图 id 是引擎落库时才生成的,你手上没有,编一个只会让引擎去查一个不存在的图, |
| 396 | 405 | 白烧一轮(见 Step 7 那张表最后一行)。 | ... | ... |