graph-real-chain-smoke.test.js 21.4 KB
/**
 * 图执行的**真实链路 smoke** —— 全程零注入,唯一被替换的是最外层的 Claude CLI 子进程。
 *
 * ── 这条测试还的是哪笔债 ──────────────────────────────────────────────
 * [27-P3/P4](../../../docs/todo/system-design/27-p3-p4-work-graph-console.md) §十 末
 * 「四条硬规矩」第 2 条:
 *
 * > **测试全绿 ≠ 能跑**。P3 的引擎用例至今仍一律注入假 executor / 假 planner,
 * > 第 7、8、9、10 号缺陷和当时的「planWorkGraph 未接线」全部在覆盖范围之外——
 * > **夹具正好绕开了缺失的那一环**。……**凡是靠注入点验的能力,都要有一条真实路径的
 * > smoke 进 CI 才算验收完成**——这条欠账仍未还。
 *
 * 在这之前,唯一走真实路径的是 `scripts/real-pm-smoke.js`(人工跑、要真 API 额度、
 * 而且验的是 DevLoop 不是图执行),**没进 CI,随时会腐坏**。
 *
 * ── 「零注入」的边界画在哪 ────────────────────────────────────────────
 * 不调 `setAgentExecutor` / `setPlanWorkGraph`。链路完整走:
 *   advanceOne → defaultAgentExecutor → AgentManager → ClaudeCliProtocol
 *              → onAcpEvent(mock/files, session/result) → 落盘 → readNodeProduct
 *              → settleNode → spendBudget → 边界守卫
 * 只有最后那层真子进程被 `LEMONBOSS_CLAUDE_MODE=mock` 换成 fixture 回放 ——
 * 真 LLM 进不了 CI(要额度、要分钟级时间、输出不确定),但**「真 LLM」和「真代码路径」
 * 是两件事**,这里换掉前者、保住后者。
 *
 * ── 三条断言各盯一处「假 executor 天然绕开」的接线 ────────────────────
 *   ① 产物落盘:`mock/files` → `defaultAgentExecutor` 的 onAcpEvent → 节点产物目录。
 *      假 executor 直接返回 `{status:'success'}`,这一环整个不存在。
 *   ② token 计数:`session/result.usage` → onAcpEvent 累加 → `spendBudget`。
 *      这是 §六 验收 10 卡了两轮的那一维(「三个维度写在一起,只有它没有产出方」)。
 *   ③ 边界守卫真的开了:`boundaryGuardApplicable` 只在 `agentExecutor === defaultAgentExecutor`
 *      时返回 true —— 注入假 executor 的用例里它**恒为 false**,那条分支实际零覆盖。
 *
 * ── 两支都要走 ────────────────────────────────────────────────────────
 * `agent_call` 与 `subgraph` 是两条**不同的**默认实现(`defaultAgentExecutor` /
 * `defaultPlanWorkGraph`),各有各的 onAcpEvent 累加、各有各的守卫开关分支。
 * 只覆盖前者的话,「规划子图」那半条链仍然是零真实覆盖 —— 所以下面 ④⑤ 专走 subgraph。
 *
 * ── 隔离 ──────────────────────────────────────────────────────────────
 * GRAPH_DB_PATH / GRAPH_RUNS_DIR 都指临时目录,且必须在 require 之前设
 * (两个模块都在顶部快照)。`LEMONBOSS_CLAUDE_MODE` 同理:ClaudeCliProtocol 的
 * MOCK_MODE 是模块级 const。
 */
process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-real-chain-'));
process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite');
process.env.GRAPH_RUNS_DIR = path.join(TMP, 'runs');

// fixture 名 = 本体 id(图执行侧的 agent 名带公司前缀,mock 会先剥掉)。
// 用 prd-planning:本体真实存在,fixture 自带 files[](含 readNodeProduct 要读的
// attempt-{attempt}.json)与 usage。
const AGENT = 'prd-planning';
const PLANNER = 'workgraph-planning'; // subgraph 那一支的规划本体(fixture 同名)
const WRAPPER = 'zz-smoke-wrapper';   // 承载子图的那个节点,只是个名字,不会被调用
const COMPANY = 'zz-real-chain-co';

let wdb; let engine;

describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程)', () => {
  let runId; let nodeRunId; let rootRunId;

  beforeAll(async () => {
    for (const m of ['../../services/graph-db', '../../services/work-graph-db',
      '../../services/graph-run-sse', '../../services/graph-engine']) {
      delete require.cache[require.resolve(m)];
    }
    wdb = require('../../services/work-graph-db');
    engine = require('../../services/graph-engine');

    const db = wdb.getDb();
    const now = 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(AGENT, AGENT, `system/agents/${AGENT}`, now, now);

    const g = wdb.createWorkGraph({ companyId: COMPANY, nodes: [{ nodeName: AGENT, systemAgentId: AGENT }], edges: [] });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({
      graphKind: 'work', graphId: g.id, companyId: COMPANY, executionMode: 'manual',
      // 额度给足:这条 smoke 要验的是「有没有数」,不是「够不够花」
      budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } },
    });
    engine.markRunReady(run.id);
    runId = run.id;
    // createRun 的返回值不一定带 root_run_id,从库里读回来(这条 run 自己就是 root)
    rootRunId = wdb.getRun(run.id).root_run_id;
    expect(rootRunId).toBe(run.id);

    // ready → running(「下一步」只对 running/paused 可用)
    engine.startRun(runId, { mode: 'manual' });

    const out = await engine.nextStep(runId, { task: '真实链路 smoke:产出一份 PRD' });
    if (!out.ok) throw new Error(`nextStep 没推动:${JSON.stringify(out)}`);
    nodeRunId = out.node.id;
    // 节点必须真的跑完 —— 停在 running / fail 的话下面三条断言验的是残局
    expect(out.node.status).toBe('success');
  }, 60_000);

  afterAll(() => {
    // 真实链路会在本体下建公司态目录(AgentManager 的 cwd 是 system/agents/{aid}/{cid}/{taskId})。
    // 它 gitignore 挡着、不进版本库,但留着会让别的用例遍历 system/agents/ 时多扫一份,
    // 也让 `ls system/agents/prd-planning/` 出现一个没人认领的公司。
    for (const a of [AGENT, PLANNER]) {
      fs.rmSync(path.join(__dirname, '../../../system/agents', a, COMPANY), { recursive: true, force: true });
    }
    delete process.env.LEMONBOSS_CLAUDE_MODE;
    delete process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_RUNS_DIR;
    fs.rmSync(TMP, { recursive: true, force: true });
  });

  it('① mock/files 真的落到了节点产物目录(假 executor 下这一环整个不存在)', () => {
    const dir = path.join(process.env.GRAPH_RUNS_DIR, rootRunId, 'nodes', nodeRunId);
    expect(fs.existsSync(path.join(dir, 'attempt-0.json'))).toBe(true);
    expect(fs.readFileSync(path.join(dir, 'prd.md'), 'utf8')).toContain('PRD');
    // attempt-0.json 是引擎 readNodeProduct 要读的那份 —— 它读不到就判不出 verdict,
    // 所以这条同时也证明了 `{attempt}` 占位符替换那一步没坏
    const product = JSON.parse(fs.readFileSync(path.join(dir, 'attempt-0.json'), 'utf8'));
    expect(product.verdict).toBe('no_prior_failure');
  });

  it('② token 用量真的从 session/result.usage 数进了预算(§六 验收 10 那一维)', () => {
    const spent = JSON.parse(wdb.getRun(rootRunId).budget_json).spent;
    expect(spent.agent_calls).toBe(1);
    // fixture 报 1200 + 340;断言等值而不是 >0 —— 「大于 0」在累加写成 `=` 而非 `+=` 时也成立
    expect(spent.tokens).toBe(1540);
  });

  // ── subgraph 那一支:defaultPlanWorkGraph ─────────────────────────────
  //
  // 覆盖边界说清楚:这里**跳过 `resolvePlanningAgentName` 里的按需 hire**(预先塞一行
  // company_agents 让它认为雇过了)。理由是 hire 要一条真实的公司记录(`this.get(cid)`),
  // 而那条路径另有 `company-hire-org-layer.test.js` 专门盯着 —— 这条 smoke 要验的是
  // 「规划这一段链路通不通」,不是「招聘通不通」。
  function seedPlannerHired() {
    const db = wdb.getDb();
    const now = 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(PLANNER, PLANNER, `system/agents/${PLANNER}`, now, now);
    // subgraph 节点自己也要有 system_agents 行(work_graph_nodes.system_agent_id 是外键)。
    // 它只是「哪个 org 角色拥有这张子图」的名字,不会被真的调用 —— 调的是 PLANNER。
    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(WRAPPER, WRAPPER, `system/agents/${WRAPPER}`, now, now);
    db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
        VALUES (?, ?, ?, 'active', ?)`).run(COMPANY, PLANNER, PLANNER, now);
  }

  it('④ subgraph 节点真的把 workgraph-planning 的图 JSON 变成了一条子 run', async () => {
    seedPlannerHired();
    const g = wdb.createWorkGraph({
      companyId: COMPANY,
      nodes: [{ nodeName: WRAPPER, systemAgentId: WRAPPER, nodeKind: 'subgraph' }],
      edges: [],
    });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({
      graphKind: 'work', graphId: g.id, companyId: COMPANY, executionMode: 'manual',
      budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } },
    });
    engine.markRunReady(run.id);
    engine.startRun(run.id);

    const out = await engine.nextStep(run.id, { task: '真实链路 smoke:规划一张子图' });
    if (!out.ok) throw new Error(`subgraph 没推动:${JSON.stringify(out)}`);
    // 父节点停在 running 等子 run,这是 subgraph 的正常形态(不是没跑动)
    expect(out.reason).toBe('awaiting-child-run');
    expect(out.node.childRunId).toBeTruthy();

    // 子 run 的定义必须来自 fixture 那张图 —— 注入假 planner 时这一整段(回复解析 →
    // createWorkGraphSafely → 建子 run)都不存在
    const child = wdb.getRun(out.node.childRunId);
    expect(child.graph_kind).toBe('work');
    const childNodes = wdb.getDb()
      .prepare(`SELECT def_node_id FROM graph_run_nodes WHERE run_id = ?`).all(child.id);
    expect(childNodes).toHaveLength(1);
    const def = wdb.getDb()
      .prepare(`SELECT node_name, system_agent_id FROM work_graph_nodes WHERE id = ?`)
      .get(childNodes[0].def_node_id);
    expect(def.node_name).toBe(AGENT);
    expect(def.system_agent_id).toBe(AGENT);

    // ⑤ 规划那一轮烧的 token 也进了预算。**它走的是另一段 onAcpEvent**
    //    (defaultPlanWorkGraph 自己那条),与 ② 验的不是同一段代码。
    const spent = JSON.parse(wdb.getRun(run.root_run_id || run.id).budget_json).spent;
    expect(spent.tokens).toBe(1060); // fixture 报 800 + 260
  }, 60_000);

  it('⑥ validator 判失败时引擎真的按失败处理(27-P3B §六 验收 4,此前只是「静态成立」)', async () => {
    // 原验收标准:「造一份非法契约跑 benchmark-validator:判决为失败且引擎按失败处理
    // (**不是** success)」。P3B 当时的结论是 🟡「静态成立,未真跑」—— 人读代码把
    // fixture verdict → FAILING_VERDICTS → readNodeProduct → callAgentNode 判 fail
    // 这条链读通了,但没有任何一次真跑或真链路证据。
    //
    // 「读通了」和「跑通了」之间隔着的正是这条 smoke 要补的东西:产物要真的落盘、
    // 要真的被 readNodeProduct 读到、verdict 要真的被认出来。任何一环断了,
    // 节点都会以 success 收场 —— 而**判错成 success 的代价是不合格产物直接放行,
    // 且不留痕迹**(P3B §八 那段「两种错代价不对称」说的就是这个方向)。
    const VALIDATOR = 'benchmark-validator';
    // 另开一家公司:一家公司同时只允许一条活跃 root run(idx_graph_runs_one_active_root),
    // 上面几条留下的 run 还在活跃集里,共用公司会撞 RunSlotBusyError —— 那是约束在正常工作,
    // 不是这条用例要验的东西。
    const CO = `${COMPANY}-v4`;
    const db = wdb.getDb();
    const now = 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(VALIDATOR, VALIDATOR, `system/agents/${VALIDATOR}`, now, now);

    const g = wdb.createWorkGraph({
      companyId: CO,
      // 带一条 fail 兜底边:没有出边时失败节点收在 `blocked`(挂起等人工),
      // 那也是「按失败处理」,但看不出**路由**有没有走通。加上边,判据才完整:
      // 失败 → 节点 fail → 沿 fail 边点亮下游。
      nodes: [
        { nodeName: VALIDATOR, systemAgentId: VALIDATOR },
        { nodeName: AGENT, systemAgentId: AGENT },
      ],
      edges: [{ from: VALIDATOR, to: AGENT, condition: 'fail' }],
    });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CO, executionMode: 'manual' });
    engine.markRunReady(run.id);
    engine.startRun(run.id);

    const out = await engine.nextStep(run.id, { task: '真实链路 smoke:验一份非法契约' });
    if (!out.ok) throw new Error(`validator 没推动:${JSON.stringify(out)}`);
    // 这两行就是验收 4 的全部:**不是 success**,且归因落了库
    expect(out.node.status).toBe('fail');
    // fail_reason 取自封闭域 ATTRIBUTION_REASONS —— fixture 里写个自创的词会被
    // 按设计降级成 null(引擎不认识的归因一律不信),第一版就是这么红的
    expect(out.node.failReason).toBe('input_underspecified');
    // 路由也要走通:失败的下一步是沿 fail 边点亮下游,不是原地不动
    expect((out.activated || []).map((a) => a.name)).toContain(AGENT);

    // 判词也要带下去 —— 没有它,下一轮 agent 不知道要修什么(§八.14 那条四环全通的链路)。
    // 注意判词落在**被点亮的下游节点**那一行,不是失败节点自己那一行:
    // 「判词的载体 = 点亮它的那次状态转换」,这样多条回退边并存时天然不串台。
    const downstream = wdb.getDb()
      .prepare(`SELECT remediation_note FROM graph_run_nodes
                 WHERE run_id = ? AND def_node_id = (SELECT id FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = ?)`)
      .get(run.id, g.id, AGENT);
    expect(downstream.remediation_note).toContain('test_script');

    fs.rmSync(path.join(__dirname, '../../../system/agents', VALIDATOR, CO), { recursive: true, force: true });
  }, 60_000);

  it('⑦ 31-work-graph-fail-fallback-coverage T7:非入口节点自判失败时,'
    + '引擎沿新补的兜底边自动打回上游——不调 retryNode,不需要人工点重试', async () => {
    // 复现 e2e-demo 那次真实卡住的最小形状:prd(入口)--success--> sdd,
    // sdd 自己写出 fail_reason=input_underspecified 却没有精确匹配边时,
    // 退到这条新补的兜底边 sdd --fail(无 failReason)--> prd(backward)。
    // 这正是 planner.json 补的那条边的最小复现,用真实 graph-engine 代码路径验证——
    // 不是「补了边、结构校验能过」,是「补了边之后引擎运行时真的会走它」。
    const SDD_AGENT = 'sdd-planning';
    const CO = `${COMPANY}-t7`;
    const db = wdb.getDb();
    const now = 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(SDD_AGENT, SDD_AGENT, `system/agents/${SDD_AGENT}`, now, now);

    const g = wdb.createWorkGraph({
      companyId: CO,
      nodes: [
        { nodeName: 'prd', systemAgentId: AGENT },
        { nodeName: 'sdd', systemAgentId: SDD_AGENT },
      ],
      edges: [
        { from: 'prd', to: 'sdd', condition: 'success' },
        { from: 'sdd', to: 'prd', condition: 'fail', edgeKind: 'backward' }, // 31 号文档新补的那条兜底边
      ],
    });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CO, executionMode: 'manual' });
    engine.markRunReady(run.id);
    engine.startRun(run.id);

    // 第一步:prd(入口)真的跑成功
    const step1 = await engine.nextStep(run.id, { task: 'T7 smoke:先跑 prd' });
    if (!step1.ok) throw new Error(`prd 没推动:${JSON.stringify(step1)}`);
    expect(step1.node.status).toBe('success');
    expect((step1.activated || []).map((a) => a.name)).toContain('sdd');

    // 第二步:sdd 自判 input_underspecified,图上没有为这个 fail_reason 精确匹配的边,
    // 应该落到兜底边、自动打回 prd —— 全程没有任何一处调用 retryNode。
    const step2 = await engine.nextStep(run.id, { task: 'T7 smoke:跑 sdd,会自判失败' });
    if (!step2.ok) throw new Error(`sdd 没推动:${JSON.stringify(step2)}`);
    // backward 边一旦命中,settleNode 会把「回退窗口内」的节点(含 sdd 自己)连同它一起重置回
    // pending,等下一轮从 prd 重新走一遍——所以 step2.node(sdd 那一行)读到的已经是重置后的
    // 状态,不是它刚判失败那一瞬间的快照。真正证明"失败被路由"的是 backwardEdgeId + activated。
    expect(step2.node.status).toBe('pending'); // sdd 自己被回退窗口一并重置,等下一轮
    expect(step2.backwardEdgeId).toBeTruthy(); // 命中了一条真实的 backward 边(不是死路 blocked)
    // 这一行就是 T7 要证的东西:sdd 判失败之后,prd 被引擎自己点亮,不是停在原地等人工
    expect((step2.activated || []).map((a) => a.name)).toContain('prd');

    // 命中的确实是 31 号文档新补的那条兜底边(sdd → prd, fail, 无 failReason)
    const hitEdge = wdb.getDb()
      .prepare(`SELECT from_node_id, to_node_id, condition, fail_reason FROM work_graph_edges WHERE id = ?`)
      .get(step2.backwardEdgeId);
    const nodeName = (id) => wdb.getDb().prepare(`SELECT node_name FROM work_graph_nodes WHERE id = ?`).get(id).node_name;
    expect(nodeName(hitEdge.from_node_id)).toBe('sdd');
    expect(nodeName(hitEdge.to_node_id)).toBe('prd');
    expect(hitEdge.fail_reason).toBeNull();

    // sdd 真的自判了 input_underspecified(不是被引擎凭空发明的)——从它这次调用的归因文件读回
    const sddNodeRunId = step1.activated.find((a) => a.name === 'sdd').id;
    const sddDir = path.join(process.env.GRAPH_RUNS_DIR, run.root_run_id || run.id, 'nodes', sddNodeRunId);
    const sddAttempt = JSON.parse(fs.readFileSync(path.join(sddDir, 'attempt-0.json'), 'utf8'));
    expect(sddAttempt.fail_reason).toBe('input_underspecified');

    // 判词也真的带到了被点亮的 prd 节点上(remediation_note 的载体是「点亮它的那次状态转换」,
    // 与 ⑥ 同一条规矩),不是丢在 sdd 自己那一行没人看得到
    const prdRow = wdb.getDb()
      .prepare(`SELECT remediation_note, status FROM graph_run_nodes
                 WHERE run_id = ? AND def_node_id = (SELECT id FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = ?)`)
      .get(run.id, g.id, 'prd');
    expect(prdRow.status).toBe('ready');
    expect(prdRow.remediation_note).toContain('contract.json');

    for (const a of [SDD_AGENT, AGENT]) {
      fs.rmSync(path.join(__dirname, '../../../system/agents', a, CO), { recursive: true, force: true });
    }
  }, 60_000);

  it('③ 边界守卫在这条路径上是**开着**的(注入假 executor 时它恒为假,那条分支零覆盖)', () => {
    // 判据取自引擎自己的开关函数,而不是去数 timeline 事件:
    // 守卫在宿主仓库干净时本来就不产生事件,拿「没有事件」当「没开」会反过来判错。
    expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(true);
    expect(engine.boundaryGuardApplicable({ nodeKind: 'subgraph' })).toBe(true);
    // 对照:注入之后必须变假 —— 少了这条,上面两句在函数写成 `return true` 时也全绿。
    // 两支各有各的开关分支,所以两边都要验。
    engine.setAgentExecutor(async () => ({ status: 'success' }));
    engine.setPlanWorkGraph(async () => ({ workGraphId: 'wg_000000000000' }));
    expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(false);
    expect(engine.boundaryGuardApplicable({ nodeKind: 'subgraph' })).toBe(false);
    engine.setAgentExecutor(null);
    engine.setPlanWorkGraph(null);
  });
});