budget-wallclock-active.test.js 10 KB
/**
 * Unit Tests — 墙钟预算只算「真的在跑」的时间,不算挂起等人的时间
 *
 * ── 挡的是什么 ─────────────────────────────────────────────────────────
 * 原实现按 `now - spent.started_at` 算,而 started_at 是「第一次花钱的绝对时刻」。
 * 这与 blocked 的语义直接对撞:预算耗尽、节点判罚都会把 run 打到 blocked,
 * 而 blocked 的定义就是**挂起等人工**。人隔夜再来处置 →「等待」被算成「已用」→
 * 一起跑就报 wallclock_ms 耗尽 → 再 blocked → 再等人。**过夜的 run 永远醒不过来。**
 *
 * 而且报出来的原因具有误导性:写着 `wallclock_ms`(跑太久),实际是等人等太久。
 *
 * 真事:run_9168bd24f46c(e2e-demo 做官网)2026-08-18 15:48 起跑、16:19 判 blocked,
 * 只花了 7/100 次调用、69,961/5,000,000 tokens,两天后仍卡死 —— 用掉的预算不到 2%。
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

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

const engine = require('../../services/graph-engine');
const wdb = require('../../services/work-graph-db');

const HOUR = 3600_000;
const budget = (spent) => JSON.stringify({ wallclock_ms: 2 * HOUR, total_agent_calls: 100, spent });

/** 直接落一行 root run,只为拿一个带 budget_json 的载体(不走图定义那一整套) */
function seedRun(id, spentObj, status = 'running') {
  const db = wdb.getDb();
  const t = '2026-08-18T15:48:00.000Z';
  db.prepare(`INSERT INTO graph_runs
      (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at)
      VALUES (?, 'org', 'g1', 'co1', ?, 'manual', ?, ?, ?, ?)`)
    .run(id, id, status, budget(spentObj), t, t);
  return wdb.getRun(id);
}

describe('预算 · 墙钟只算活跃时长', () => {
  afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
  beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run());

  it('挂起期间墙钟不走:两天前 blocked 的 run,余额照旧', () => {
    // 这一条就是 run_9168bd24f46c 的形态:active_ms 只有 31 分钟,started_at 已断开
    const run = seedRun('r_old', { agent_calls: 7, tokens: 69961, active_ms: 31 * 60_000, started_at: null }, 'blocked');
    const gate = engine.checkBudget(run);
    expect(gate.exhausted).toEqual([]);
    expect(gate.ok).toBe(true);
  });

  it('旧算法下这条 run 必然耗尽 —— 证明这个测试真的在量东西', () => {
    // 反例对照:起点是两天前的绝对时刻、没有 active_ms(存量行的原始形态)
    const spent = { agent_calls: 7, tokens: 69961, started_at: '2026-08-18T15:48:17.630Z' };
    const legacyElapsed = Date.now() - Date.parse(spent.started_at);
    expect(legacyElapsed).toBeGreaterThan(2 * HOUR); // 旧判据:早就超了
    // 新判据看的是 active_ms,started_at 断开后就不再累加
    expect(engine.wallclockElapsedMs({ ...spent, started_at: null, active_ms: 31 * 60_000 })).toBeLessThan(2 * HOUR);
  });

  it('活跃段分次累加:三段各 40 分钟 → 第三段跑到一半就该耗尽', () => {
    expect(engine.wallclockElapsedMs({ active_ms: 80 * 60_000, started_at: null })).toBe(80 * 60_000);
    const run = seedRun('r_acc', { active_ms: 119 * 60_000, started_at: null }, 'blocked');
    expect(engine.checkBudget(run).ok).toBe(true);
    wdb.getDb().prepare('UPDATE graph_runs SET budget_json = ? WHERE id = ?')
      .run(budget({ active_ms: 121 * 60_000, started_at: null }), 'r_acc');
    expect(engine.checkBudget(wdb.getRun('r_acc')).exhausted).toEqual(['wallclock_ms']);
  });

  it('settleWallclock:进 running 开始计时,离开 running 把这一段并进 active_ms', () => {
    seedRun('r_settle', { active_ms: 5 * 60_000, started_at: null }, 'running');
    const started = engine.settleWallclock('r_settle', 'running');
    expect(started.started_at).toBeTruthy();
    expect(started.active_ms).toBe(5 * 60_000);

    // 生产里转换先落库、钩子后跑,所以这里也得先把状态改掉 —— 不改的话
    // treeHasRunningRun 仍看到 running,结算被正确地跳过(那是另一条用例在验的行为)
    wdb.getDb().prepare(`UPDATE graph_runs SET run_status='blocked' WHERE id='r_settle'`).run();
    const stopped = engine.settleWallclock('r_settle', 'blocked');
    expect(stopped.started_at).toBeNull();
    expect(stopped.active_ms).toBeGreaterThanOrEqual(5 * 60_000);   // 只增不减
    expect(stopped.active_ms).toBeLessThan(6 * 60_000);             // 这一段是毫秒级,不该凭空多出一分钟
  });

  it('存量行(没有 active_ms)进 running 时按 0 起算,不把旧锚点当活跃时长', () => {
    // 旧锚点里混着等待时间、复原不出来 —— 一笔勾销是有意的:真正兜住花钱的是调用次数与 tokens
    seedRun('r_legacy', { agent_calls: 7, started_at: '2026-08-18T15:48:17.630Z' }, 'running');
    const s = engine.settleWallclock('r_legacy', 'running');
    expect(s.active_ms).toBe(0);
    expect(Date.parse(s.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z'));
    expect(engine.checkBudget(wdb.getRun('r_legacy')).ok).toBe(true);
  });

  /**
   * 这一组是**真跑撞出来的**:第一版只认 root run 的转换。
   * 而人工重试一个 work 层节点动的是**子 run**(blocked → running),root 全程 blocked ——
   * 于是子 run 跑整场、墙钟一次都没开始计,旧锚点还留着,第一次 checkBudget 立刻判耗尽。
   * 单测当时全绿:它们只喂了 root。
   */
  const seedChild = (id, rootId, status) => {
    wdb.getDb().prepare(`INSERT INTO graph_runs
        (id, graph_kind, graph_id, company_id, root_run_id, parent_run_id, execution_mode, run_status, budget_json, created_at, updated_at)
        VALUES (?,'work','wg1','co1',?,?,'manual',?, NULL, ?, ?)`)
      .run(id, rootId, rootId, status, '2026-08-18T15:49:00.000Z', '2026-08-18T15:49:00.000Z');
  };

  it('子 run 进 running 也开始计时:预算行仍写 root,但触发的是子 run 的转换', () => {
    seedRun('r_root', { active_ms: 0, started_at: null }, 'blocked'); // root 一直 blocked
    seedChild('r_child', 'r_root', 'running');
    const s = engine.settleWallclock('r_child', 'running');
    expect(s).not.toBeNull();
    expect(s.started_at).toBeTruthy();
    expect(JSON.parse(wdb.getRun('r_root').budget_json).spent.started_at).toBeTruthy();
  });

  it('存量行由子 run 的转换重锚 —— 那条卡了两天的 run 就是这个形态', () => {
    seedRun('r_root2', { agent_calls: 7, tokens: 69961, started_at: '2026-08-18T15:48:17.630Z' }, 'blocked');
    seedChild('r_child2', 'r_root2', 'running');
    engine.settleWallclock('r_child2', 'running');
    const spent = JSON.parse(wdb.getRun('r_root2').budget_json).spent;
    expect(spent.active_ms).toBe(0);
    expect(Date.parse(spent.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z'));
    expect(engine.checkBudget(wdb.getRun('r_child2')).ok).toBe(true); // 子 run 查的也是 root 的池子
  });

  it('一层停了不结算 —— 树里还有别的 run 在跑,计时不能被提前掐断', () => {
    seedRun('r_root3', { active_ms: 60_000, started_at: '2026-08-20T00:00:00.000Z' }, 'running');
    seedChild('r_child3', 'r_root3', 'blocked');
    expect(engine.settleWallclock('r_child3', 'blocked')).toBeNull();
    expect(JSON.parse(wdb.getRun('r_root3').budget_json).spent.started_at).toBe('2026-08-20T00:00:00.000Z');

    // root 也停下来,树里再没有 running → 这才结算
    wdb.getDb().prepare(`UPDATE graph_runs SET run_status='blocked' WHERE id='r_root3'`).run();
    const s = engine.settleWallclock('r_root3', 'blocked');
    expect(s.started_at).toBeNull();
    expect(s.active_ms).toBeGreaterThan(60_000);
  });

  it('树里已经有 run 在跑时,另一条进 running 不重锚(否则前一段被抹掉)', () => {
    seedRun('r_root4', { active_ms: 0, started_at: '2026-08-20T00:00:00.000Z' }, 'running');
    seedChild('r_child4', 'r_root4', 'running');
    expect(engine.settleWallclock('r_child4', 'running')).toBeNull();
    expect(JSON.parse(wdb.getRun('r_root4').budget_json).spent.started_at).toBe('2026-08-20T00:00:00.000Z');
  });

  it('挂在漏斗上:走 sse.transitionRun 就会结算,不需要调用方记得调', () => {
    // 这一条守的是钩子的**位置**:进 running 既不是收敛、也不空坑,
    // 放在 afterRunTransition 那个 early return 后面就变成「只结算停下来、从不开始计时」——
    // 症状是 active_ms 永远 0,墙钟形同虚设,而且照样全绿。
    const sse = require('../../services/graph-run-sse');
    seedRun('r_funnel', { active_ms: 0, started_at: null }, 'ready');

    expect(sse.transitionRun('r_funnel', 'ready', 'running')).toBeTruthy();
    expect(JSON.parse(wdb.getRun('r_funnel').budget_json).spent.started_at).toBeTruthy();

    expect(sse.transitionRun('r_funnel', 'running', 'blocked')).toBeTruthy();
    const spent = JSON.parse(wdb.getRun('r_funnel').budget_json).spent;
    expect(spent.started_at).toBeNull();
    expect(spent.active_ms).toBeGreaterThanOrEqual(0);
  });

  it('没配 wallclock_ms 的 run:结算是 no-op,不许凭空造出 active_ms 字段', () => {
    const db = wdb.getDb();
    db.prepare(`INSERT INTO graph_runs
        (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at)
        VALUES ('r_nowc','org','g1','co1','r_nowc','manual','running', ?, ?, ?)`)
      .run(JSON.stringify({ total_agent_calls: 10, spent: { agent_calls: 1 } }), '2026-08-18T15:48:00.000Z', '2026-08-18T15:48:00.000Z');
    expect(engine.settleWallclock('r_nowc', 'blocked')).toBeNull();
    expect(JSON.parse(wdb.getRun('r_nowc').budget_json).spent).toEqual({ agent_calls: 1 });
  });
});