From 9a16416ac63a2e9113c8aa6065f67f13f042d34b Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 23 Aug 2026 19:07:28 +0800 Subject: [PATCH] fix(graph-run): 补上 POST /:id/budget 端点,给 blocked run 追加预算 --- server/routes/graph-run.js | 33 +++++++++++++++++++++++++++++++++ server/services/graph-engine.js | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-console-node-signals.test.js | 1 + server/tests/unit/graph-engine-extend-budget.test.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/graph-console/node-signals.js | 4 +++- 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 server/tests/unit/graph-engine-extend-budget.test.js diff --git a/server/routes/graph-run.js b/server/routes/graph-run.js index e846114..0291e84 100644 --- a/server/routes/graph-run.js +++ b/server/routes/graph-run.js @@ -328,6 +328,39 @@ router.post('/:id/nodes/:nodeId/retry', h((req, res) => { res.json(engine.retryNode(run.id, nodeRun.id)); })); +/** + * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列) + * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean } + * 可用条件:run_status = 'blocked' + * + * 只在这一个状态开放:预算耗尽 / 墙钟看门狗(`sweepWallclockBudgets`)打到 blocked 的 run + * 才需要人工追加额度,其它状态下加预算没有对应的业务场景(想在 running 时顺手提高上限, + * 应该是另一个决策要不要做的问题,这里不顺手放开)。 + * `resume: true` 时追加完立即调 `/start` 同一条路径拉回 running——两步拆成一个请求, + * 免得调用方在这两步之间手动再打一次 `/start`。 + */ +router.post('/:id/budget', h((req, res) => { + const run = loadRun(req, res); + if (!run) return; + if (!ensureStatus(res, run, '追加预算', ['blocked'])) return; + + const { total_agent_calls, total_tokens, wallclock_ms, resume } = req.body || {}; + let out; + try { + out = engine.extendBudget(run.id, { total_agent_calls, total_tokens, wallclock_ms }); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + + if (!resume) return res.json(out); + + // 复用「启动」同一条路径(admitOrRequeue 的坑满退回队列、清软信号……一样都不重写) + const fresh = loadRun(req, res); + if (!fresh) return; + const { driver, ...startOut } = engine.startRun(fresh.id, {}); + res.json({ ...out, resumed: { ...startOut, auto: Boolean(driver) } }); +})); + // ──────────────────────────────────────────────────────────────── // 只读查询类端点 // ──────────────────────────────────────────────────────────────── diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index c7571b8..5e33435 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -2845,6 +2845,60 @@ function spendBudget(run, { agentCalls = 0, tokens = 0 } = {}) { } /** + * 运维操作:给一条 blocked 的 run 追加预算上限,为「启动」铺路。 + * + * 「只增不减」(§四.A.3 的既有约定)在这里延伸成:只吃正数增量、只加到已经配了额度的 + * 那一维上(`b[key] == null` = 那一维本来就不限额,追加没有意义,忽略而不是凭空开一维新限额)。 + * + * 顺手结清卡死的墙钟锚点——不做这一步的话,`startRunSync` 一「启动」就会走 + * `settleWallclock(runId,'running')`:`spent.started_at` 非空会被判成「树里已经有别的 run + * 在跑,别重锚」(见该函数注释),于是沿用的还是 blocked 之前那个旧锚点,预算刚加完 + * 下一次 `checkBudget` 立刻用「挂起等待的时长」把新加的额度吃光,run 秒进秒出。 + * 这里手动做一次 `settleWallclock` 本该在「离开 running」那一刻做的事:把锚点到现在的 + * 时长并进 `active_ms`、断开锚点,让「启动」重新开一段干净的计时。 + */ +function extendBudget(runId, patch = {}) { + const run = getRun(runId); + if (!run) throw new Error(`graph_runs 不存在: ${runId}`); + const held = readBudget(run); + if (!held) throw new Error(`run ${runId} 没有挂 budget_json,没有预算可加`); + + const b = held.budget; + if (!b.spent) b.spent = {}; + const before = { ...b, spent: { ...b.spent } }; + + const keys = ['total_agent_calls', 'total_tokens', 'wallclock_ms']; + const applied = {}; + for (const key of keys) { + if (patch[key] == null) continue; + if (typeof patch[key] !== 'number' || !(patch[key] > 0)) { + throw new Error(`${key} 增量必须是正数,收到: ${patch[key]}`); + } + if (b[key] == null) continue; // 该维度本来就不限额,追加没有意义 + b[key] += patch[key]; + applied[key] = patch[key]; + } + if (Object.keys(applied).length === 0) { + throw new Error('没有任何一维预算被追加(增量缺失,或目标维度本来就不限额)'); + } + + if (b.spent.started_at) { + b.spent.active_ms = (b.spent.active_ms || 0) + Math.max(0, Date.now() - Date.parse(b.spent.started_at)); + b.spent.started_at = null; + } + + getDb() + .prepare(`UPDATE graph_runs SET budget_json = ?, updated_at = ? WHERE id = ?`) + .run(JSON.stringify(b), now(), held.rootRunId); + + sse.emitEvent(runId, 'budget.extended', '运维追加预算并结清墙钟锚点', { + root_run_id: held.rootRunId, applied, before, after: b, + }, { ref: held.rootRunId }); + + return { ok: true, rootRunId: held.rootRunId, applied, budget: b }; +} + +/** * 资源类原因(预算耗尽 / 账户欠费)→ **整棵 run 树挂起等人工**,不是失败(§四.A.3 / 27-P4 §4.4): * root run → blocked,树上所有非终态 run 一律置 pause_requested=1。 * 同样只做一次 listRunTree 索引查询。 @@ -4495,6 +4549,7 @@ module.exports = { // 任务级总预算(§四.A.3) checkBudget, spendBudget, + extendBudget, settleWallclock, wallclockElapsedMs, suspendRunTree, diff --git a/server/tests/unit/graph-console-node-signals.test.js b/server/tests/unit/graph-console-node-signals.test.js index 3de6059..62290a5 100644 --- a/server/tests/unit/graph-console-node-signals.test.js +++ b/server/tests/unit/graph-console-node-signals.test.js @@ -77,6 +77,7 @@ describe('控制台节点信号(W12-UI 读回层)', () => { 'loop.terminated': { cap: 'edge' }, 'loop.cross_layer': {}, 'budget.spent': { delta: { agent_calls: 1, tokens: 42 } }, + 'budget.extended': { applied: { wallclock_ms: 14_400_000 } }, 'plan.call': { agent: 'workgraph-planning', round: 1 }, 'plan.done': { work_graph_id: 'wg-1', round: 1, nodes: 3, edges: 2 }, 'plan.rejected': { round: 1, errors: [{ rule: 'reference', detail: '图不存在' }] }, diff --git a/server/tests/unit/graph-engine-extend-budget.test.js b/server/tests/unit/graph-engine-extend-budget.test.js new file mode 100644 index 0000000..cff16d6 --- /dev/null +++ b/server/tests/unit/graph-engine-extend-budget.test.js @@ -0,0 +1,73 @@ +/** + * 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(); + }); +}); diff --git a/web/src/components/graph-console/node-signals.js b/web/src/components/graph-console/node-signals.js index 9cd55ee..0a07bd9 100644 --- a/web/src/components/graph-console/node-signals.js +++ b/web/src/components/graph-console/node-signals.js @@ -20,7 +20,7 @@ export const RUN_EVENT_KINDS = [ 'agent.call', 'node.output', 'node.error', 'node.triage', 'node.contract', 'node.boundary', 'node.subgraph', 'loop.registered', 'loop.terminated', 'loop.cross_layer', - 'budget.spent', + 'budget.spent', 'budget.extended', 'plan.call', 'plan.done', 'plan.rejected', ]; @@ -94,6 +94,8 @@ export function describeEvent(e) { return '跨层定点回退'; case 'budget.spent': return `预算扣减 +${(d.delta && d.delta.agent_calls) || 0} 次调用 / +${(d.delta && d.delta.tokens) || 0} tokens`; + case 'budget.extended': + return `运维追加预算:${Object.entries(d.applied || {}).map(([k, v]) => `${k} +${v}`).join('、') || '?'}`; case 'plan.call': return `调 ${d.agent || 'workgraph-planning'} 规划子图(第 ${d.round ?? '?'} 轮)`; case 'plan.done': -- libgit2 0.21.4