Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/claude/__tests__/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,104 @@ describe('ClaudeExecutor', () => {
});
});

// 回归:错误型 result 的顶层 usage 全为 0,若照它算费用会得到 0,
// 而真实花费只在 total_cost_usd / modelUsage 里 —— 超预算恰恰是最该记账的场景。
describe('cost accounting on error results', () => {
const EMPTY_USAGE = {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
};

it('falls back to session total when usage is empty (error_max_budget_usd)', async () => {
// 取自线上真实一次超预算执行的字段
setupMessages([
{ type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude-opus-5', tools: [] },
{
type: 'result',
subtype: 'error_max_budget_usd',
session_id: 'sess-1',
duration_ms: 14,
num_turns: 1,
total_cost_usd: 18.606643,
usage: EMPTY_USAGE,
modelUsage: {
'claude-opus-5': {
costUSD: 18.606643,
inputTokens: 273178,
outputTokens: 210030,
cacheReadInputTokens: 9259706,
cacheCreationInputTokens: 1177624,
},
},
},
]);

const result = await executor.execute(makeInput());

expect(result.success).toBe(false);
expect(result.costUsd).toBeCloseTo(18.606643, 4); // 不是 0
expect(result.numTurns).toBe(1);
expect(result.error).toContain('error_max_budget_usd');
});

it('does not fall back when usage is populated', async () => {
// usage 有值时仍按单次用量算,避免 resume 首次 query 把历史累计算进来
setupMessages([
{ type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude-opus-5', tools: [] },
{
type: 'result',
subtype: 'success',
session_id: 'sess-1',
result: 'done',
duration_ms: 100,
total_cost_usd: 99, // 混入历史累计的假高值
usage: {
input_tokens: 1000,
output_tokens: 500,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
modelUsage: {
'claude-opus-5': {
costUSD: 99,
inputTokens: 900_000, // 与顶层 usage 不一致 → 走定价计算分支
outputTokens: 400_000,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
},
]);

const result = await executor.execute(makeInput());

expect(result.success).toBe(true);
expect(result.costUsd).toBeLessThan(1); // 按 1500 token 算,远小于 $99
});

it('keeps zero cost when the session genuinely spent nothing', async () => {
setupMessages([
{ type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude-opus-5', tools: [] },
{
type: 'result',
subtype: 'error_max_budget_usd',
session_id: 'sess-1',
duration_ms: 14,
num_turns: 1,
total_cost_usd: 0,
usage: EMPTY_USAGE,
modelUsage: {},
},
]);

const result = await executor.execute(makeInput());

expect(result.costUsd).toBe(0);
});
});

describe('killSessionsForChat', () => {
it('should kill all session key patterns for a chat', async () => {
// 直接往 runningQueries 注入 mock entries 来测试 killSessionsForChat
Expand Down
37 changes: 35 additions & 2 deletions src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const DEFAULT_PRICING = MODEL_PRICING['claude-opus-4-6']!;
*
* 当 usage 和 modelUsage 一致时(无 subagent、非首次 resume),计算结果 ≈ total_cost_usd。
* 当出现累计偏差时,本函数返回更合理的单次费用。
*
* 注意:错误型 result(如 error_max_budget_usd)的顶层 usage 全为 0,本函数会算出 0,
* 此时真实花费只存在于 modelUsage 里。调用方需用 isUsageEmpty() 判断并回退,
* 否则超预算这类最该记账的场景反而记成 0。
*/
function calculateCostFromUsage(
usage: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number },
Expand Down Expand Up @@ -86,6 +90,28 @@ function calculateCostFromUsage(
);
}

/**
* 顶层 usage 是否为空(四个 token 计数全为 0)。
*
* SDK 对错误型 result(error_max_budget_usd / error_during_execution 等)不填顶层 usage,
* 于是按 usage 计算的费用是 0,而真实消耗只体现在 modelUsage / total_cost_usd 上。
* 实测一次超预算的定时任务:usage 全 0,但 modelUsage 记录 output 21 万 token、
* cacheRead 926 万 token,实际 $18.61 —— 若不回退,这笔钱记账为 0。
*/
function isUsageEmpty(usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
}): boolean {
return (
usage.input_tokens === 0 &&
usage.output_tokens === 0 &&
usage.cache_creation_input_tokens === 0 &&
usage.cache_read_input_tokens === 0
);
}

/** 只读模式下禁止调用的写入类工具 */
const WRITE_TOOLS = new Set([
'Edit', 'Write', 'NotebookEdit', 'Bash', 'Skill',
Expand Down Expand Up @@ -1319,8 +1345,13 @@ export class ClaudeExecutor {
// SDK 的 total_cost_usd / modelUsage / durationApiMs 在 resume 首次 query 时
// 会包含整个 session 的历史累计值,导致简单问题显示天价费用。
// 改用顶层 usage 字段(仅包含本次 query 的 token 用量)自行计算费用。
const queryCostUsd = (resultMessage.usage && resultMessage.modelUsage)
? calculateCostFromUsage(resultMessage.usage as Parameters<typeof calculateCostFromUsage>[0], resultMessage.modelUsage)
// 例外:错误型 result(error_max_budget_usd 等)的顶层 usage 全为 0,
// 按它算出来的费用是 0,真实花费只在 total_cost_usd / modelUsage 里。
// 这类场景恰恰最该记账(超预算),因此回退到 SDK 的累计值。
const usageForCost = resultMessage.usage as Parameters<typeof calculateCostFromUsage>[0] | undefined;
const costFallbackToSessionTotal = !!usageForCost && isUsageEmpty(usageForCost) && (resultMessage.total_cost_usd ?? 0) > 0;
const queryCostUsd = (usageForCost && resultMessage.modelUsage && !costFallbackToSessionTotal)
? calculateCostFromUsage(usageForCost, resultMessage.modelUsage)
: resultMessage.total_cost_usd;

// terminal_reason: SDK 0.2.91+ 暴露 query 终止原因
Expand All @@ -1332,6 +1363,8 @@ export class ClaudeExecutor {
terminalReason,
sdkTotalCostUsd: resultMessage.total_cost_usd,
queryCostUsd,
// true 表示 queryCostUsd 用的是会话累计口径(顶层 usage 为空时的回退)
costFallbackToSessionTotal,
numTurns: resultMessage.num_turns,
durationMs: resultMessage.duration_ms,
durationApiMs: resultMessage.duration_api_ms,
Expand Down
21 changes: 21 additions & 0 deletions src/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ export interface ClaudeResult {
conversationTrace?: ConversationTurn[];
}

/**
* 一次任务执行的结果摘要。
*
* 供 cron 等「发起方不在飞书事件链路上」的调用方判断执行是否真正成功、花了多少钱。
* 从 ClaudeResult 提炼,不含 output/sessionId 等大字段。
*
* 背景:executeDirectTask / executeClaudeTask 原先返回 void,cron scheduler 只能靠
* 「有没有抛异常」判断成败。结果是 SDK 因超预算立即拒绝(零 turn)时 cron 仍记 ok,
* 定时任务连续多天没真正执行而记录显示一切正常。
*/
export interface TaskOutcome {
/** 是否执行成功(SDK result.subtype === 'success') */
success: boolean;
/** 本次花费 (USD);错误型 result 下为会话累计口径 */
costUsd?: number;
/** 失败原因 */
error?: string;
/** 总轮数 —— 零轮 + 失败通常意味着 SDK 直接拒绝了请求 */
numTurns?: number;
}

/**
* /compact 上下文压缩结果。
*
Expand Down
76 changes: 76 additions & 0 deletions src/cron/__tests__/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,4 +427,80 @@ describe('CronScheduler', () => {

expect(executeTask).toHaveBeenCalledTimes(1);
});

// ── 执行结果记账与静默失败 ──
//
// 回归背景:executeTask 原先返回 void,scheduler 只能靠「有没有抛异常」判断成败。
// SDK 因会话累计花费超 maxBudgetUsd 而拒绝执行时并不抛异常(实测 numTurns=1、
// 零 token、18 秒返回,一个 turn 都没跑),于是 run 被记成 ok 且 cost_usd 为空——
// 定时任务连续多天空跑,而记录显示一切正常。

it('records cost_usd on a successful run', async () => {
executeTask = vi.fn(async () => ({ success: true, costUsd: 1.2345, numTurns: 12 })) as unknown as CronTaskExecutor;
scheduler = new CronScheduler({ store, executeTask, sendMessage });

const job = await scheduler.addJob({
name: 'billed',
chatId: 'chat1',
userId: 'user1',
prompt: 'work',
schedule: { kind: 'every', everyMs: 60_000 },
});
await scheduler.triggerJob(job.id);

const [run] = store.getRecentRuns(job.id, 1);
expect(run!.status).toBe('ok');
expect(run!.costUsd).toBeCloseTo(1.2345, 4);
expect(store.get(job.id)!.state.lastStatus).toBe('ok');
});

it('treats a failed outcome as an error and still records its cost', async () => {
// 复现 08-02 那次:SDK 拒绝执行,不抛异常,但已经花掉 $18.61
executeTask = vi.fn(async () => ({
success: false,
costUsd: 18.606643,
error: 'Query ended with: error_max_budget_usd',
numTurns: 1,
})) as unknown as CronTaskExecutor;
scheduler = new CronScheduler({ store, executeTask, sendMessage });

const job = await scheduler.addJob({
name: 'over-budget',
chatId: 'chat1',
userId: 'user1',
prompt: 'work',
schedule: { kind: 'every', everyMs: 60_000 },
});
await scheduler.triggerJob(job.id);

const [run] = store.getRecentRuns(job.id, 1);
expect(run!.status).toBe('error'); // 不再静默记 ok
expect(run!.costUsd).toBeCloseTo(18.606643, 4); // 钱要记上
expect(run!.error).toContain('error_max_budget_usd');

const state = store.get(job.id)!.state;
expect(state.lastStatus).toBe('error');
expect(state.consecutiveErrors).toBe(1); // 触发退避
// 零轮执行要给出可诊断的提示,而不是只丢一个 subtype
expect(state.lastError).toContain('maxBudgetUsd');
});

it('keeps legacy behaviour when the executor returns nothing', async () => {
// 老实现(返回 void)不应被判为失败
executeTask = vi.fn(async () => undefined) as unknown as CronTaskExecutor;
scheduler = new CronScheduler({ store, executeTask, sendMessage });

const job = await scheduler.addJob({
name: 'void-executor',
chatId: 'chat1',
userId: 'user1',
prompt: 'work',
schedule: { kind: 'every', everyMs: 60_000 },
});
await scheduler.triggerJob(job.id);

const [run] = store.getRecentRuns(job.id, 1);
expect(run!.status).toBe('ok');
expect(run!.costUsd).toBeUndefined();
});
});
58 changes: 58 additions & 0 deletions src/cron/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';

vi.mock('../../utils/logger.js', () => ({
logger: {
Expand Down Expand Up @@ -350,6 +351,63 @@ describe('CronStore', () => {
expect(job.threadRootMessageId).toBe('msg-456');
expect(job.contextSnapshot).toBe('repo: taptap/maker, branch: main');
});

// ── max_budget_usd 死字段移除的回归 ──
//
// 该字段曾存在于 cron_jobs 表和 CronJob 类型上,但 scheduler 从未把它传给
// executor(预算实际由 agent 级配置决定),属于「设了以为生效」的死字段。
// 移除后必须保证:① 不再出现在读出的 job 上;② 老库遗留的物理列不阻塞写入。

it('should not expose maxBudgetUsd on jobs', () => {
const job = store.add({
name: 'no-budget-job',
chatId: 'chat1',
userId: 'user1',
prompt: 'budget is agent-level',
schedule: { kind: 'every', everyMs: 60_000 },
});

expect(job).not.toHaveProperty('maxBudgetUsd');
expect(store.get(job.id)).not.toHaveProperty('maxBudgetUsd');
});

it('should add and update jobs on a legacy db that still has max_budget_usd', () => {
// 模拟升级前的库:补回遗留列,并用最严格的 NOT NULL 形式
const legacyPath = join(tempDir, 'legacy-cron.db');
const seed = new CronStore(legacyPath);
seed.close();
const raw = new Database(legacyPath);
raw.exec('ALTER TABLE cron_jobs ADD COLUMN max_budget_usd REAL NOT NULL DEFAULT 5');
raw.close();

const legacy = new CronStore(legacyPath);
try {
// INSERT 省略 max_budget_usd —— 应走列默认值而非报约束错误
const job = legacy.add({
name: 'legacy-job',
chatId: 'chat1',
userId: 'user1',
prompt: 'still works',
schedule: { kind: 'every', everyMs: 60_000 },
});
expect(job.name).toBe('legacy-job');
expect(job).not.toHaveProperty('maxBudgetUsd');

// UPDATE 同样不再触碰该列
const updated = legacy.update(job.id, { name: 'legacy-job-renamed' });
expect(updated!.name).toBe('legacy-job-renamed');

// 遗留列仍在,值为默认 5,但对上层不可见
const rawCheck = new Database(legacyPath, { readonly: true });
const row = rawCheck.prepare('SELECT max_budget_usd FROM cron_jobs WHERE id = ?').get(job.id) as
| { max_budget_usd: number }
| undefined;
rawCheck.close();
expect(row!.max_budget_usd).toBe(5);
} finally {
legacy.close();
}
});
});

// ── computeNextRunAtMs ──
Expand Down
Loading
Loading