Commit 573f2aae9d0c4eb0bb3f7d374637e12ed3c1f5cb
1 parent
0c562d9d
feat(graph-engine): 图执行结算结果桥接进本体 memory.md(36 号文档阶段一)
此前只有人类对话路径 agent-chat.js 会写 memory.md,图执行的验收反馈从不进 memory.md,Dream 天天扫全是空转。runClaimedNode 结算后新增 recordNodeOutcomeMemory 桥接点,只在真派发(isRealDispatch)且 systemAgentId 对应本体已雇佣(bodyDir 存在) 时才记一行 success/fail 摘要,避免测试夹具/假 executor 用例把假地址写进真实 system/agents/ 目录。memory-service.appendToMemoryMd 补 mkdirSync,因为这条新调用方 不像既有的 saveSessionSummary 那样先建过 memory/ 目录。 从原本混在墙钟看门狗改动里的工作区 diff 中拆出,两个特性互不相关。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
219 additions
and
4 deletions
server/services/graph-engine.js
| ... | ... | @@ -69,6 +69,7 @@ const { |
| 69 | 69 | */ |
| 70 | 70 | const sse = require('./graph-run-sse'); |
| 71 | 71 | const locator = require('./agent-locator'); |
| 72 | +const memoryService = require('./memory-service'); | |
| 72 | 73 | |
| 73 | 74 | /** |
| 74 | 75 | * 写入边界段头的**唯一定义**在 prompt-builder(28 §4.4):产出这段的是这里,据此决定 |
| ... | ... | @@ -1486,9 +1487,8 @@ async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts = |
| 1486 | 1487 | // 也大多是 `w-prd`/`impl` 这类不对应仓库里任何真实目录的夹具名——扫真实文件系统 |
| 1487 | 1488 | // 对这些用例既没意义,又会把全仓库几百个注入假 executor 的用例一次性判成 |
| 1488 | 1489 | // body_not_ready(本改动第一版就是这么把测试套件炸穿的,改用这条判据后收窄回来)。 |
| 1489 | - const missingBody = ctx.nodeKind !== 'subgraph' && ctx.systemAgentId && agentExecutor === defaultAgentExecutor | |
| 1490 | - ? locator.missingBodyDefinition(ctx.systemAgentId) | |
| 1491 | - : []; | |
| 1490 | + const isRealDispatch = ctx.nodeKind !== 'subgraph' && !!ctx.systemAgentId && agentExecutor === defaultAgentExecutor; | |
| 1491 | + const missingBody = isRealDispatch ? locator.missingBodyDefinition(ctx.systemAgentId) : []; | |
| 1492 | 1492 | |
| 1493 | 1493 | let outcome; |
| 1494 | 1494 | if (missingBody.length) { |
| ... | ... | @@ -1590,7 +1590,34 @@ async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts = |
| 1590 | 1590 | }; |
| 1591 | 1591 | } |
| 1592 | 1592 | |
| 1593 | - return settleNode(runId, picked.id, outcome); | |
| 1593 | + const settled = settleNode(runId, picked.id, outcome); | |
| 1594 | + // 36 号文档 §三.3:图执行的验收反馈此前从不进 agent 自己的 memory.md(只有人类对话路径 | |
| 1595 | + // agent-chat.js 会写),Dream 天天扫全是空转。这里补上唯一的桥接点——只在真派发、且这个 | |
| 1596 | + // systemAgentId 真的是已雇佣的本体(bodyDir 存在)时才记,避免测试夹具/假 executor 用例 | |
| 1597 | + // 把假地址写进真实 system/agents/ 目录(isRealDispatch 与 missingBody 同一条判据, | |
| 1598 | + // bodyDir 存在性检查是多一层防线)。 | |
| 1599 | + if (isRealDispatch && settled && settled.node && fs.existsSync(locator.bodyDir(ctx.systemAgentId))) { | |
| 1600 | + recordNodeOutcomeMemory(ctx, settled.node); | |
| 1601 | + } | |
| 1602 | + return settled; | |
| 1603 | +} | |
| 1604 | + | |
| 1605 | +/** | |
| 1606 | + * 把节点这次运行的结算结果记一行进它自己的 memory.md——36 号文档提案的桥接点。 | |
| 1607 | + * 只记摘要(成功/失败+原因),不是提炼;提炼是 Dream 的活,这里只负责「让原料存在」。 | |
| 1608 | + */ | |
| 1609 | +function recordNodeOutcomeMemory(ctx, nodeSnapshot) { | |
| 1610 | + try { | |
| 1611 | + const addr = resolveAgentName(ctx.companyId, ctx.systemAgentId); | |
| 1612 | + const task = `${ctx.nodeName}(第 ${ctx.attempt + 1} 次运行)`; | |
| 1613 | + const result = nodeSnapshot.status === 'success' | |
| 1614 | + ? 'success' | |
| 1615 | + : `fail${nodeSnapshot.failReason ? `: ${nodeSnapshot.failReason}` : ''}`; | |
| 1616 | + memoryService.appendToMemoryMd(addr, task, result, { stats: { toolCallCount: 1 } }); | |
| 1617 | + } catch (err) { | |
| 1618 | + // 记忆写入是旁路信号,失败不该反过来影响图执行本身 | |
| 1619 | + console.error('[graph-engine] 节点结果写入 memory.md 失败:', err.message); | |
| 1620 | + } | |
| 1594 | 1621 | } |
| 1595 | 1622 | |
| 1596 | 1623 | /** |
| ... | ... | @@ -4490,6 +4517,11 @@ module.exports = { |
| 4490 | 4517 | splitUnattributable, |
| 4491 | 4518 | sharedWorktreeReason, |
| 4492 | 4519 | |
| 4520 | + // 36 号文档:图执行结果 → memory.md 桥接。真实调用方是 runClaimedNode(结算之后); | |
| 4521 | + // 导出只为让"success 那一行怎么格式化"不必靠真起 CLI 触发 isRealDispatch 才能验 | |
| 4522 | + // (fail 分支可以借道 body_not_ready 走端到端,success 分支目前只有这条直调路径)。 | |
| 4523 | + recordNodeOutcomeMemory, | |
| 4524 | + | |
| 4493 | 4525 | // 选边 / 熔断 / 透传 |
| 4494 | 4526 | selectFailEdge, |
| 4495 | 4527 | handleBackwardEdge, | ... | ... |
server/services/memory-service.js
| ... | ... | @@ -220,6 +220,9 @@ function appendToMemoryMd(agentId, task, result, meta) { |
| 220 | 220 | if (isNoiseEntry(task, result)) return; |
| 221 | 221 | |
| 222 | 222 | const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md'); |
| 223 | + // 唯一现有调用方 saveSessionSummary 先建过 short-term/(连带建出 memory/),这里 | |
| 224 | + // 独立调用(36 号文档:graph-engine 的节点结算桥接)时 memory/ 可能从没被建过。 | |
| 225 | + fs.mkdirSync(path.dirname(memoryPath), { recursive: true }); | |
| 223 | 226 | let content = ''; |
| 224 | 227 | try { |
| 225 | 228 | content = fs.readFileSync(memoryPath, 'utf-8'); | ... | ... |
server/tests/unit/graph-engine-node-outcome-memory.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — 图执行结果桥接进 memory.md(36 号文档:Innovator/Dream 训练闭环可行性调研) | |
| 3 | + * | |
| 4 | + * 36 号文档实查发现的头号阻塞项:`graph-engine.js` 从不写 `memory.md`(唯一写入路径是 | |
| 5 | + * 人类对话 `agent-chat.js`),导致 Dream 每日扫描时,org/work graph 里真正干活的专业节点 | |
| 6 | + * (`prd-planning`/`coder`/`benchmark-validator`……)的 memory.md 永远是模板空壳——不是 | |
| 7 | + * "原料脏",是压根没有原料。本文件锁的是补上的桥接点(`runClaimedNode` 结算之后调用 | |
| 8 | + * `recordNodeOutcomeMemory`): | |
| 9 | + * ① 真实派发 + 本体已雇佣(bodyDir 存在)+ fail(body_not_ready) → 写一行摘要 | |
| 10 | + * (借道 body_not_ready 端到端验证整条 isRealDispatch 真实链路——它跟其它"真派发" | |
| 11 | + * 场景一样满足 agentExecutor===defaultAgentExecutor,却在真正调用 CLI 之前短路, | |
| 12 | + * 是全仓库唯一能不起真 CLI 就摸到 isRealDispatch===true 的路径,35 号文档同款手法) | |
| 13 | + * ② 本体已雇佣,但 bodyDir 不存在 → 不写,避免测试夹具/未雇佣本体把假地址灌进 | |
| 14 | + * 真实 system/agents/ 目录(与 missingBody 同一条判据,多一层防线) | |
| 15 | + * ③ 注入假 executor(全仓库其余用例的常态)→ 不写,不干扰任何现有测试 | |
| 16 | + * ④ `recordNodeOutcomeMemory` 直调:success 一行的格式化(advanceOne 端到端摸不到 | |
| 17 | + * 这条分支——isRealDispatch 为真时 else 分支会走真实 defaultAgentExecutor,起真 CLI, | |
| 18 | + * 不适合单测;直调覆盖纯格式化逻辑,属于"最小可运行检查") | |
| 19 | + * | |
| 20 | + * 隔离手法同 graph-engine-body-not-ready.test.js:monkeypatch 模块级活引用 | |
| 21 | + * (`locator.missingBodyDefinition` / `locator.bodyDir` / `memoryService.appendToMemoryMd`), | |
| 22 | + * 不碰真实 system/agents/ 目录、不做真实文件 I/O。 | |
| 23 | + */ | |
| 24 | +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; | |
| 25 | +import fs from 'fs'; | |
| 26 | +import os from 'os'; | |
| 27 | +import path from 'path'; | |
| 28 | + | |
| 29 | +describe('图执行结果桥接进 memory.md(36 号文档)', () => { | |
| 30 | + let wdb; | |
| 31 | + let engine; | |
| 32 | + let locator; | |
| 33 | + let memoryService; | |
| 34 | + let runsDir; | |
| 35 | + const TEST_DB_PATH = '/tmp/test-graph-engine-node-outcome-memory.sqlite'; | |
| 36 | + let origBodyDir; | |
| 37 | + let origAppendToMemoryMd; | |
| 38 | + let calls; | |
| 39 | + | |
| 40 | + beforeAll(() => { | |
| 41 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | |
| 42 | + try { fs.unlinkSync(f); } catch {} | |
| 43 | + } | |
| 44 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 45 | + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-node-outcome-memory-runs-')); | |
| 46 | + process.env.GRAPH_RUNS_DIR = runsDir; | |
| 47 | + for (const m of [ | |
| 48 | + '../../services/graph-db', | |
| 49 | + '../../services/graph-service', | |
| 50 | + '../../services/org-graph-service', | |
| 51 | + '../../services/work-graph-db', | |
| 52 | + '../../services/graph-run-sse', | |
| 53 | + '../../services/graph-engine', | |
| 54 | + '../../services/agent-locator', | |
| 55 | + '../../services/memory-service', | |
| 56 | + ]) { | |
| 57 | + delete require.cache[require.resolve(m)]; | |
| 58 | + } | |
| 59 | + wdb = require('../../services/work-graph-db'); | |
| 60 | + engine = require('../../services/graph-engine'); | |
| 61 | + locator = require('../../services/agent-locator'); | |
| 62 | + memoryService = require('../../services/memory-service'); | |
| 63 | + origBodyDir = locator.bodyDir; | |
| 64 | + origAppendToMemoryMd = memoryService.appendToMemoryMd; | |
| 65 | + }); | |
| 66 | + | |
| 67 | + afterAll(() => { | |
| 68 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | |
| 69 | + try { fs.unlinkSync(f); } catch {} | |
| 70 | + } | |
| 71 | + delete process.env.GRAPH_DB_PATH; | |
| 72 | + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} | |
| 73 | + delete process.env.GRAPH_RUNS_DIR; | |
| 74 | + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { | |
| 75 | + delete require.cache[require.resolve(m)]; | |
| 76 | + } | |
| 77 | + }); | |
| 78 | + | |
| 79 | + afterEach(() => { | |
| 80 | + locator.missingBodyDefinition = () => []; | |
| 81 | + locator.bodyDir = origBodyDir; | |
| 82 | + memoryService.appendToMemoryMd = origAppendToMemoryMd; | |
| 83 | + engine.setAgentExecutor(null); | |
| 84 | + }); | |
| 85 | + | |
| 86 | + function ensureAgent(id) { | |
| 87 | + const db = wdb.getDb(); | |
| 88 | + const ts = new Date().toISOString(); | |
| 89 | + db.prepare(` | |
| 90 | + INSERT OR IGNORE INTO system_agents | |
| 91 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 92 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) | |
| 93 | + `).run(id, id, `system/agents/${id}`, ts, ts); | |
| 94 | + } | |
| 95 | + | |
| 96 | + function buildRun({ companyId, taskId, systemAgentId, nodeName = 'solo' }) { | |
| 97 | + ensureAgent(systemAgentId); | |
| 98 | + const g = wdb.createWorkGraph({ | |
| 99 | + companyId, taskId, | |
| 100 | + nodes: [{ nodeName, systemAgentId }], | |
| 101 | + edges: [], | |
| 102 | + }); | |
| 103 | + expect(g.errors || []).toEqual([]); | |
| 104 | + const run = wdb.createRun({ | |
| 105 | + graphKind: 'work', graphId: g.id, companyId, taskId, | |
| 106 | + budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } }, | |
| 107 | + }); | |
| 108 | + engine.markRunReady(run.id); | |
| 109 | + engine.startRun(run.id, { mode: 'manual' }); | |
| 110 | + return run.id; | |
| 111 | + } | |
| 112 | + | |
| 113 | + function spyMemory() { | |
| 114 | + calls = []; | |
| 115 | + memoryService.appendToMemoryMd = (...args) => { calls.push(args); }; | |
| 116 | + } | |
| 117 | + | |
| 118 | + it('① 真实派发(借道 body_not_ready 短路,不起真 CLI)+ bodyDir 存在 → 记一行摘要', async () => { | |
| 119 | + spyMemory(); | |
| 120 | + const existingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zz-hired-body-')); | |
| 121 | + locator.bodyDir = (id) => (id === 'zz-hired-agent-1' ? existingDir : origBodyDir(id)); | |
| 122 | + // 本体判为不完整——不会真的调用 defaultAgentExecutor(body_not_ready 在派发之前短路), | |
| 123 | + // 但仍然是 isRealDispatch===true 的真实结算路径,settled.node.failReason 是真结算出来的。 | |
| 124 | + locator.missingBodyDefinition = (id) => (id === 'zz-hired-agent-1' ? ['agent.md'] : []); | |
| 125 | + | |
| 126 | + const runId = buildRun({ companyId: 'co-mem-1', taskId: 'task-mem-1', systemAgentId: 'zz-hired-agent-1', nodeName: '实现节点' }); | |
| 127 | + const out = await engine.advanceOne(runId); | |
| 128 | + expect(out.ok).toBe(true); | |
| 129 | + | |
| 130 | + expect(calls.length).toBe(1); | |
| 131 | + const [addr, task, result] = calls[0]; | |
| 132 | + expect(addr).toBe('co-mem-1:zz-hired-agent-1'); | |
| 133 | + expect(task).toContain('实现节点'); | |
| 134 | + expect(task).toContain('第 1 次运行'); | |
| 135 | + expect(result).toBe('fail: body_not_ready'); | |
| 136 | + | |
| 137 | + fs.rmSync(existingDir, { recursive: true, force: true }); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + it('② bodyDir 不存在(未雇佣/测试夹具)→ 不记,避免灌脏真实 system/agents/', async () => { | |
| 141 | + spyMemory(); | |
| 142 | + locator.bodyDir = (id) => (id === 'zz-unhired-agent' ? path.join(os.tmpdir(), 'zz-graph-engine-memory-test-does-not-exist') : origBodyDir(id)); | |
| 143 | + locator.missingBodyDefinition = (id) => (id === 'zz-unhired-agent' ? ['agent.md'] : []); | |
| 144 | + | |
| 145 | + const runId = buildRun({ companyId: 'co-mem-2', taskId: 'task-mem-2', systemAgentId: 'zz-unhired-agent' }); | |
| 146 | + await engine.advanceOne(runId); | |
| 147 | + | |
| 148 | + expect(calls.length).toBe(0); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it('③ 注入假 executor(isRealDispatch=false)→ 不记,不干扰全仓库其余用例', async () => { | |
| 152 | + spyMemory(); | |
| 153 | + const existingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zz-hired-body-fake-exec-')); | |
| 154 | + locator.bodyDir = (id) => (id === 'zz-fake-exec-agent' ? existingDir : origBodyDir(id)); | |
| 155 | + locator.missingBodyDefinition = () => ['agent.md']; // 就算判成缺失也不该拦——闸门本身就不该生效 | |
| 156 | + | |
| 157 | + engine.setAgentExecutor(async () => ({ status: 'success' })); | |
| 158 | + const runId = buildRun({ companyId: 'co-mem-3', taskId: 'task-mem-3', systemAgentId: 'zz-fake-exec-agent' }); | |
| 159 | + await engine.advanceOne(runId); | |
| 160 | + | |
| 161 | + expect(calls.length).toBe(0); | |
| 162 | + | |
| 163 | + fs.rmSync(existingDir, { recursive: true, force: true }); | |
| 164 | + }); | |
| 165 | + | |
| 166 | + it('④ recordNodeOutcomeMemory 直调:success 一行格式化正确', () => { | |
| 167 | + spyMemory(); | |
| 168 | + engine.recordNodeOutcomeMemory( | |
| 169 | + { companyId: 'co-mem-4', systemAgentId: 'zz-direct-agent', nodeName: '校验节点', attempt: 2 }, | |
| 170 | + { status: 'success', failReason: null }, | |
| 171 | + ); | |
| 172 | + | |
| 173 | + expect(calls.length).toBe(1); | |
| 174 | + const [addr, task, result] = calls[0]; | |
| 175 | + expect(addr).toBe('co-mem-4:zz-direct-agent'); | |
| 176 | + expect(task).toContain('校验节点'); | |
| 177 | + expect(task).toContain('第 3 次运行'); | |
| 178 | + expect(result).toBe('success'); | |
| 179 | + }); | |
| 180 | +}); | ... | ... |