graph-cross-layer-address.test.js 9.77 KB
/**
 * Unit Tests — R-17.1 跨层地址「哪一半是必填的」(27-P3B §四 W10)
 *
 * 起因:W10 原文(以及 validator-triager/agent.md 顶部粒度块的旧措辞)说
 * 「`target_org_node` + `target_node` **两半都写**才合法,只写一半会静默退化成整张图重跑」。
 * 本文件把这句话拿到引擎上实跑一遍,结论是**反的**:
 *
 *   - `normalizeNodeRef` 只在 `workNode` 为空时返回 null(`orgNode` 缺省合法,写死成 null);
 *   - `resolveCrossLayerTarget` 的第 ④ 关是 `if (ref.orgNode && ref.orgNode !== orgNodeName)`
 *     —— `orgNode` 缺省时这一关**根本不进**,改以回退边的目标为准。
 *
 * 所以:**`target_node` 是唯一必填的那一半**,`target_org_node` 是可选的一致性校验。
 * 把「两半都要写」写进模板会诱导 agent 去编 org node 名 —— 恰是 agent.md 自己那条红线。
 *
 * 隔离手法照抄 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
 * agent 调用与子图规划全部注入假实现,全程不起 Claude CLI。
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

describe('R-17.1 跨层地址 · 两半的必填性(W10 事实核实)', () => {
  let wdb;
  let engine;
  let runsDir;
  const TEST_DB_PATH = '/tmp/test-graph-cross-layer-address.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-xlayer-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');
    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 org_graph_edges;
      DELETE FROM org_graph_nodes;
      DELETE FROM system_agents;
    `);
    db.pragma('foreign_keys = ON');
    engine.setAgentExecutor(null);
  });

  let seq = 0;
  const nextCompany = () => `xl-${++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, source_kind, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
    `);
    for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts);
  }

  /** org 层形状:coder / validator 都是 subgraph,validator 判失败沿 backward 边退回 coder */
  function buildTwoLayerRun(companyId) {
    ensureAgents(['coder', 'validator']);
    const g = wdb.createWorkGraph({
      companyId,
      nodes: [
        { nodeName: 'coder', nodeKind: 'subgraph' },
        { nodeName: 'validator', nodeKind: 'subgraph' },
      ],
      edges: [
        { from: 'coder', to: 'validator' },
        { from: 'validator', to: 'coder', condition: 'fail', edgeKind: 'backward' },
      ],
    });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({
      graphKind: 'work', graphId: g.id, companyId, executionMode: 'auto',
    });
    engine.markRunReady(run.id);

    engine.setPlanWorkGraph(async (ctx) => {
      ensureAgents(['impl-a', 'impl-b', 'validator-triager']);
      const sub = ctx.nodeName === 'coder'
        ? wdb.createWorkGraph({
          companyId, nodes: ['impl-a', 'impl-b'], edges: [{ from: 'impl-a', to: 'impl-b' }],
        })
        : wdb.createWorkGraph({ companyId, nodes: ['validator-triager'], edges: [] });
      expect(sub.errors || []).toEqual([]);
      return { workGraphId: sub.id };
    });
    return run.id;
  }

  function fakeExecutor(behaviors) {
    const calls = [];
    engine.setAgentExecutor(async (ctx) => {
      calls.push({ node: ctx.nodeName, attempt: ctx.attempt });
      return behaviors[ctx.nodeName] || { status: 'success' };
    });
    return calls;
  }

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

  // ────────────────────────────────────────────────────────────
  it('只写 target_node(不写 target_org_node)→ 跨层定点重置照样生效,org 那一半以回退边为准', async () => {
    const companyId = nextCompany();
    const runId = buildTwoLayerRun(companyId);

    const calls = fakeExecutor({
      'impl-a': { status: 'success' },
      'impl-b': { status: 'success' },
      // ← W10 的核心:**只有 workNode**,没有 orgNode
      'validator-triager': {
        status: 'fail',
        failReason: 'impl_bug',
        failNodeRef: { workNode: 'impl-a' },
        remediationNote: 'impl-a 少处理了空输入',
      },
    });

    await engine.startRun(runId, { mode: 'auto' }).driver;

    const coder = nodeByName(runId, 'coder');
    const inner = Object.fromEntries(
      wdb.getRunState(coder.childRunId).nodes.map((n) => [n.name, n])
    );

    // ① 地址被判为合法并落库(orgNode 显式为 null,不是把整条地址丢掉)
    const triager = wdb.getRunState(coder.childRunId === null ? runId : runId).nodes;
    expect(triager).toBeTruthy();
    expect(JSON.parse(nodeByName(runId, 'validator').failNodeRef || 'null'))
      .toEqual({ orgNode: null, workNode: 'impl-a' });

    // ② 真的做了定点重置:只有 impl-a 重跑,contract 未变的 impl-b 一次都没重跑
    expect(inner['impl-a'].attempt).toBeGreaterThan(0);
    expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1);
    expect(inner['impl-b'].attempt).toBe(0);
    expect(calls.filter((c) => c.node === 'impl-b')).toHaveLength(1);

    // ③ 账本上 target_node_ref 非空 = 这一次退的是「某一个节点」,
    //    且 orgNode 被引擎按回退边的目标补齐成 'coder'(agent 没写、引擎自己解析出来的)
    const records = wdb.listLoopRecords(runId);
    expect(records.length).toBeGreaterThan(0);
    expect(JSON.parse(records[0].target_node_ref)).toMatchObject({
      orgNode: 'coder', workNode: 'impl-a',
      childRunId: coder.childRunId, nodeRunId: inner['impl-a'].id,
    });
  });

  it('只写 target_org_node(不写 target_node)→ 整条地址作废,退化成整张 work graph 重跑', async () => {
    const companyId = nextCompany();
    const runId = buildTwoLayerRun(companyId);

    const calls = fakeExecutor({
      'impl-a': { status: 'success' },
      'impl-b': { status: 'success' },
      // ← 反向:只有 orgNode,没有 workNode。normalizeNodeRef 返回 null
      'validator-triager': {
        status: 'fail',
        failReason: 'impl_bug',
        failNodeRef: { orgNode: 'coder' },
      },
    });

    await engine.startRun(runId, { mode: 'auto' }).driver;

    const coder = nodeByName(runId, 'coder');
    // 地址整条没落库
    expect(nodeByName(runId, 'validator').failNodeRef).toBeNull();
    // 整张子图重跑:两个节点都重跑了;子 run 仍然复用同一张
    expect(wdb.listRunTree(runId).filter((r) => r.parent_node_run_id === coder.id)).toHaveLength(1);
    expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1);
    expect(calls.filter((c) => c.node === 'impl-b').length).toBeGreaterThan(1);
    expect(wdb.listLoopRecords(runId).every((r) => r.target_node_ref === null)).toBe(true);
  });

  // ────────────────────────────────────────────────────────────
  // 模板侧:键名逐字对齐(「差一个字母就是静默失效」的防线)
  // ────────────────────────────────────────────────────────────
  it('validator-triager 模板里的地址键名与引擎读的逐字一致,且没有近似拼写的变体', () => {
    const root = path.resolve(__dirname, '../../../system/templates/validator-triager');
    const text = ['agent.md', 'tools.md'].map((f) => fs.readFileSync(path.join(root, f), 'utf8')).join('\n');

    // 引擎 readNodeProduct 读的就是这两个顶层键(graph-engine.js:doc.target_org_node / doc.target_node)
    expect(text).toContain('target_org_node');
    expect(text).toContain('target_node');

    // 近似拼写一律不许出现:这些写法引擎一个都读不到,且失效是静默的
    for (const bad of ['targetNode', 'targetOrgNode', 'target_orgnode', 'org_node_target', 'target_work_node']) {
      expect(text).not.toContain(bad);
    }

    // 必填性表述:模板不许再说「两半都要写才生效」(引擎侧 orgNode 缺省合法,见本文件第一条用例)
    expect(text).not.toContain('两个都给全');
    expect(text).not.toContain('两半都写');
  });
});