Commit c9b96374ab7faf1e3be5c0837cf235c0df75b3d1

Authored by yibo
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>
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/&lt;公司ID&gt;/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/&lt;公司ID&gt;/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/&lt;公司ID&gt;/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 那张表最后一行)。
... ...