diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 5e33435..83c0a3d 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -1632,6 +1632,34 @@ function recordNodeOutcomeMemory(ctx, nodeSnapshot) { } /** + * subgraph 节点(org 层)结算时,把「这次规划出的图最终跑得怎么样」记进 + * `workgraph-planning` 自己的 memory.md——36 号文档追加发现:它的图形状裁剪判据 + * 现在只有"允许裁剪"没有"具体判据"(真实撞见:单页静态网站任务原样照抄 + * planner.json 全部 5 节点,一个都没砍),而它自己完全没有可积累的历史——这是补上 + * 这个原料通道的桥接点,跟 recordNodeOutcomeMemory 记 agent_call 节点是同一个精神。 + * + * 不能直接复用 recordNodeOutcomeMemory:subgraph 节点的 `ctx.systemAgentId` 是**宿主 + * org node 自己的身份**(如 "org-coder"),不是真正执行规划的那个 agent——规划本体 + * 全局唯一,恒为 `PLANNING_AGENT_ID`(R-15.2)。 + * + * 只在真实规划(`planWorkGraph === defaultPlanWorkGraph`)时记:测试夹具几乎总会注入 + * 假 planWorkGraph(defaultPlanWorkGraph 会真的起 CLI 调 workgraph-planning),这道闸门 + * 挡住的正是"把假地址写进真实 system/agents/workgraph-planning/ 目录"这类污染 + * (与 runClaimedNode 里 agentExecutor === defaultAgentExecutor 同一个判据)。 + */ +function recordWorkgraphPlanningMemory(companyId, nodeName, status, failReason) { + if (planWorkGraph !== defaultPlanWorkGraph) return; + try { + const addr = resolveAgentName(companyId, PLANNING_AGENT_ID); + const task = `为 ${nodeName} 规划的 work graph`; + const result = status === 'success' ? 'success' : `fail${failReason ? `: ${failReason}` : ''}`; + memoryService.appendToMemoryMd(addr, task, result, { stats: { toolCallCount: 1 } }); + } catch (err) { + console.error('[graph-engine] workgraph-planning 结果写入 memory.md 失败:', err.message); + } +} + +/** * 批量推进(27-P4 T4/T6):一次拾取至多 `maxConcurrency` 个 ready 节点,并发执行。 * * `maxConcurrency <= 1` 时**直接委托给 `advanceOne`**,不走下面的批量路径——零行为差异, @@ -1847,6 +1875,13 @@ function settleNode(runId, nodeRunId, outcome = {}) { reset: [], infraRerun: false, }; + // 36 号文档:subgraph 节点结算 = 「这次规划出的图最终跑得怎么样」这个信号第一次齐全的时刻 + // (outcome 是 deriveChildOutcome 派生的,代表子 work graph run 的真实收敛结果)。 + const settledDefNode = def.nodes.get(nodeRun.def_node_id); + if (settledDefNode && settledDefNode.nodeKind === 'subgraph') { + recordWorkgraphPlanningMemory(run.company_id, settledDefNode.name || nodeRun.def_node_id, to, failReason); + } + if (to === 'success') { // R-19 产出侧:把这次落盘的文件登记进产物索引,下游节点靠它拿到真实路径。 // 只在 success 时记 —— 失败节点的半成品不该被下游当输入读。 @@ -4605,6 +4640,10 @@ module.exports = { // 导出只为让"success 那一行怎么格式化"不必靠真起 CLI 触发 isRealDispatch 才能验 // (fail 分支可以借道 body_not_ready 走端到端,success 分支目前只有这条直调路径)。 recordNodeOutcomeMemory, + // 36 号文档追加:subgraph 节点(workgraph-planning 规划出的图)结算 → memory.md 桥接。 + // 真实调用方是 settleNode;导出只为不必真起 CLI 触发 planWorkGraph===defaultPlanWorkGraph + // 就能验证格式化与闸门逻辑(同 recordNodeOutcomeMemory 的导出理由)。 + recordWorkgraphPlanningMemory, // 选边 / 熔断 / 透传 selectFailEdge, diff --git a/server/tests/unit/graph-engine-workgraph-planning-memory.test.js b/server/tests/unit/graph-engine-workgraph-planning-memory.test.js new file mode 100644 index 0000000..79dcc1e --- /dev/null +++ b/server/tests/unit/graph-engine-workgraph-planning-memory.test.js @@ -0,0 +1,158 @@ +/** + * Unit Tests — subgraph 节点结算 → workgraph-planning 自己的 memory.md 桥接(36 号文档追加) + * + * 背景:36 号文档真实回归发现 `workgraph-planning` 的图形状裁剪判据现在只有"允许裁剪" + * 没有"具体判据"(单页静态网站任务原样照抄 planner.json 全部 5 节点,一个都没砍),而它 + * 自己完全没有可积累的历史——阶段一的 `recordNodeOutcomeMemory` 桥接明确排除了 subgraph + * 节点(那是给 agent_call 节点记的,subgraph 节点没有对应的"真实执行"概念)。 + * + * 本文件锁的是补上的那半:subgraph 节点结算(= 它规划出的子 work graph 最终收敛)时, + * 把"这次规划的图跑得怎么样"记进 `workgraph-planning` 自己的 memory.md(不是宿主 org node + * 的 `ctx.systemAgentId`,那是宿主自己的身份,不是真正执行规划的 agent——规划本体全局唯一)。 + * + * ① 真实规划(`planWorkGraph === defaultPlanWorkGraph`)+ success → 记 "success" + * ② 真实规划 + fail(reason) → 记 "fail: " + * ③ 注入假 planWorkGraph(全仓库现有 subgraph 测试的常态,见 graph-engine-revive-parent.test.js + * 的 buildParentWithFailingChild)→ 不记,不能因为这次改动就把假地址写进真实 + * system/agents/workgraph-planning/ 目录 + * + * ①②借道直调(不必真起 CLI 触发 planWorkGraph===defaultPlanWorkGraph 才能验证格式化), + * ③走完整的 subgraph 结算流程(复用 graph-engine-revive-parent.test.js 的建图手法), + * 确认现有"全仓库 subgraph 测试都注入假 planWorkGraph"这条约定下这个新桥接确实按兵不动。 + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('subgraph 节点结算桥接进 workgraph-planning 的 memory.md(36 号文档)', () => { + let wdb; + let engine; + let memoryService; + let runsDir; + const TEST_DB_PATH = '/tmp/test-graph-engine-workgraph-planning-memory.sqlite'; + let origAppendToMemoryMd; + let calls; + + beforeAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-wgplan-memory-runs-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/graph-engine', + '../../services/memory-service', + ]) { + delete require.cache[require.resolve(m)]; + } + wdb = require('../../services/work-graph-db'); + engine = require('../../services/graph-engine'); + memoryService = require('../../services/memory-service'); + origAppendToMemoryMd = memoryService.appendToMemoryMd; + }); + + afterAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_RUNS_DIR; + }); + + beforeEach(() => { + const db = wdb.getDb(); + db.pragma('foreign_keys = OFF'); + db.exec(` + DELETE FROM graph_run_loop_records; + DELETE FROM graph_run_nodes; + DELETE FROM graph_runs; + DELETE FROM work_graph_edges; + DELETE FROM work_graph_nodes; + DELETE FROM work_graphs; + DELETE FROM system_agents; + `); + db.pragma('foreign_keys = ON'); + engine.setAgentExecutor(null); + engine.setPlanWorkGraph(null); + memoryService.appendToMemoryMd = origAppendToMemoryMd; + }); + + function spyMemory() { + calls = []; + memoryService.appendToMemoryMd = (...args) => { calls.push(args); }; + } + + it('① 真实规划(直调,不必起真 CLI)+ success → 记一行 "success"', () => { + spyMemory(); + engine.recordWorkgraphPlanningMemory('co-wgplan-1', 'org-coder', 'success', null); + + expect(calls.length).toBe(1); + const [addr, task, result] = calls[0]; + expect(addr).toBe('co-wgplan-1:workgraph-planning'); + expect(task).toContain('org-coder'); + expect(result).toBe('success'); + }); + + it('② 真实规划(直调)+ fail(reason) → 记一行 "fail: "', () => { + spyMemory(); + engine.recordWorkgraphPlanningMemory('co-wgplan-2', 'org-validator', 'fail', 'impl_bug'); + + expect(calls.length).toBe(1); + const [addr, , result] = calls[0]; + expect(addr).toBe('co-wgplan-2:workgraph-planning'); + expect(result).toBe('fail: impl_bug'); + }); + + // ③:复用 graph-engine-revive-parent.test.js 的建图手法——父 run 一个 subgraph 节点 + // (宿主 org node)+ 一张会失败/成功的子图,全程注入假 setPlanWorkGraph(全仓库 subgraph + // 测试的常态:defaultPlanWorkGraph 会真的起 CLI 调 workgraph-planning)。 + function ensureAgents(ids) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); + } + + it('③ 注入假 planWorkGraph(全仓库现有 subgraph 测试的常态)→ 不记,不污染真实目录', async () => { + spyMemory(); + const companyId = 'co-wgplan-3'; + ensureAgents(['org-coder', 'wg-only']); + + const childG = wdb.createWorkGraph({ companyId, nodes: ['wg-only'], edges: [] }); + expect(childG.errors || []).toEqual([]); + + const parentG = wdb.createWorkGraph({ + companyId, + nodes: [{ nodeName: 'org-coder', systemAgentId: 'org-coder', nodeKind: 'subgraph' }], + edges: [], + }); + expect(parentG.errors || []).toEqual([]); + + const parentRun = wdb.createRun({ graphKind: 'work', graphId: parentG.id, companyId, executionMode: 'manual' }); + engine.markRunReady(parentRun.id); + engine.startRun(parentRun.id, { mode: 'manual' }); + + engine.setPlanWorkGraph(() => ({ workGraphId: childG.id })); + engine.setAgentExecutor(async () => ({ status: 'success', output: '子节点成功' })); + + for (let i = 0; i < 12; i += 1) { + const r = await engine.advanceTree(parentRun.id); + if (!r || r.ok === false) break; + } + + expect(wdb.getRunState(parentRun.id).run.run_status).toBe('completed'); + expect(calls.length).toBe(0); + }); +}); diff --git a/system/agents/workgraph-planning/agent.md b/system/agents/workgraph-planning/agent.md index b1c086d..c53ae6e 100644 --- a/system/agents/workgraph-planning/agent.md +++ b/system/agents/workgraph-planning/agent.md @@ -149,13 +149,22 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents ### Step 5 · 落盘规划记录 -把图和**为什么这么设计的理由**写成一份 `workgraph-plan-.md`,供事后人工审计。 +把图和**为什么这么设计的理由**写成一份 `workgraph-plan--<宿主orgNode节点名>.md`, +供事后人工审计。 + +> ⚠️ **文件名必须带宿主 org node 名字,不能只用 ``。** 一个任务里 `org-planner` / +> `org-coder` / `org-validator` 各自都会调你一次、各自规划一张图,都落在**同一个项目根**下—— +> 三次写入如果共用 `workgraph-plan-.md` 这一个文件名,后写的会把先写的**原样覆盖**, +> 事后审计只剩最后一个 org node 的记录(2026-08-23 真实撞见过:`org-planner` 当时的规划记录 +> 被后跑的 `org-validator` 覆盖,事后一个字都找不回来)。你的 `nodeName` +> (首轮消息「① 宿主 org node 的角色定义」段里的「节点名(org graph 内)」)就是这里要用的 +> `<宿主orgNode节点名>`,例如 `workgraph-plan-T-mt5d3mi9-57cf-org-coder.md`。 **落点看首轮消息的「写入边界」段**,不要凭习惯写相对路径: | 边界段说了什么 | 你写哪儿 | |---|---| -| 给了项目根 | `<项目根>/workgraph-plan-.md`,**用绝对路径** | +| 给了项目根 | `<项目根>/workgraph-plan--<宿主orgNode节点名>.md`,**用绝对路径** | | 明说没分到项目根 | **一个文件都不落**,把这份记录直接写进回复正文 | ⚠️ 相对路径(`workspace/...`)会落到**引擎源码仓库**里——那正是边界段禁止写的位置。 @@ -376,7 +385,7 @@ ls data/companies/<公司ID>/agents/ 1. **不要放弃**——被打回一轮是设计里预期的交互,不是失败。 2. **不要原样重交**——不许把同一份图再贴一遍指望它这次能过。 3. **不要绕过校验**——不许直接写数据库、不许把违规节点删光来"通过"。 -4. 每次重交前,**先在规划记录 `workgraph-plan-.md` 里追加一行**:撞了哪条规则、怎么改的。 +4. 每次重交前,**先在规划记录 `workgraph-plan--<宿主orgNode节点名>.md` 里追加一行**:撞了哪条规则、怎么改的。 5. **引擎只给你 3 轮**(`PLAN_MAX_ROUNDS`,比这份模板从前写的 5 轮更紧——每一轮都是一次真花钱的调用)。 第 3 轮还不过,这个节点就按 fail 结算走兜底边。所以**第一轮就把约束九和五项校验自查做完**, 不要把校验当成免费的往返。 @@ -390,7 +399,7 @@ ls data/companies/<公司ID>/agents/ - 节点数 / 边数,其中 backward 边几条; - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3); - **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误); -- 规划记录的绝对路径(`<项目根>/workgraph-plan-.md`;没分到项目根就把记录写在正文里)。 +- 规划记录的绝对路径(`<项目根>/workgraph-plan--<宿主orgNode节点名>.md`;没分到项目根就把记录写在正文里)。 **不要报 `workGraphId`**——图 id 是引擎落库时才生成的,你手上没有,编一个只会让引擎去查一个不存在的图, 白烧一轮(见 Step 7 那张表最后一行)。