From 573f2aae9d0c4eb0bb3f7d374637e12ed3c1f5cb Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 23 Aug 2026 11:19:32 +0800 Subject: [PATCH] feat(graph-engine): 图执行结算结果桥接进本体 memory.md(36 号文档阶段一) --- server/services/graph-engine.js | 40 ++++++++++++++++++++++++++++++++++++---- server/services/memory-service.js | 3 +++ server/tests/unit/graph-engine-node-outcome-memory.test.js | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 server/tests/unit/graph-engine-node-outcome-memory.test.js diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 7e02dee..f146598 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -69,6 +69,7 @@ const { */ const sse = require('./graph-run-sse'); const locator = require('./agent-locator'); +const memoryService = require('./memory-service'); /** * 写入边界段头的**唯一定义**在 prompt-builder(28 §4.4):产出这段的是这里,据此决定 @@ -1486,9 +1487,8 @@ async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts = // 也大多是 `w-prd`/`impl` 这类不对应仓库里任何真实目录的夹具名——扫真实文件系统 // 对这些用例既没意义,又会把全仓库几百个注入假 executor 的用例一次性判成 // body_not_ready(本改动第一版就是这么把测试套件炸穿的,改用这条判据后收窄回来)。 - const missingBody = ctx.nodeKind !== 'subgraph' && ctx.systemAgentId && agentExecutor === defaultAgentExecutor - ? locator.missingBodyDefinition(ctx.systemAgentId) - : []; + const isRealDispatch = ctx.nodeKind !== 'subgraph' && !!ctx.systemAgentId && agentExecutor === defaultAgentExecutor; + const missingBody = isRealDispatch ? locator.missingBodyDefinition(ctx.systemAgentId) : []; let outcome; if (missingBody.length) { @@ -1590,7 +1590,34 @@ async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts = }; } - return settleNode(runId, picked.id, outcome); + const settled = settleNode(runId, picked.id, outcome); + // 36 号文档 §三.3:图执行的验收反馈此前从不进 agent 自己的 memory.md(只有人类对话路径 + // agent-chat.js 会写),Dream 天天扫全是空转。这里补上唯一的桥接点——只在真派发、且这个 + // systemAgentId 真的是已雇佣的本体(bodyDir 存在)时才记,避免测试夹具/假 executor 用例 + // 把假地址写进真实 system/agents/ 目录(isRealDispatch 与 missingBody 同一条判据, + // bodyDir 存在性检查是多一层防线)。 + if (isRealDispatch && settled && settled.node && fs.existsSync(locator.bodyDir(ctx.systemAgentId))) { + recordNodeOutcomeMemory(ctx, settled.node); + } + return settled; +} + +/** + * 把节点这次运行的结算结果记一行进它自己的 memory.md——36 号文档提案的桥接点。 + * 只记摘要(成功/失败+原因),不是提炼;提炼是 Dream 的活,这里只负责「让原料存在」。 + */ +function recordNodeOutcomeMemory(ctx, nodeSnapshot) { + try { + const addr = resolveAgentName(ctx.companyId, ctx.systemAgentId); + const task = `${ctx.nodeName}(第 ${ctx.attempt + 1} 次运行)`; + const result = nodeSnapshot.status === 'success' + ? 'success' + : `fail${nodeSnapshot.failReason ? `: ${nodeSnapshot.failReason}` : ''}`; + memoryService.appendToMemoryMd(addr, task, result, { stats: { toolCallCount: 1 } }); + } catch (err) { + // 记忆写入是旁路信号,失败不该反过来影响图执行本身 + console.error('[graph-engine] 节点结果写入 memory.md 失败:', err.message); + } } /** @@ -4490,6 +4517,11 @@ module.exports = { splitUnattributable, sharedWorktreeReason, + // 36 号文档:图执行结果 → memory.md 桥接。真实调用方是 runClaimedNode(结算之后); + // 导出只为让"success 那一行怎么格式化"不必靠真起 CLI 触发 isRealDispatch 才能验 + // (fail 分支可以借道 body_not_ready 走端到端,success 分支目前只有这条直调路径)。 + recordNodeOutcomeMemory, + // 选边 / 熔断 / 透传 selectFailEdge, handleBackwardEdge, diff --git a/server/services/memory-service.js b/server/services/memory-service.js index 3a216a7..d1442dc 100644 --- a/server/services/memory-service.js +++ b/server/services/memory-service.js @@ -220,6 +220,9 @@ function appendToMemoryMd(agentId, task, result, meta) { if (isNoiseEntry(task, result)) return; const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md'); + // 唯一现有调用方 saveSessionSummary 先建过 short-term/(连带建出 memory/),这里 + // 独立调用(36 号文档:graph-engine 的节点结算桥接)时 memory/ 可能从没被建过。 + fs.mkdirSync(path.dirname(memoryPath), { recursive: true }); let content = ''; try { content = fs.readFileSync(memoryPath, 'utf-8'); diff --git a/server/tests/unit/graph-engine-node-outcome-memory.test.js b/server/tests/unit/graph-engine-node-outcome-memory.test.js new file mode 100644 index 0000000..22667bf --- /dev/null +++ b/server/tests/unit/graph-engine-node-outcome-memory.test.js @@ -0,0 +1,180 @@ +/** + * Unit Tests — 图执行结果桥接进 memory.md(36 号文档:Innovator/Dream 训练闭环可行性调研) + * + * 36 号文档实查发现的头号阻塞项:`graph-engine.js` 从不写 `memory.md`(唯一写入路径是 + * 人类对话 `agent-chat.js`),导致 Dream 每日扫描时,org/work graph 里真正干活的专业节点 + * (`prd-planning`/`coder`/`benchmark-validator`……)的 memory.md 永远是模板空壳——不是 + * "原料脏",是压根没有原料。本文件锁的是补上的桥接点(`runClaimedNode` 结算之后调用 + * `recordNodeOutcomeMemory`): + * ① 真实派发 + 本体已雇佣(bodyDir 存在)+ fail(body_not_ready) → 写一行摘要 + * (借道 body_not_ready 端到端验证整条 isRealDispatch 真实链路——它跟其它"真派发" + * 场景一样满足 agentExecutor===defaultAgentExecutor,却在真正调用 CLI 之前短路, + * 是全仓库唯一能不起真 CLI 就摸到 isRealDispatch===true 的路径,35 号文档同款手法) + * ② 本体已雇佣,但 bodyDir 不存在 → 不写,避免测试夹具/未雇佣本体把假地址灌进 + * 真实 system/agents/ 目录(与 missingBody 同一条判据,多一层防线) + * ③ 注入假 executor(全仓库其余用例的常态)→ 不写,不干扰任何现有测试 + * ④ `recordNodeOutcomeMemory` 直调:success 一行的格式化(advanceOne 端到端摸不到 + * 这条分支——isRealDispatch 为真时 else 分支会走真实 defaultAgentExecutor,起真 CLI, + * 不适合单测;直调覆盖纯格式化逻辑,属于"最小可运行检查") + * + * 隔离手法同 graph-engine-body-not-ready.test.js:monkeypatch 模块级活引用 + * (`locator.missingBodyDefinition` / `locator.bodyDir` / `memoryService.appendToMemoryMd`), + * 不碰真实 system/agents/ 目录、不做真实文件 I/O。 + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('图执行结果桥接进 memory.md(36 号文档)', () => { + let wdb; + let engine; + let locator; + let memoryService; + let runsDir; + const TEST_DB_PATH = '/tmp/test-graph-engine-node-outcome-memory.sqlite'; + let origBodyDir; + 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-node-outcome-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/agent-locator', + '../../services/memory-service', + ]) { + delete require.cache[require.resolve(m)]; + } + wdb = require('../../services/work-graph-db'); + engine = require('../../services/graph-engine'); + locator = require('../../services/agent-locator'); + memoryService = require('../../services/memory-service'); + origBodyDir = locator.bodyDir; + 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; + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { + delete require.cache[require.resolve(m)]; + } + }); + + afterEach(() => { + locator.missingBodyDefinition = () => []; + locator.bodyDir = origBodyDir; + memoryService.appendToMemoryMd = origAppendToMemoryMd; + engine.setAgentExecutor(null); + }); + + function ensureAgent(id) { + const db = wdb.getDb(); + const ts = new Date().toISOString(); + 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', ?, ?) + `).run(id, id, `system/agents/${id}`, ts, ts); + } + + function buildRun({ companyId, taskId, systemAgentId, nodeName = 'solo' }) { + ensureAgent(systemAgentId); + const g = wdb.createWorkGraph({ + companyId, taskId, + nodes: [{ nodeName, systemAgentId }], + edges: [], + }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ + graphKind: 'work', graphId: g.id, companyId, taskId, + budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } }, + }); + engine.markRunReady(run.id); + engine.startRun(run.id, { mode: 'manual' }); + return run.id; + } + + function spyMemory() { + calls = []; + memoryService.appendToMemoryMd = (...args) => { calls.push(args); }; + } + + it('① 真实派发(借道 body_not_ready 短路,不起真 CLI)+ bodyDir 存在 → 记一行摘要', async () => { + spyMemory(); + const existingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zz-hired-body-')); + locator.bodyDir = (id) => (id === 'zz-hired-agent-1' ? existingDir : origBodyDir(id)); + // 本体判为不完整——不会真的调用 defaultAgentExecutor(body_not_ready 在派发之前短路), + // 但仍然是 isRealDispatch===true 的真实结算路径,settled.node.failReason 是真结算出来的。 + locator.missingBodyDefinition = (id) => (id === 'zz-hired-agent-1' ? ['agent.md'] : []); + + const runId = buildRun({ companyId: 'co-mem-1', taskId: 'task-mem-1', systemAgentId: 'zz-hired-agent-1', nodeName: '实现节点' }); + const out = await engine.advanceOne(runId); + expect(out.ok).toBe(true); + + expect(calls.length).toBe(1); + const [addr, task, result] = calls[0]; + expect(addr).toBe('co-mem-1:zz-hired-agent-1'); + expect(task).toContain('实现节点'); + expect(task).toContain('第 1 次运行'); + expect(result).toBe('fail: body_not_ready'); + + fs.rmSync(existingDir, { recursive: true, force: true }); + }); + + it('② bodyDir 不存在(未雇佣/测试夹具)→ 不记,避免灌脏真实 system/agents/', async () => { + spyMemory(); + locator.bodyDir = (id) => (id === 'zz-unhired-agent' ? path.join(os.tmpdir(), 'zz-graph-engine-memory-test-does-not-exist') : origBodyDir(id)); + locator.missingBodyDefinition = (id) => (id === 'zz-unhired-agent' ? ['agent.md'] : []); + + const runId = buildRun({ companyId: 'co-mem-2', taskId: 'task-mem-2', systemAgentId: 'zz-unhired-agent' }); + await engine.advanceOne(runId); + + expect(calls.length).toBe(0); + }); + + it('③ 注入假 executor(isRealDispatch=false)→ 不记,不干扰全仓库其余用例', async () => { + spyMemory(); + const existingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zz-hired-body-fake-exec-')); + locator.bodyDir = (id) => (id === 'zz-fake-exec-agent' ? existingDir : origBodyDir(id)); + locator.missingBodyDefinition = () => ['agent.md']; // 就算判成缺失也不该拦——闸门本身就不该生效 + + engine.setAgentExecutor(async () => ({ status: 'success' })); + const runId = buildRun({ companyId: 'co-mem-3', taskId: 'task-mem-3', systemAgentId: 'zz-fake-exec-agent' }); + await engine.advanceOne(runId); + + expect(calls.length).toBe(0); + + fs.rmSync(existingDir, { recursive: true, force: true }); + }); + + it('④ recordNodeOutcomeMemory 直调:success 一行格式化正确', () => { + spyMemory(); + engine.recordNodeOutcomeMemory( + { companyId: 'co-mem-4', systemAgentId: 'zz-direct-agent', nodeName: '校验节点', attempt: 2 }, + { status: 'success', failReason: null }, + ); + + expect(calls.length).toBe(1); + const [addr, task, result] = calls[0]; + expect(addr).toBe('co-mem-4:zz-direct-agent'); + expect(task).toContain('校验节点'); + expect(task).toContain('第 3 次运行'); + expect(result).toBe('success'); + }); +}); -- libgit2 0.21.4