Commit bab675cbc50465b8c82ee25735cd280891119dcf

Authored by yibo
1 parent ec17c8ff

feat(graph-engine): 图执行前置检查本体完整性(35 号文档提案 A)

runClaimedNode 派发真实调用之前,先查一次 agentLocator.missingBodyDefinition
(systemAgentId)——此前这个判据只在招聘/建公司路径上被查过,图执行的核心路径
从没查过:本体因任何原因(装配没跑完、手工改坏、迁移遗留)仍是空骨架时,引擎
会原样把它当正常本体派发一次真实调用,静默产出一份没有角色指导支撑的结果
(真实样本见 34 号文档 §五:unit-tester 顶着 _base 骨架跑完了一整条真实任务)。

本体不完整时:新 fail_reason='body_not_ready'(与招聘路径的同名状态同义),
不扣预算(没有调用发生,"一次调用一定发生了"这条记账理由不成立)、不起边界
守卫、走正常的选边/兜底/熔断路径(不发明第二套重试语义)。

只在 agentExecutor === defaultAgentExecutor 时生效(与 boundaryGuardApplicable
同一判据)——第一版忘了这条,把全仓库几百个用假 systemAgentId + 注入假 executor
的用例全部判成 body_not_ready,跑单测直接炸穿 75 个用例;加上这条判据后收窄回来,
只在真会起 CLI 的路径上生效。

只查 agent_call 节点:subgraph 节点的 systemAgentId 不是即将被直接调用的本体,
它派发的是 workgraph-planning 去规划子图。

新增 graph-engine-body-not-ready.test.js(4 例)+ 更新
graph-engine-fail-reason.test.js 的取值域断言(五个→六个)。全量单测
1004/1004 通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/services/graph-engine.js
@@ -201,6 +201,11 @@ const ENGINE_FAIL_REASONS = Object.freeze({ @@ -201,6 +201,11 @@ const ENGINE_FAIL_REASONS = Object.freeze({
201 // 不发明取值域之外的字符串(否则会被 §四.D.2.2 的合法性检查当自创取值降级成 null)。 201 // 不发明取值域之外的字符串(否则会被 §四.D.2.2 的合法性检查当自创取值降级成 null)。
202 MISSING_INPUT: 'missing_required_input', 202 MISSING_INPUT: 'missing_required_input',
203 PRODUCES_MISSING: 'produces_missing', 203 PRODUCES_MISSING: 'produces_missing',
  204 + // 35 号文档提案 A:本体完整性闸门只在招聘路径上查过(company-service.js 的
  205 + // 'body_not_ready' 状态),图执行侧从未查过——本体因任何原因仍是空骨架时,
  206 + // 会静默拿一份没有角色指导的提示词去跑真实 CLI(真实样本见 34 号文档 §五)。
  207 + // 字符串与招聘路径的 'body_not_ready' 同名同义,不是两套判词。
  208 + BODY_NOT_READY: 'body_not_ready',
204 }); 209 });
205 210
206 /** 归因/结构化产出文件名(agent.md「产出格式」:runs/{rootRunId}/nodes/{nodeRunId}/attempt-{n}.json) */ 211 /** 归因/结构化产出文件名(agent.md「产出格式」:runs/{rootRunId}/nodes/{nodeRunId}/attempt-{n}.json) */
@@ -1466,56 +1471,91 @@ async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts = @@ -1466,56 +1471,91 @@ async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts =
1466 // 自觉拼对路径,而真跑已经证明它拼不出来。引擎直接把路径列出来,不给它猜的机会。 1471 // 自觉拼对路径,而真跑已经证明它拼不出来。引擎直接把路径列出来,不给它猜的机会。
1467 ctx.task += describeInputs(ctx, def); 1472 ctx.task += describeInputs(ctx, def);
1468 1473
1469 - // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。  
1470 - // subgraph 节点这一次花的是 workgraph-planning 的调用,同样按一次计。  
1471 - spendBudget(run, { agentCalls: 1 });  
1472 -  
1473 - sse.emitEvent(  
1474 - runId,  
1475 - 'agent.call',  
1476 - `${ctx.nodeKind === 'subgraph' ? '规划子图' : '调用 agent'} ${ctx.systemAgentId || nodeName}(attempt #${picked.attempt + 1})`,  
1477 - {  
1478 - node_run_id: picked.id, node_name: nodeName, node_kind: ctx.nodeKind,  
1479 - system_agent_id: ctx.systemAgentId, attempt: picked.attempt,  
1480 - remediation_note: ctx.remediationNote,  
1481 - },  
1482 - { ref: picked.id },  
1483 - );  
1484 -  
1485 - // W3:调用前后各拍一张宿主仓库快照。能拦什么拦不了什么见 applyBoundaryGuard 的注释。  
1486 - const guardSnapshot = beginBoundaryGuard(ctx); 1474 + // 35 号文档提案 A:本体完整性闸门。此前 `missingBodyDefinition` 只在招聘/建公司
  1475 + // 路径上被查过(graph-service.js / graph-seed-service.js / company-service.js),
  1476 + // 图执行的核心路径(这里)从未查过——本体因任何原因(装配没跑完、手工改坏、
  1477 + // 迁移遗留……)仍是空骨架时,引擎会原样把它当成正常本体,派发一次真实 CLI 调用,
  1478 + // 静默产出一份「像是完成了、其实没有任何角色指导」的结果(真实样本见 34 号文档 §五:
  1479 + // `unit-tester` 全程顶着 `_base` 骨架跑完了一整条真实任务)。
  1480 + //
  1481 + // 只查 `agent_call` 节点:subgraph 节点派发的是 workgraph-planning 去规划子图,
  1482 + // 它的 `systemAgentId` 不是「即将被真实调用的本体」,不是这里要拦的场景。
  1483 + //
  1484 + // 且只在**真会起 CLI 的执行器**下查(与 `boundaryGuardApplicable` 同一个判据):
  1485 + // 测试注入的假 executor 是进程内函数,压根不会读任何 agent.md,用的 `systemAgentId`
  1486 + // 也大多是 `w-prd`/`impl` 这类不对应仓库里任何真实目录的夹具名——扫真实文件系统
  1487 + // 对这些用例既没意义,又会把全仓库几百个注入假 executor 的用例一次性判成
  1488 + // body_not_ready(本改动第一版就是这么把测试套件炸穿的,改用这条判据后收窄回来)。
  1489 + const missingBody = ctx.nodeKind !== 'subgraph' && ctx.systemAgentId && agentExecutor === defaultAgentExecutor
  1490 + ? locator.missingBodyDefinition(ctx.systemAgentId)
  1491 + : [];
1487 1492
1488 let outcome; 1493 let outcome;
1489 - try {  
1490 - outcome = ctx.nodeKind === 'subgraph' ? await executeSubgraph(ctx) : await agentExecutor(ctx);  
1491 - } catch (err) {  
1492 - // executor 自己炸了(规划失败 / 配置缺失等)算节点失败。  
1493 - // W12 补完:fail_reason 落 `node_error`(不再是 null)。选边行为与 null 完全一致  
1494 - // ——没有任何图骨架写这条边,`selectFailEdge` 精确匹配不到就退兜底边——  
1495 - // 但概览 tab 从「失败、原因 —」变成「失败、node_error」,操作者第一眼就知道  
1496 - // 这是**引擎/配置**层面炸的,不是 agent 判的业务失败。取值论证见 ENGINE_FAIL_REASONS。  
1497 - // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。 1494 + if (missingBody.length) {
  1495 + // 没有真的发起调用:不扣预算(「一次调用一定发生了」这条记账理由在这里不成立
  1496 + // ——这次压根没有调用发生),不起边界守卫(没有子进程会去动宿主仓库)。
1498 outcome = { 1497 outcome = {
1499 - status: 'fail', output: err.message,  
1500 - failReason: ENGINE_FAIL_REASONS.NODE_ERROR, tokens: err.tokens || 0, 1498 + status: 'fail',
  1499 + failReason: ENGINE_FAIL_REASONS.BODY_NOT_READY,
  1500 + output: `本体 ${ctx.systemAgentId} 仍缺角色定义(${missingBody.join(', ')}),本节点未派发真实调用`,
1501 }; 1501 };
1502 - // W12:这条路径以前是**净损失** —— err.message 写进 outcome.output 之后没有任何一环消费它:  
1503 - // 不落库(graph_run_nodes 没有 output 列,且不该有,见 EVENT_TEXT_MAX 的注释)、  
1504 - // 不进 SSE、也不写 timeline,fail_reason 还是 null。操作者看到的是「节点 fail、无任何原因」。  
1505 - // 真跑第一个 work 节点时 AgentManager 抛的「Agent not found」正是从这里出去的。  
1506 - // 现在原因先进审计轨(timeline.ndjson,永不改写)+ 实时轨(stream.log / SSE),  
1507 - // 排障至少有的可查;错误类型与调用目标一并记,好判断是「本体没雇进公司」还是别的。  
1508 - timelineNote(ctx, 'node.error', `节点执行器抛异常(${(err && err.name) || 'Error'}):${err && err.message}`, {  
1509 - node_run_id: ctx.nodeRunId, node_name: nodeName, attempt: ctx.attempt,  
1510 - node_kind: ctx.nodeKind, system_agent_id: ctx.systemAgentId || null,  
1511 - error: clipText(err && err.message ? err.message : String(err)),  
1512 - error_type: (err && err.name) || 'Error',  
1513 - // 栈顶几行足够定位抛点(整份栈进事件没意义,也会把 stream.log 撑大)  
1514 - error_stack: clipText(err && err.stack ? String(err.stack).split('\n').slice(0, 6).join('\n') : null),  
1515 - }); 1502 + timelineNote(ctx, 'node.boundary',
  1503 + `本体 ${ctx.systemAgentId} 尚未装配完成(缺 ${missingBody.join(', ')}),节点直接判 fail,不派发真实调用`,
  1504 + {
  1505 + node_run_id: ctx.nodeRunId, node_name: nodeName, system_agent_id: ctx.systemAgentId,
  1506 + missing: missingBody, degraded: 'body_not_ready',
  1507 + });
  1508 + } else {
  1509 + // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。
  1510 + // subgraph 节点这一次花的是 workgraph-planning 的调用,同样按一次计。
  1511 + spendBudget(run, { agentCalls: 1 });
  1512 +
  1513 + sse.emitEvent(
  1514 + runId,
  1515 + 'agent.call',
  1516 + `${ctx.nodeKind === 'subgraph' ? '规划子图' : '调用 agent'} ${ctx.systemAgentId || nodeName}(attempt #${picked.attempt + 1})`,
  1517 + {
  1518 + node_run_id: picked.id, node_name: nodeName, node_kind: ctx.nodeKind,
  1519 + system_agent_id: ctx.systemAgentId, attempt: picked.attempt,
  1520 + remediation_note: ctx.remediationNote,
  1521 + },
  1522 + { ref: picked.id },
  1523 + );
  1524 +
  1525 + // W3:调用前后各拍一张宿主仓库快照。能拦什么拦不了什么见 applyBoundaryGuard 的注释。
  1526 + const guardSnapshot = beginBoundaryGuard(ctx);
  1527 +
  1528 + try {
  1529 + outcome = ctx.nodeKind === 'subgraph' ? await executeSubgraph(ctx) : await agentExecutor(ctx);
  1530 + } catch (err) {
  1531 + // executor 自己炸了(规划失败 / 配置缺失等)算节点失败。
  1532 + // W12 补完:fail_reason 落 `node_error`(不再是 null)。选边行为与 null 完全一致
  1533 + // ——没有任何图骨架写这条边,`selectFailEdge` 精确匹配不到就退兜底边——
  1534 + // 但概览 tab 从「失败、原因 —」变成「失败、node_error」,操作者第一眼就知道
  1535 + // 这是**引擎/配置**层面炸的,不是 agent 判的业务失败。取值论证见 ENGINE_FAIL_REASONS。
  1536 + // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。
  1537 + outcome = {
  1538 + status: 'fail', output: err.message,
  1539 + failReason: ENGINE_FAIL_REASONS.NODE_ERROR, tokens: err.tokens || 0,
  1540 + };
  1541 + // W12:这条路径以前是**净损失** —— err.message 写进 outcome.output 之后没有任何一环消费它:
  1542 + // 不落库(graph_run_nodes 没有 output 列,且不该有,见 EVENT_TEXT_MAX 的注释)、
  1543 + // 不进 SSE、也不写 timeline,fail_reason 还是 null。操作者看到的是「节点 fail、无任何原因」。
  1544 + // 真跑第一个 work 节点时 AgentManager 抛的「Agent not found」正是从这里出去的。
  1545 + // 现在原因先进审计轨(timeline.ndjson,永不改写)+ 实时轨(stream.log / SSE),
  1546 + // 排障至少有的可查;错误类型与调用目标一并记,好判断是「本体没雇进公司」还是别的。
  1547 + timelineNote(ctx, 'node.error', `节点执行器抛异常(${(err && err.name) || 'Error'}):${err && err.message}`, {
  1548 + node_run_id: ctx.nodeRunId, node_name: nodeName, attempt: ctx.attempt,
  1549 + node_kind: ctx.nodeKind, system_agent_id: ctx.systemAgentId || null,
  1550 + error: clipText(err && err.message ? err.message : String(err)),
  1551 + error_type: (err && err.name) || 'Error',
  1552 + // 栈顶几行足够定位抛点(整份栈进事件没意义,也会把 stream.log 撑大)
  1553 + error_stack: clipText(err && err.stack ? String(err.stack).split('\n').slice(0, 6).join('\n') : null),
  1554 + });
  1555 + }
  1556 + // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。
  1557 + outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome);
1516 } 1558 }
1517 - // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。  
1518 - outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome);  
1519 1559
1520 // executor 报了 token 用量就一并扣。产生端见 defaultAgentExecutor 的 onAcpEvent 1560 // executor 报了 token 用量就一并扣。产生端见 defaultAgentExecutor 的 onAcpEvent
1521 // (claude-cli 协议的 session/result.usage);不上报 usage 的协议这一维恒为 0, 1561 // (claude-cli 协议的 session/result.usage);不上报 usage 的协议这一维恒为 0,
server/tests/unit/graph-engine-body-not-ready.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — 35 号文档提案 A:本体完整性闸门接进图执行前置检查
  3 + *
  4 + * `agentLocator.missingBodyDefinition()` 此前只在招聘/建公司路径上被查过
  5 + * (graph-service.js / graph-seed-service.js / company-service.js),图执行的
  6 + * 核心路径(`runClaimedNode`)从未查过——本体因任何原因仍是空骨架时,引擎会
  7 + * 原样把它当正常本体,派发一次真实调用(真实样本见 34 号文档 §五:`unit-tester`
  8 + * 全程顶着 `_base` 骨架跑完了一整条真实任务,产出一份没有任何角色指导支撑的结果)。
  9 + *
  10 + * 本文件锁的是修完之后的行为:
  11 + * ① 本体不完整时,节点直接判 fail(`body_not_ready`),**不派发真实调用**
  12 + * (注入的 executor 断言从未被调用)
  13 + * ② 不扣预算(没有调用发生,「一次调用一定发生了」这条记账理由不成立)
  14 + * ③ 本体完整时行为不变(不动 §三十一/§三十三 号文档已经验过的正常路径)
  15 + *
  16 + * 隔离:monkeypatch `agent-locator.missingBodyDefinition`(模块级函数,`graph-engine.js`
  17 + * 每次调用时都走 `locator.missingBodyDefinition(id)` 这个活引用,不是加载时快照,
  18 + * patch 完之后立刻生效,不需要碰真实的 system/agents/ 目录),测试结束还原。
  19 + */
  20 +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
  21 +import fs from 'fs';
  22 +import os from 'os';
  23 +import path from 'path';
  24 +
  25 +describe('graph-engine 本体完整性闸门(35 号文档提案 A)', () => {
  26 + let wdb;
  27 + let engine;
  28 + let orgSvc;
  29 + let locator;
  30 + let runsDir;
  31 + const TEST_DB_PATH = '/tmp/test-graph-engine-body-not-ready.sqlite';
  32 + const origMissingBodyDefinition = () => {};
  33 +
  34 + beforeAll(() => {
  35 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  36 + try { fs.unlinkSync(f); } catch {}
  37 + }
  38 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  39 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-body-not-ready-runs-'));
  40 + process.env.GRAPH_RUNS_DIR = runsDir;
  41 + for (const m of [
  42 + '../../services/graph-db',
  43 + '../../services/graph-service',
  44 + '../../services/org-graph-service',
  45 + '../../services/work-graph-db',
  46 + '../../services/graph-run-sse',
  47 + '../../services/graph-engine',
  48 + '../../services/agent-locator',
  49 + ]) {
  50 + delete require.cache[require.resolve(m)];
  51 + }
  52 + wdb = require('../../services/work-graph-db');
  53 + engine = require('../../services/graph-engine');
  54 + orgSvc = require('../../services/org-graph-service');
  55 + locator = require('../../services/agent-locator');
  56 + });
  57 +
  58 + afterAll(() => {
  59 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  60 + try { fs.unlinkSync(f); } catch {}
  61 + }
  62 + delete process.env.GRAPH_DB_PATH;
  63 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  64 + delete process.env.GRAPH_RUNS_DIR;
  65 + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
  66 + delete require.cache[require.resolve(m)];
  67 + }
  68 + });
  69 +
  70 + afterEach(() => {
  71 + // 每条用例自己决定 patch 成什么,用完必须还原——同一个 locator 模块实例
  72 + // 会被下一条用例复用(require.cache 只在 beforeAll 清过一次)
  73 + locator.missingBodyDefinition = origMissingBodyDefinition;
  74 + engine.setAgentExecutor(null);
  75 + });
  76 +
  77 + function ensureAgent(id) {
  78 + const db = wdb.getDb();
  79 + const ts = new Date().toISOString();
  80 + db.prepare(`
  81 + INSERT OR IGNORE INTO system_agents
  82 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  83 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  84 + `).run(id, id, `system/agents/${id}`, ts, ts);
  85 + }
  86 +
  87 + function buildRun({ companyId, taskId, systemAgentId }) {
  88 + ensureAgent(systemAgentId);
  89 + const g = wdb.createWorkGraph({
  90 + companyId, taskId,
  91 + nodes: [{ nodeName: 'solo', systemAgentId }],
  92 + edges: [],
  93 + });
  94 + expect(g.errors || []).toEqual([]);
  95 + const run = wdb.createRun({
  96 + graphKind: 'work', graphId: g.id, companyId, taskId,
  97 + budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } },
  98 + });
  99 + engine.markRunReady(run.id);
  100 + engine.startRun(run.id, { mode: 'manual' });
  101 + return run.id;
  102 + }
  103 +
  104 + // ①②:这道闸门只在**真会起 CLI 的执行器**下生效(与 `boundaryGuardApplicable`
  105 + // 同一个判据,见 graph-engine.js 里那条注释)——所以这两条用例**不注入假 executor**,
  106 + // 让 `agentExecutor` 保持模块默认值(`=== defaultAgentExecutor`),从而真正触发闸门;
  107 + // 又因为闸门在派发之前就短路返回,`defaultAgentExecutor` 实际上从未被调用到,
  108 + // 不会真的去起 Claude CLI 子进程。
  109 +
  110 + it('① 本体不完整:节点直接判 fail(body_not_ready),短路在真正派发之前', async () => {
  111 + const companyId = 'co-body-not-ready';
  112 + const taskId = 'task-body-not-ready';
  113 + const runId = buildRun({ companyId, taskId, systemAgentId: 'zz-blank-body' });
  114 +
  115 + locator.missingBodyDefinition = (id) => (id === 'zz-blank-body' ? ['agent.md', 'soul.md'] : []);
  116 +
  117 + const out = await engine.advanceOne(runId);
  118 + expect(out.ok).toBe(true);
  119 +
  120 + const state = wdb.getRunState(runId);
  121 + expect(state.nodes[0].status).toBe('blocked');
  122 + expect(state.nodes[0].failReason).toBe('body_not_ready');
  123 + });
  124 +
  125 + it('② 本体不完整时不扣预算——没有调用发生,「一次调用一定发生了」这条记账理由不成立', async () => {
  126 + const companyId = 'co-body-not-ready-budget';
  127 + const taskId = 'task-body-not-ready-budget';
  128 + const runId = buildRun({ companyId, taskId, systemAgentId: 'zz-blank-body-2' });
  129 +
  130 + locator.missingBodyDefinition = () => ['agent.md'];
  131 +
  132 + await engine.advanceOne(runId);
  133 +
  134 + const run = wdb.getRun(runId);
  135 + const spent = JSON.parse(run.budget_json).spent;
  136 + expect(spent.agent_calls).toBe(0);
  137 + });
  138 +
  139 + it('③ 注入假 executor 时这道闸门不生效——保护全仓库其余用「假 systemAgentId + 假 executor」的用例', async () => {
  140 + const companyId = 'co-body-ready';
  141 + const taskId = 'task-body-ready';
  142 + const runId = buildRun({ companyId, taskId, systemAgentId: 'zz-fixture-only-agent' });
  143 +
  144 + // 就算把它判成"缺失",只要走的是注入的假 executor,闸门也不该拦——这正是本改动
  145 + // 第一版把全仓库测试炸穿的那个坑(真实文件系统里几乎没有一个测试夹具用的
  146 + // systemAgentId 真的对应 system/agents/ 下的目录),修完之后要把它钉住不再复现。
  147 + locator.missingBodyDefinition = () => ['agent.md'];
  148 +
  149 + let executorCalled = false;
  150 + engine.setAgentExecutor(async () => {
  151 + executorCalled = true;
  152 + return { status: 'success' };
  153 + });
  154 +
  155 + await engine.advanceOne(runId);
  156 + expect(executorCalled).toBe(true);
  157 +
  158 + const run = wdb.getRun(runId);
  159 + expect(JSON.parse(run.budget_json).spent.agent_calls).toBe(1);
  160 +
  161 + const state = wdb.getRunState(runId);
  162 + expect(state.nodes[0].status).toBe('success');
  163 + });
  164 +
  165 + it('④ subgraph 节点不受这道闸门约束——它派发的是 workgraph-planning 规划子图,不是本体自己', async () => {
  166 + const companyId = 'tmp-body-subgraph';
  167 + const taskId = 'task-body-subgraph';
  168 + ensureAgent('zz-org-node-agent');
  169 + expect(orgSvc.writeGraph(companyId, {
  170 + nodes: [{ roleName: 'org-solo', systemAgentId: 'zz-org-node-agent' }],
  171 + edges: [],
  172 + }).ok).toBe(true);
  173 +
  174 + const run = wdb.createRun({
  175 + graphKind: 'org', graphId: companyId, companyId, taskId,
  176 + budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } },
  177 + });
  178 + engine.markRunReady(run.id);
  179 + engine.startRun(run.id, { mode: 'manual' });
  180 +
  181 + // 就算这个"本体"是空骨架,subgraph 节点也不该被这道闸门拦——它不直接调用这个本体,
  182 + // 它派发的是 workgraph-planning 去规划子图
  183 + locator.missingBodyDefinition = () => ['agent.md'];
  184 +
  185 + let planCalled = false;
  186 + engine.setPlanWorkGraph(async () => {
  187 + planCalled = true;
  188 + return { draft: { nodes: [{ nodeName: 'x', systemAgentId: 'zz-org-node-agent' }], edges: [] }, source: 'test' };
  189 + });
  190 +
  191 + try {
  192 + await engine.advanceOne(run.id);
  193 + expect(planCalled).toBe(true);
  194 + } finally {
  195 + engine.setPlanWorkGraph(null);
  196 + }
  197 + });
  198 +});
server/tests/unit/graph-engine-fail-reason.test.js
@@ -123,12 +123,14 @@ describe(&#39;引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9 @@ -123,12 +123,14 @@ describe(&#39;引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9
123 // W12 补完 · 三类引擎自判失败都在 fail_reason 上留名 123 // W12 补完 · 三类引擎自判失败都在 fail_reason 上留名
124 // ──────────────────────────────────────────────────────────────── 124 // ────────────────────────────────────────────────────────────────
125 125
126 - it('取值域:五个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => { 126 + it('取值域:六个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => {
127 const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS); 127 const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS);
128 expect(engineReasons).toEqual([ 128 expect(engineReasons).toEqual([
129 'node_error', 'boundary_escape', 'child_run_failed', 129 'node_error', 'boundary_escape', 'child_run_failed',
130 // T-mt25ecbd:produces/needs 契约闸门新增的两个取值(settleNode 的 success 判定里) 130 // T-mt25ecbd:produces/needs 契约闸门新增的两个取值(settleNode 的 success 判定里)
131 'missing_required_input', 'produces_missing', 131 'missing_required_input', 'produces_missing',
  132 + // 35 号文档提案 A:本体完整性闸门(runClaimedNode 派发前检查)新增的取值
  133 + 'body_not_ready',
132 ]); 134 ]);
133 // infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它 135 // infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它
134 // 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。 136 // 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。