graph-engine-extend-budget.test.js 3.54 KB
/**
 * Unit Test — engine.extendBudget:给 blocked run 追加预算 + 结清卡死的墙钟锚点
 *
 * 背景:run_b962ea87f58f 因 wallclock_ms 耗尽被 block,`spent.started_at` 卡在 block
 * 之前的旧时刻没有断开(该 run 被 block 那一刻树里还有别的 run 在跑,settleWallclock
 * 判定「不结算」,见 budget-wallclock-active.test.js 那条同名用例)。
 * 只加 wallclock_ms 上限不够:`startRunSync` 一启动就走 settleWallclock('running'),
 * 见到 started_at 非空会拒绝重锚,新加的额度立刻被「挂起等待的时长」吃光。
 * 这个测试钉住 extendBudget 必须同时把这个锚点结清。
 */
import { describe, it, expect, 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-extend-budget-'));
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;

function seedRun(id, budgetObj, status = 'blocked') {
  const db = wdb.getDb();
  const t = '2026-08-23T05:23:52.886Z';
  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, JSON.stringify(budgetObj), t, t);
  return wdb.getRun(id);
}

describe('extendBudget · 追加预算并结清卡死的墙钟锚点', () => {
  afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
  beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run());

  it('run_b962ea87f58f 的真实形态:追加后立即可用,且锚点被断开', () => {
    const stuckStartedAt = '2026-08-23T05:23:52.886Z'; // 距 now() 已远超 2h
    const run = seedRun('r_stuck', {
      total_agent_calls: 100, total_tokens: 5_000_000, wallclock_ms: 2 * HOUR,
      spent: { agent_calls: 35, tokens: 249_324, active_ms: 0, started_at: stuckStartedAt },
    });

    // 追加前:卡死的锚点让墙钟早就判耗尽
    expect(engine.checkBudget(run).exhausted).toContain('wallclock_ms');

    const out = engine.extendBudget(run.id, { wallclock_ms: 4 * HOUR });
    expect(out.ok).toBe(true);
    expect(out.budget.wallclock_ms).toBe(6 * HOUR);
    expect(out.budget.spent.started_at).toBeNull(); // 锚点必须断开
    expect(out.budget.spent.active_ms).toBeGreaterThan(0); // 挂起时长已并入 active_ms

    const fresh = wdb.getRun('r_stuck');
    expect(engine.checkBudget(fresh).ok).toBe(true);

    // 「启动」重锚不会被旧锚点拒绝——settleWallclock('running') 应该重新开一段计时
    const settled = engine.settleWallclock('r_stuck', 'running');
    expect(settled.started_at).toBeTruthy();
  });

  it('目标维度本来就不限额时忽略该维度;一维都没追加成就报错', () => {
    seedRun('r_partial', { total_agent_calls: 100, spent: { agent_calls: 5 } });
    // wallclock_ms 本来就没配额度,追加它是 no-op;total_agent_calls 有配额,真正生效
    const out = engine.extendBudget('r_partial', { wallclock_ms: HOUR, total_agent_calls: 50 });
    expect(out.applied).toEqual({ total_agent_calls: 50 });
    expect(out.budget.total_agent_calls).toBe(150);
    expect(out.budget.wallclock_ms).toBeUndefined();

    seedRun('r_none', { total_agent_calls: 100, spent: {} });
    expect(() => engine.extendBudget('r_none', { wallclock_ms: HOUR })).toThrow();
  });
});