diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 8db913c..7963c93 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -201,6 +201,11 @@ const ENGINE_FAIL_REASONS = Object.freeze({ // 不发明取值域之外的字符串(否则会被 §四.D.2.2 的合法性检查当自创取值降级成 null)。 MISSING_INPUT: 'missing_required_input', PRODUCES_MISSING: 'produces_missing', + // 35 号文档提案 A:本体完整性闸门只在招聘路径上查过(company-service.js 的 + // 'body_not_ready' 状态),图执行侧从未查过——本体因任何原因仍是空骨架时, + // 会静默拿一份没有角色指导的提示词去跑真实 CLI(真实样本见 34 号文档 §五)。 + // 字符串与招聘路径的 'body_not_ready' 同名同义,不是两套判词。 + BODY_NOT_READY: 'body_not_ready', }); /** 归因/结构化产出文件名(agent.md「产出格式」:runs/{rootRunId}/nodes/{nodeRunId}/attempt-{n}.json) */ @@ -1466,56 +1471,91 @@ async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts = // 自觉拼对路径,而真跑已经证明它拼不出来。引擎直接把路径列出来,不给它猜的机会。 ctx.task += describeInputs(ctx, def); - // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。 - // subgraph 节点这一次花的是 workgraph-planning 的调用,同样按一次计。 - spendBudget(run, { agentCalls: 1 }); - - sse.emitEvent( - runId, - 'agent.call', - `${ctx.nodeKind === 'subgraph' ? '规划子图' : '调用 agent'} ${ctx.systemAgentId || nodeName}(attempt #${picked.attempt + 1})`, - { - node_run_id: picked.id, node_name: nodeName, node_kind: ctx.nodeKind, - system_agent_id: ctx.systemAgentId, attempt: picked.attempt, - remediation_note: ctx.remediationNote, - }, - { ref: picked.id }, - ); - - // W3:调用前后各拍一张宿主仓库快照。能拦什么拦不了什么见 applyBoundaryGuard 的注释。 - const guardSnapshot = beginBoundaryGuard(ctx); + // 35 号文档提案 A:本体完整性闸门。此前 `missingBodyDefinition` 只在招聘/建公司 + // 路径上被查过(graph-service.js / graph-seed-service.js / company-service.js), + // 图执行的核心路径(这里)从未查过——本体因任何原因(装配没跑完、手工改坏、 + // 迁移遗留……)仍是空骨架时,引擎会原样把它当成正常本体,派发一次真实 CLI 调用, + // 静默产出一份「像是完成了、其实没有任何角色指导」的结果(真实样本见 34 号文档 §五: + // `unit-tester` 全程顶着 `_base` 骨架跑完了一整条真实任务)。 + // + // 只查 `agent_call` 节点:subgraph 节点派发的是 workgraph-planning 去规划子图, + // 它的 `systemAgentId` 不是「即将被真实调用的本体」,不是这里要拦的场景。 + // + // 且只在**真会起 CLI 的执行器**下查(与 `boundaryGuardApplicable` 同一个判据): + // 测试注入的假 executor 是进程内函数,压根不会读任何 agent.md,用的 `systemAgentId` + // 也大多是 `w-prd`/`impl` 这类不对应仓库里任何真实目录的夹具名——扫真实文件系统 + // 对这些用例既没意义,又会把全仓库几百个注入假 executor 的用例一次性判成 + // body_not_ready(本改动第一版就是这么把测试套件炸穿的,改用这条判据后收窄回来)。 + const missingBody = ctx.nodeKind !== 'subgraph' && ctx.systemAgentId && agentExecutor === defaultAgentExecutor + ? locator.missingBodyDefinition(ctx.systemAgentId) + : []; let outcome; - try { - outcome = ctx.nodeKind === 'subgraph' ? await executeSubgraph(ctx) : await agentExecutor(ctx); - } catch (err) { - // executor 自己炸了(规划失败 / 配置缺失等)算节点失败。 - // W12 补完:fail_reason 落 `node_error`(不再是 null)。选边行为与 null 完全一致 - // ——没有任何图骨架写这条边,`selectFailEdge` 精确匹配不到就退兜底边—— - // 但概览 tab 从「失败、原因 —」变成「失败、node_error」,操作者第一眼就知道 - // 这是**引擎/配置**层面炸的,不是 agent 判的业务失败。取值论证见 ENGINE_FAIL_REASONS。 - // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。 + if (missingBody.length) { + // 没有真的发起调用:不扣预算(「一次调用一定发生了」这条记账理由在这里不成立 + // ——这次压根没有调用发生),不起边界守卫(没有子进程会去动宿主仓库)。 outcome = { - status: 'fail', output: err.message, - failReason: ENGINE_FAIL_REASONS.NODE_ERROR, tokens: err.tokens || 0, + status: 'fail', + failReason: ENGINE_FAIL_REASONS.BODY_NOT_READY, + output: `本体 ${ctx.systemAgentId} 仍缺角色定义(${missingBody.join(', ')}),本节点未派发真实调用`, }; - // W12:这条路径以前是**净损失** —— err.message 写进 outcome.output 之后没有任何一环消费它: - // 不落库(graph_run_nodes 没有 output 列,且不该有,见 EVENT_TEXT_MAX 的注释)、 - // 不进 SSE、也不写 timeline,fail_reason 还是 null。操作者看到的是「节点 fail、无任何原因」。 - // 真跑第一个 work 节点时 AgentManager 抛的「Agent not found」正是从这里出去的。 - // 现在原因先进审计轨(timeline.ndjson,永不改写)+ 实时轨(stream.log / SSE), - // 排障至少有的可查;错误类型与调用目标一并记,好判断是「本体没雇进公司」还是别的。 - timelineNote(ctx, 'node.error', `节点执行器抛异常(${(err && err.name) || 'Error'}):${err && err.message}`, { - node_run_id: ctx.nodeRunId, node_name: nodeName, attempt: ctx.attempt, - node_kind: ctx.nodeKind, system_agent_id: ctx.systemAgentId || null, - error: clipText(err && err.message ? err.message : String(err)), - error_type: (err && err.name) || 'Error', - // 栈顶几行足够定位抛点(整份栈进事件没意义,也会把 stream.log 撑大) - error_stack: clipText(err && err.stack ? String(err.stack).split('\n').slice(0, 6).join('\n') : null), - }); + timelineNote(ctx, 'node.boundary', + `本体 ${ctx.systemAgentId} 尚未装配完成(缺 ${missingBody.join(', ')}),节点直接判 fail,不派发真实调用`, + { + node_run_id: ctx.nodeRunId, node_name: nodeName, system_agent_id: ctx.systemAgentId, + missing: missingBody, degraded: 'body_not_ready', + }); + } else { + // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。 + // subgraph 节点这一次花的是 workgraph-planning 的调用,同样按一次计。 + spendBudget(run, { agentCalls: 1 }); + + sse.emitEvent( + runId, + 'agent.call', + `${ctx.nodeKind === 'subgraph' ? '规划子图' : '调用 agent'} ${ctx.systemAgentId || nodeName}(attempt #${picked.attempt + 1})`, + { + node_run_id: picked.id, node_name: nodeName, node_kind: ctx.nodeKind, + system_agent_id: ctx.systemAgentId, attempt: picked.attempt, + remediation_note: ctx.remediationNote, + }, + { ref: picked.id }, + ); + + // W3:调用前后各拍一张宿主仓库快照。能拦什么拦不了什么见 applyBoundaryGuard 的注释。 + const guardSnapshot = beginBoundaryGuard(ctx); + + try { + outcome = ctx.nodeKind === 'subgraph' ? await executeSubgraph(ctx) : await agentExecutor(ctx); + } catch (err) { + // executor 自己炸了(规划失败 / 配置缺失等)算节点失败。 + // W12 补完:fail_reason 落 `node_error`(不再是 null)。选边行为与 null 完全一致 + // ——没有任何图骨架写这条边,`selectFailEdge` 精确匹配不到就退兜底边—— + // 但概览 tab 从「失败、原因 —」变成「失败、node_error」,操作者第一眼就知道 + // 这是**引擎/配置**层面炸的,不是 agent 判的业务失败。取值论证见 ENGINE_FAIL_REASONS。 + // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。 + outcome = { + status: 'fail', output: err.message, + failReason: ENGINE_FAIL_REASONS.NODE_ERROR, tokens: err.tokens || 0, + }; + // W12:这条路径以前是**净损失** —— err.message 写进 outcome.output 之后没有任何一环消费它: + // 不落库(graph_run_nodes 没有 output 列,且不该有,见 EVENT_TEXT_MAX 的注释)、 + // 不进 SSE、也不写 timeline,fail_reason 还是 null。操作者看到的是「节点 fail、无任何原因」。 + // 真跑第一个 work 节点时 AgentManager 抛的「Agent not found」正是从这里出去的。 + // 现在原因先进审计轨(timeline.ndjson,永不改写)+ 实时轨(stream.log / SSE), + // 排障至少有的可查;错误类型与调用目标一并记,好判断是「本体没雇进公司」还是别的。 + timelineNote(ctx, 'node.error', `节点执行器抛异常(${(err && err.name) || 'Error'}):${err && err.message}`, { + node_run_id: ctx.nodeRunId, node_name: nodeName, attempt: ctx.attempt, + node_kind: ctx.nodeKind, system_agent_id: ctx.systemAgentId || null, + error: clipText(err && err.message ? err.message : String(err)), + error_type: (err && err.name) || 'Error', + // 栈顶几行足够定位抛点(整份栈进事件没意义,也会把 stream.log 撑大) + error_stack: clipText(err && err.stack ? String(err.stack).split('\n').slice(0, 6).join('\n') : null), + }); + } + // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。 + outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome); } - // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。 - outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome); // executor 报了 token 用量就一并扣。产生端见 defaultAgentExecutor 的 onAcpEvent // (claude-cli 协议的 session/result.usage);不上报 usage 的协议这一维恒为 0, diff --git a/server/tests/unit/graph-engine-body-not-ready.test.js b/server/tests/unit/graph-engine-body-not-ready.test.js new file mode 100644 index 0000000..bbed1ff --- /dev/null +++ b/server/tests/unit/graph-engine-body-not-ready.test.js @@ -0,0 +1,198 @@ +/** + * Unit Tests — 35 号文档提案 A:本体完整性闸门接进图执行前置检查 + * + * `agentLocator.missingBodyDefinition()` 此前只在招聘/建公司路径上被查过 + * (graph-service.js / graph-seed-service.js / company-service.js),图执行的 + * 核心路径(`runClaimedNode`)从未查过——本体因任何原因仍是空骨架时,引擎会 + * 原样把它当正常本体,派发一次真实调用(真实样本见 34 号文档 §五:`unit-tester` + * 全程顶着 `_base` 骨架跑完了一整条真实任务,产出一份没有任何角色指导支撑的结果)。 + * + * 本文件锁的是修完之后的行为: + * ① 本体不完整时,节点直接判 fail(`body_not_ready`),**不派发真实调用** + * (注入的 executor 断言从未被调用) + * ② 不扣预算(没有调用发生,「一次调用一定发生了」这条记账理由不成立) + * ③ 本体完整时行为不变(不动 §三十一/§三十三 号文档已经验过的正常路径) + * + * 隔离:monkeypatch `agent-locator.missingBodyDefinition`(模块级函数,`graph-engine.js` + * 每次调用时都走 `locator.missingBodyDefinition(id)` 这个活引用,不是加载时快照, + * patch 完之后立刻生效,不需要碰真实的 system/agents/ 目录),测试结束还原。 + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('graph-engine 本体完整性闸门(35 号文档提案 A)', () => { + let wdb; + let engine; + let orgSvc; + let locator; + let runsDir; + const TEST_DB_PATH = '/tmp/test-graph-engine-body-not-ready.sqlite'; + const origMissingBodyDefinition = () => {}; + + 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-body-not-ready-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', + ]) { + delete require.cache[require.resolve(m)]; + } + wdb = require('../../services/work-graph-db'); + engine = require('../../services/graph-engine'); + orgSvc = require('../../services/org-graph-service'); + locator = require('../../services/agent-locator'); + }); + + 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(() => { + // 每条用例自己决定 patch 成什么,用完必须还原——同一个 locator 模块实例 + // 会被下一条用例复用(require.cache 只在 beforeAll 清过一次) + locator.missingBodyDefinition = origMissingBodyDefinition; + 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 }) { + ensureAgent(systemAgentId); + const g = wdb.createWorkGraph({ + companyId, taskId, + nodes: [{ nodeName: 'solo', 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; + } + + // ①②:这道闸门只在**真会起 CLI 的执行器**下生效(与 `boundaryGuardApplicable` + // 同一个判据,见 graph-engine.js 里那条注释)——所以这两条用例**不注入假 executor**, + // 让 `agentExecutor` 保持模块默认值(`=== defaultAgentExecutor`),从而真正触发闸门; + // 又因为闸门在派发之前就短路返回,`defaultAgentExecutor` 实际上从未被调用到, + // 不会真的去起 Claude CLI 子进程。 + + it('① 本体不完整:节点直接判 fail(body_not_ready),短路在真正派发之前', async () => { + const companyId = 'co-body-not-ready'; + const taskId = 'task-body-not-ready'; + const runId = buildRun({ companyId, taskId, systemAgentId: 'zz-blank-body' }); + + locator.missingBodyDefinition = (id) => (id === 'zz-blank-body' ? ['agent.md', 'soul.md'] : []); + + const out = await engine.advanceOne(runId); + expect(out.ok).toBe(true); + + const state = wdb.getRunState(runId); + expect(state.nodes[0].status).toBe('blocked'); + expect(state.nodes[0].failReason).toBe('body_not_ready'); + }); + + it('② 本体不完整时不扣预算——没有调用发生,「一次调用一定发生了」这条记账理由不成立', async () => { + const companyId = 'co-body-not-ready-budget'; + const taskId = 'task-body-not-ready-budget'; + const runId = buildRun({ companyId, taskId, systemAgentId: 'zz-blank-body-2' }); + + locator.missingBodyDefinition = () => ['agent.md']; + + await engine.advanceOne(runId); + + const run = wdb.getRun(runId); + const spent = JSON.parse(run.budget_json).spent; + expect(spent.agent_calls).toBe(0); + }); + + it('③ 注入假 executor 时这道闸门不生效——保护全仓库其余用「假 systemAgentId + 假 executor」的用例', async () => { + const companyId = 'co-body-ready'; + const taskId = 'task-body-ready'; + const runId = buildRun({ companyId, taskId, systemAgentId: 'zz-fixture-only-agent' }); + + // 就算把它判成"缺失",只要走的是注入的假 executor,闸门也不该拦——这正是本改动 + // 第一版把全仓库测试炸穿的那个坑(真实文件系统里几乎没有一个测试夹具用的 + // systemAgentId 真的对应 system/agents/ 下的目录),修完之后要把它钉住不再复现。 + locator.missingBodyDefinition = () => ['agent.md']; + + let executorCalled = false; + engine.setAgentExecutor(async () => { + executorCalled = true; + return { status: 'success' }; + }); + + await engine.advanceOne(runId); + expect(executorCalled).toBe(true); + + const run = wdb.getRun(runId); + expect(JSON.parse(run.budget_json).spent.agent_calls).toBe(1); + + const state = wdb.getRunState(runId); + expect(state.nodes[0].status).toBe('success'); + }); + + it('④ subgraph 节点不受这道闸门约束——它派发的是 workgraph-planning 规划子图,不是本体自己', async () => { + const companyId = 'tmp-body-subgraph'; + const taskId = 'task-body-subgraph'; + ensureAgent('zz-org-node-agent'); + expect(orgSvc.writeGraph(companyId, { + nodes: [{ roleName: 'org-solo', systemAgentId: 'zz-org-node-agent' }], + edges: [], + }).ok).toBe(true); + + const run = wdb.createRun({ + graphKind: 'org', graphId: companyId, 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' }); + + // 就算这个"本体"是空骨架,subgraph 节点也不该被这道闸门拦——它不直接调用这个本体, + // 它派发的是 workgraph-planning 去规划子图 + locator.missingBodyDefinition = () => ['agent.md']; + + let planCalled = false; + engine.setPlanWorkGraph(async () => { + planCalled = true; + return { draft: { nodes: [{ nodeName: 'x', systemAgentId: 'zz-org-node-agent' }], edges: [] }, source: 'test' }; + }); + + try { + await engine.advanceOne(run.id); + expect(planCalled).toBe(true); + } finally { + engine.setPlanWorkGraph(null); + } + }); +}); diff --git a/server/tests/unit/graph-engine-fail-reason.test.js b/server/tests/unit/graph-engine-fail-reason.test.js index 85cc5de..6afbe21 100644 --- a/server/tests/unit/graph-engine-fail-reason.test.js +++ b/server/tests/unit/graph-engine-fail-reason.test.js @@ -123,12 +123,14 @@ describe('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9 // W12 补完 · 三类引擎自判失败都在 fail_reason 上留名 // ──────────────────────────────────────────────────────────────── - it('取值域:五个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => { + it('取值域:六个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => { const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS); expect(engineReasons).toEqual([ 'node_error', 'boundary_escape', 'child_run_failed', // T-mt25ecbd:produces/needs 契约闸门新增的两个取值(settleNode 的 success 判定里) 'missing_required_input', 'produces_missing', + // 35 号文档提案 A:本体完整性闸门(runClaimedNode 派发前检查)新增的取值 + 'body_not_ready', ]); // infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它 // 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。