graph-engine-fail-reason.test.js 17.9 KB
/**
 * Unit Tests — 引擎自判失败的 fail_reason + 失败上下文进提示词(27-P3B W12 补完 / W9)
 *
 * ── W12 补完锁的是什么 ──────────────────────────────────────────────
 * W12 第一轮只把失败**原因文本**送进了 timeline 与 `node.output.output_excerpt`
 * (= 控制台的「运行日志」tab),`graph_run_nodes.fail_reason` 仍然恒 null。
 * 真跑与界面走查都撞到同一幕:点开失败节点,**概览 tab 第一眼是「失败、原因 —」**,
 * 要知道去翻第三个 tab 才看得到「Agent not found: prd-planning」。
 * 本文件锁三类「引擎自己判定的失败」都在 fail_reason 上留名,四环走完:
 *   产生(catch / 守卫 / 子 run 派生)→ 传递(outcome.failReason → settleNode)
 *   → 落库(graph_run_nodes.fail_reason,与状态同一次条件更新)
 *   → 读回(getRunState —— `GET /api/graph-runs/:id` 与概览 tab 走的就是这条)
 *
 * ── W9 锁的是什么 ──────────────────────────────────────────────────
 * `validator-triager/agent.md` 承诺「提示词里有:哪个节点失败、第几 attempt、失败摘要」,
 * 而在此之前**全仓库没有任何代码提供它**。这里锁两件事:
 *   ① 有前序失败时,下一个节点的 ctx.task 里真的出现了这三样;
 *   ② 没有前序失败时**一个字都不塞**(不白烧上下文)。
 * 其中最关键的一例是 backward 回退之后:`resetForwardDownstream` 会把失败节点重置成
 * pending 并 `fail_reason = NULL`,所以「查库找最近失败节点」在**最需要它的那条路径上
 * 恰好查不到**。本文件显式断言了那一刻库里确实没有失败行,而提示词里仍然有。
 *
 * 隔离手法同 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
 * 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到;executor 全是注入的假货,
 * 不起任何 Claude CLI。
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

describe('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9)', () => {
  let wdb;
  let engine;
  let sse;
  let runsDir;
  const TEST_DB_PATH = '/tmp/test-graph-engine-fail-reason.sqlite';

  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-fail-reason-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',
    ]) {
      delete require.cache[require.resolve(m)];
    }
    wdb = require('../../services/work-graph-db');
    sse = require('../../services/graph-run-sse');
    engine = require('../../services/graph-engine');
  });

  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;
  });

  beforeEach(() => {
    const db = wdb.getDb();
    db.pragma('foreign_keys = OFF');
    db.exec(`
      DELETE FROM graph_run_loop_records;
      DELETE FROM graph_run_nodes;
      DELETE FROM graph_runs;
      DELETE FROM work_graph_edges;
      DELETE FROM work_graph_nodes;
      DELETE FROM work_graphs;
      DELETE FROM system_agents;
    `);
    db.pragma('foreign_keys = ON');
    engine.setAgentExecutor(null);
    engine.setPlanWorkGraph(null);
  });

  let seq = 0;
  const nextCompany = () => `w12b-co-${++seq}`;

  function ensureAgents(ids) {
    const db = wdb.getDb();
    const ts = new Date().toISOString();
    const ins = 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', ?, ?)
    `);
    for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts);
  }

  /** @returns {{runId: string, companyId: string}} */
  function buildRun(nodes, edges = []) {
    const companyId = nextCompany();
    ensureAgents(nodes.map((n) => (typeof n === 'string' ? n : n.systemAgentId)));
    const g = wdb.createWorkGraph({ companyId, nodes, edges });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
    engine.markRunReady(run.id);
    return { runId: run.id, companyId };
  }

  const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name);

  const streamEvents = (runId) =>
    fs.readFileSync(path.join(runsDir, runId, 'stream.log'), 'utf8')
      .split('\n\n').filter(Boolean)
      .map((block) => ({
        event: (/^event: (.+)$/m.exec(block) || [])[1],
        data: JSON.parse(/^data: (.+)$/m.exec(block)[1]),
      }));

  // ────────────────────────────────────────────────────────────────
  // W12 补完 · 三类引擎自判失败都在 fail_reason 上留名
  // ────────────────────────────────────────────────────────────────

  it('取值域:三个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => {
    const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS);
    expect(engineReasons).toEqual(['node_error', 'boundary_escape', 'child_run_failed']);
    // infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它
    // 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。
    expect(engineReasons).not.toContain(engine.INFRA_FLAKE_REASON);
  });

  it('① executor 抛异常:fail_reason=node_error 真的落库(不是只进 timeline)', async () => {
    const { runId } = buildRun(['prd-planning']);
    engine.setAgentExecutor(async () => {
      throw new Error('Agent not found: prd-planning');
    });

    engine.startRun(runId);
    await engine.nextStep(runId);

    // 读回走的是 getRunState —— 与 GET /api/graph-runs/:id、概览 tab 同一条路
    const node = nodeByName(runId, 'prd-planning');
    expect(node.status).toBe('blocked');
    expect(node.failReason).toBe('node_error');

    // 落库那一环单独验一次:直接读表,排除「只是 getRunState 在拼装时编出来的」
    const row = wdb.getDb().prepare('SELECT fail_reason FROM graph_run_nodes WHERE id = ?').get(node.id);
    expect(row.fail_reason).toBe('node_error');

    // SSE 那一环(控制台实时轨)同样带上,不然界面要等下一次轮询才更新
    const out = streamEvents(runId).filter((e) => e.event === 'node.output');
    expect(out).toHaveLength(1);
    expect(out[0].data.fail_reason).toBe('node_error');
    // 原因文本仍在摘要里(W12 第一轮的成果不回退)
    expect(out[0].data.output_excerpt).toBe('Agent not found: prd-planning');
  });

  it('① 路由不变:node_error 匹配不到精确边,仍走兜底边(只加信息,不改选边)', async () => {
    const { runId } = buildRun(
      ['impl', 'escalate', 'fixer'],
      [
        // 精确边写的是 agent 归因取值;引擎自判的 node_error 不许劫持它
        { from: 'impl', to: 'fixer', condition: 'fail', failReason: 'impl_bug' },
        { from: 'impl', to: 'escalate', condition: 'fail' },
      ],
    );
    engine.setAgentExecutor(async (ctx) => {
      if (ctx.nodeName === 'impl') throw new Error('Agent not found: impl');
      return { status: 'success' };
    });

    engine.startRun(runId);
    await engine.nextStep(runId);

    expect(nodeByName(runId, 'impl').failReason).toBe('node_error');
    expect(nodeByName(runId, 'escalate').status).toBe('ready');   // 兜底边被点亮
    expect(nodeByName(runId, 'fixer').status).toBe('pending');    // 精确边没被劫持
  });

  it('② 边界守卫判越界:success 被降级成 fail,且署名 boundary_escape', async () => {
    const { runId } = buildRun(['coder']);
    engine.startRun(runId);
    // 守卫在生产里只对默认执行器开(单测一律注入假 executor),所以这里直接验它的判罚:
    // before/after 两张快照由用例给定,不跑 git、不起 CLI。
    const ctx = { runId, run: wdb.getRun(runId), nodeRunId: nodeByName(runId, 'coder').id, attempt: 0, projectRoot: '/tmp/p' };
    const settled = engine.applyBoundaryGuard(
      ctx,
      '',                                        // 调用前:仓库干净
      { status: 'success', output: 'done' },     // agent 自称成功
      '?? server/routes/echo.js\nM server/index.js\n', // 调用后:宿主仓库多了两处改动
    );
    expect(settled.status).toBe('fail');
    expect(settled.failReason).toBe('boundary_escape');
    expect(settled.output).toContain('[边界守卫]');
  });

  it('② agent 自己给了归因时不被覆盖(越界只是附加事实,不是根因)', () => {
    const { runId } = buildRun(['coder']);
    engine.startRun(runId);
    const ctx = { runId, run: wdb.getRun(runId), nodeRunId: nodeByName(runId, 'coder').id, attempt: 0, projectRoot: null };
    const settled = engine.applyBoundaryGuard(
      ctx, '', { status: 'fail', failReason: 'impl_bug' }, '?? outside.txt\n',
    );
    expect(settled.failReason).toBe('impl_bug');
  });

  it('② agent 运行态文件不算越界 → 不判 fail,也不署名', () => {
    const { runId } = buildRun(['coder']);
    engine.startRun(runId);
    const ctx = { runId, run: wdb.getRun(runId), nodeRunId: nodeByName(runId, 'coder').id, attempt: 0, projectRoot: null };
    const settled = engine.applyBoundaryGuard(
      ctx, '', { status: 'success' }, 'M system/agents/agent-creator/todo.md\n',
    );
    expect(settled.status).toBe('success');
    expect(settled.failReason).toBeUndefined();
  });

  it('③ 子 run 失败且子图一个归因都没有:父节点署名 child_run_failed', async () => {
    // 父图:一个 subgraph 节点
    const { runId, companyId } = buildRun([{ nodeName: 'org-coder', systemAgentId: 'org-coder', nodeKind: 'subgraph' }]);
    ensureAgents(['impl']);
    const child = wdb.createWorkGraph({ companyId, nodes: ['impl'], edges: [] });
    expect(child.errors || []).toEqual([]);

    engine.setPlanWorkGraph(async () => ({ workGraphId: child.id }));
    // 子图里那个节点固定失败,且不给 fail_reason(= 子图没有任何归因产出)
    engine.setAgentExecutor(async () => ({ status: 'fail', failReason: null, output: '子节点炸了' }));

    engine.startRun(runId);
    await engine.advanceTree(runId);
    await engine.advanceTree(runId);
    await engine.advanceTree(runId);

    const parent = nodeByName(runId, 'org-coder');
    expect(parent.status).toBe('blocked');
    expect(parent.failReason).toBe('child_run_failed');
  });

  it('③ 子图给得出更具体的根因时,child_run_failed 不许盖掉它', async () => {
    const { runId, companyId } = buildRun([{ nodeName: 'org-coder', systemAgentId: 'org-coder', nodeKind: 'subgraph' }]);
    ensureAgents(['impl']);
    const child = wdb.createWorkGraph({ companyId, nodes: ['impl'], edges: [] });

    engine.setPlanWorkGraph(async () => ({ workGraphId: child.id }));
    engine.setAgentExecutor(async () => ({ status: 'fail', failReason: 'impl_bug' }));

    engine.startRun(runId);
    await engine.advanceTree(runId);
    await engine.advanceTree(runId);
    await engine.advanceTree(runId);

    expect(nodeByName(runId, 'org-coder').failReason).toBe('impl_bug');
  });

  // ────────────────────────────────────────────────────────────────
  // W9 · 失败上下文进提示词
  // ────────────────────────────────────────────────────────────────

  it('首个节点没有前序失败:提示词里一个字的失败上下文都不塞', async () => {
    const { runId } = buildRun(['solo']);
    const tasks = [];
    engine.setAgentExecutor(async (ctx) => { tasks.push(ctx.task); return { status: 'success' }; });

    engine.startRun(runId);
    await engine.nextStep(runId);

    expect(tasks).toHaveLength(1);
    expect(tasks[0]).not.toContain('前序失败上下文');
  });

  it('有前序失败:下一个节点的提示词里有节点名 / attempt / 失败摘要三样', async () => {
    const { runId } = buildRun(
      ['impl', 'triager'],
      [{ from: 'impl', to: 'triager', condition: 'fail' }],
    );
    const tasks = new Map();
    engine.setAgentExecutor(async (ctx) => {
      tasks.set(ctx.nodeName, ctx.task);
      if (ctx.nodeName === 'impl') throw new Error('Agent not found: impl');
      return { status: 'success' };
    });

    engine.startRun(runId);
    await engine.nextStep(runId);  // impl 抛异常 → fail → 沿 fail 边点亮 triager
    await engine.nextStep(runId);  // triager

    const prompt = tasks.get('triager');
    expect(prompt).toContain('[前序失败上下文');
    expect(prompt).toContain('impl');                       // 哪个节点
    expect(prompt).toContain('attempt-0');                  // 第几 attempt
    expect(prompt).toContain('Agent not found: impl');      // 失败摘要
    expect(prompt).toContain('node_error');                 // 引擎判定的 fail_reason
    // agent.md 里 target_node 三个取名来源的第 (a) 个,就靠这一句接上
    expect(prompt).toContain('target_node');
  });

  it('backward 回退之后仍然拿得到失败上下文 —— 而此刻库里已经查不到任何失败行', async () => {
    // validator 骨架的形状:triager 是入口,check 失败后退回 triager
    const { runId } = buildRun(
      ['triager', 'check'],
      [
        { from: 'triager', to: 'check', condition: 'success' },
        { from: 'check', to: 'triager', condition: 'fail', edgeKind: 'backward' },
      ],
    );
    const prompts = [];
    engine.setAgentExecutor(async (ctx) => {
      prompts.push({ name: ctx.nodeName, attempt: ctx.attempt, task: ctx.task });
      if (ctx.nodeName === 'check' && ctx.attempt === 0) {
        return { status: 'fail', output: '契约非法:acceptance 缺字段' };
      }
      return { status: 'success' };
    });

    engine.startRun(runId);
    await engine.nextStep(runId);  // triager attempt 0 → success
    await engine.nextStep(runId);  // check   attempt 0 → fail → backward 退回 triager
    await engine.nextStep(runId);  // triager attempt 1 ← 要在这一轮的提示词里看到失败上下文

    // 关键判据:此刻库里**一行失败都没有** —— handleBackwardEdge → resetForwardDownstream
    // 已经把 check 重置成 pending 并 fail_reason = NULL。所以「查库找最近失败节点」在这里
    // 必然落空,失败上下文只能从 append-only 的 timeline 读回。
    const rows = wdb.getRunState(runId).nodes;
    expect(rows.filter((n) => n.status === 'fail' || n.status === 'blocked')).toEqual([]);
    expect(rows.filter((n) => n.failReason)).toEqual([]);

    const second = prompts.filter((p) => p.name === 'triager')[1];
    expect(second).toBeTruthy();
    expect(second.attempt).toBe(1);
    expect(second.task).toContain('[前序失败上下文');
    expect(second.task).toContain('check');
    expect(second.task).toContain('契约非法:acceptance 缺字段');
  });

  it('失败摘要超长时按定长截断,不把整份输出灌进下一个节点的提示词', async () => {
    const { runId } = buildRun(
      ['impl', 'triager'],
      [{ from: 'impl', to: 'triager', condition: 'fail' }],
    );
    const huge = 'y'.repeat(5000);
    const tasks = new Map();
    engine.setAgentExecutor(async (ctx) => {
      tasks.set(ctx.nodeName, ctx.task);
      if (ctx.nodeName === 'impl') return { status: 'fail', output: huge };
      return { status: 'success' };
    });

    engine.startRun(runId);
    await engine.nextStep(runId);
    await engine.nextStep(runId);

    const prompt = tasks.get('triager');
    expect(prompt).toContain('已截断');
    expect(prompt.length).toBeLessThan(huge.length);
  });

  it('subgraph 节点(workgraph-planning)的任务描述同样带上失败上下文', async () => {
    const { runId, companyId } = buildRun(
      [
        { nodeName: 'org-coder', systemAgentId: 'org-coder' },
        { nodeName: 'org-planner', systemAgentId: 'org-planner', nodeKind: 'subgraph' },
      ],
      [{ from: 'org-coder', to: 'org-planner', condition: 'fail' }],
    );
    ensureAgents(['impl']);
    const child = wdb.createWorkGraph({ companyId, nodes: ['impl'], edges: [] });

    let planningTask = null;
    engine.setPlanWorkGraph(async (ctx) => { planningTask = ctx.task; return { workGraphId: child.id }; });
    engine.setAgentExecutor(async (ctx) => {
      if (ctx.nodeName === 'org-coder') throw new Error('Agent not found: org-coder');
      return { status: 'success' };
    });

    engine.startRun(runId);
    await engine.nextStep(runId);  // org-coder 抛异常 → fail 边点亮 org-planner
    await engine.nextStep(runId);  // org-planner(subgraph)→ 走 planWorkGraph

    expect(planningTask).toContain('[前序失败上下文');
    expect(planningTask).toContain('org-coder');
    expect(planningTask).toContain('Agent not found: org-coder');
  });
});