Commit bab675cbc50465b8c82ee25735cd280891119dcf
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>
Showing
3 changed files
with
286 additions
and
46 deletions
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('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9 | @@ -123,12 +123,14 @@ describe('引擎自判失败的 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)原地重跑到预算耗尽。 |
-
mentioned in commit 1b6d4355692d4a045c87f8ef02b711acd79363a9
-
mentioned in commit 298425a4e2102964051d8c72e027d387372df97f