Commit 573f2aae9d0c4eb0bb3f7d374637e12ed3c1f5cb

Authored by yibo
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>
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 +});
... ...