diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index b5de384..9ea5c80 100644 --- a/src/claude/__tests__/executor.test.ts +++ b/src/claude/__tests__/executor.test.ts @@ -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 diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 1b6db72..a609cd8 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -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 }, @@ -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', @@ -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[0], resultMessage.modelUsage) + // 例外:错误型 result(error_max_budget_usd 等)的顶层 usage 全为 0, + // 按它算出来的费用是 0,真实花费只在 total_cost_usd / modelUsage 里。 + // 这类场景恰恰最该记账(超预算),因此回退到 SDK 的累计值。 + const usageForCost = resultMessage.usage as Parameters[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 终止原因 @@ -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, diff --git a/src/claude/types.ts b/src/claude/types.ts index dceff94..a7bd61b 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -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 上下文压缩结果。 * diff --git a/src/cron/__tests__/scheduler.test.ts b/src/cron/__tests__/scheduler.test.ts index 91d9e7f..076ebc5 100644 --- a/src/cron/__tests__/scheduler.test.ts +++ b/src/cron/__tests__/scheduler.test.ts @@ -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(); + }); }); diff --git a/src/cron/__tests__/store.test.ts b/src/cron/__tests__/store.test.ts index 31d19ae..f631c0c 100644 --- a/src/cron/__tests__/store.test.ts +++ b/src/cron/__tests__/store.test.ts @@ -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: { @@ -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 ── diff --git a/src/cron/scheduler.ts b/src/cron/scheduler.ts index 5f2da0e..51fda2f 100644 --- a/src/cron/scheduler.ts +++ b/src/cron/scheduler.ts @@ -2,6 +2,7 @@ import { logger } from '../utils/logger.js'; import { CronStore, computeNextRunAtMs } from './store.js'; import { shouldSkip } from './holidays/index.js'; import type { CronJob, CronJobCreate, CronJobPatch } from './types.js'; +import type { TaskOutcome } from '../claude/types.js'; /** 错误退避时间表 */ const BACKOFF_MS = [ @@ -12,7 +13,13 @@ const BACKOFF_MS = [ 60 * 60_000, // 5th+ → 1h ]; -/** 执行 cron job 的回调类型 (由 event-handler 提供) */ +/** + * 执行 cron job 的回调类型 (由 event-handler 提供)。 + * + * 返回 TaskOutcome 才能让 scheduler 区分「真的跑完了」和「SDK 直接拒绝了」—— + * 后者不抛异常,若只看异常就会把超预算的零轮执行记成 ok。 + * 返回 undefined 表示无法判定,此时按旧行为(不抛错即成功)处理。 + */ export type CronTaskExecutor = (params: { prompt: string; chatId: string; @@ -22,7 +29,21 @@ export type CronTaskExecutor = (params: { threadId?: string; agentId: string; accountId: string; -}) => Promise; +}) => Promise; + +/** + * 执行器报告 success=false 时抛出,把 outcome 带进统一的失败处理分支 + * (退避重试、写 run.error、更新 job.state),避免复制一遍那段逻辑。 + */ +class CronTaskFailure extends Error { + constructor(readonly outcome: TaskOutcome) { + const turnHint = outcome.numTurns === 0 || outcome.numTurns === 1 + ? '(几乎零轮执行,通常是 SDK 直接拒绝了请求,例如会话累计花费已超 maxBudgetUsd)' + : ''; + super((outcome.error || 'Task reported failure without an error message') + turnHint); + this.name = 'CronTaskFailure'; + } +} /** 发占位消息的回调类型 */ export type CronMessageSender = (chatId: string, text: string, rootId?: string, accountId?: string) => Promise; @@ -187,7 +208,7 @@ export class CronScheduler { const prompt = `${contextPrefix}[⏰ 定时任务: ${job.name}]\n\n${job.prompt}`; // 3. 注入现有流程 —— 和用户 @bot 完全一样 - await this.deps.executeTask({ + const outcome = await this.deps.executeTask({ prompt, chatId: job.chatId, userId: job.userId, @@ -198,12 +219,20 @@ export class CronScheduler { accountId: job.accountId, }); - // 4. 成功 + // 4. 判定成败 —— 不能只看有没有抛异常。 + // SDK 因超预算等原因拒绝执行时不抛异常,只在 result 里给 success=false + // (实测:numTurns=1、零 token、18 秒返回,一个 turn 都没跑)。 + // 旧逻辑把这种情况记成 ok,导致任务连续多天空跑而记录显示正常。 + if (outcome && outcome.success === false) { + throw new CronTaskFailure(outcome); + } + const endMs = Date.now(); this.deps.store.updateRun(runId, { status: 'ok', endedAtMs: endMs, durationMs: endMs - startMs, + costUsd: outcome?.costUsd, }); const nextRunAtMs = computeNextRunAtMs(job.schedule, endMs); @@ -223,18 +252,24 @@ export class CronScheduler { } logger.info( - { jobId: job.id, jobName: job.name, durationMs: endMs - startMs, nextRunAtMs }, + { + jobId: job.id, jobName: job.name, durationMs: endMs - startMs, nextRunAtMs, + costUsd: outcome?.costUsd, numTurns: outcome?.numTurns, + }, 'cron: job completed', ); } catch (err) { const endMs = Date.now(); const errorStr = err instanceof Error ? err.message : String(err); + // 执行器报告的失败仍然花了钱(超预算尤其如此),必须记账 + const failureOutcome = err instanceof CronTaskFailure ? err.outcome : undefined; this.deps.store.updateRun(runId, { status: 'error', endedAtMs: endMs, error: errorStr, durationMs: endMs - startMs, + costUsd: failureOutcome?.costUsd, }); const consecutiveErrors = job.state.consecutiveErrors + 1; @@ -258,7 +293,10 @@ export class CronScheduler { } logger.error( - { jobId: job.id, jobName: job.name, err: errorStr, consecutiveErrors, nextBackoffMs: backoffMs }, + { + jobId: job.id, jobName: job.name, err: errorStr, consecutiveErrors, nextBackoffMs: backoffMs, + costUsd: failureOutcome?.costUsd, numTurns: failureOutcome?.numTurns, + }, 'cron: job failed', ); } diff --git a/src/cron/store.ts b/src/cron/store.ts index c65520c..7b36b47 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -46,7 +46,6 @@ interface CronJobRow { consecutive_errors: number; timeout_seconds: number; model: string | null; - max_budget_usd: number; agent_id: string; account_id: string; thread_id: string | null; @@ -93,7 +92,6 @@ function rowToJob(row: CronJobRow): CronJob { skipWeekends: !!row.skip_weekends, timeoutSeconds: row.timeout_seconds, model: row.model ?? undefined, - maxBudgetUsd: row.max_budget_usd, agentId: row.agent_id, accountId: row.account_id, threadId: row.thread_id ?? undefined, @@ -226,7 +224,6 @@ export class CronStore { consecutive_errors INTEGER DEFAULT 0, timeout_seconds INTEGER DEFAULT 300, model TEXT, - max_budget_usd REAL DEFAULT 5, agent_id TEXT DEFAULT 'dev', thread_id TEXT, thread_root_message_id TEXT, @@ -243,6 +240,10 @@ export class CronStore { // Column already exists — ignore } + // 老库遗留 max_budget_usd 列(REAL DEFAULT 5,可空)不再读写: + // 该字段从未被 scheduler 传给 executor,预算统一由 agent 级配置决定。 + // 保留物理列(不 DROP)以避免对运行中库做不可逆改动;INSERT 不指定它会走默认值。 + // Migration: add skip_holidays / skip_weekends columns try { this.db.exec(`ALTER TABLE cron_jobs ADD COLUMN skip_holidays INTEGER NOT NULL DEFAULT 0`); @@ -290,14 +291,14 @@ export class CronStore { id, name, chat_id, user_id, prompt, working_dir, repo_url, schedule_kind, schedule_expr, schedule_tz, every_ms, at_time, enabled, delete_after_run, skip_holidays, skip_weekends, next_run_at_ms, - timeout_seconds, model, max_budget_usd, agent_id, account_id, + timeout_seconds, model, agent_id, account_id, thread_id, thread_root_message_id, context_snapshot, created_at, updated_at ) VALUES ( @id, @name, @chat_id, @user_id, @prompt, @working_dir, @repo_url, @schedule_kind, @schedule_expr, @schedule_tz, @every_ms, @at_time, @enabled, @delete_after_run, @skip_holidays, @skip_weekends, @next_run_at_ms, - @timeout_seconds, @model, @max_budget_usd, @agent_id, @account_id, + @timeout_seconds, @model, @agent_id, @account_id, @thread_id, @thread_root_message_id, @context_snapshot, @created_at, @updated_at ) @@ -343,7 +344,6 @@ export class CronStore { skip_weekends = @skip_weekends, timeout_seconds = @timeout_seconds, model = @model, - max_budget_usd = @max_budget_usd, next_run_at_ms = @next_run_at_ms, thread_id = @thread_id, thread_root_message_id = @thread_root_message_id, @@ -417,7 +417,6 @@ export class CronStore { next_run_at_ms: nextRunAtMs ?? null, timeout_seconds: input.timeoutSeconds ?? 300, model: input.model ?? null, - max_budget_usd: input.maxBudgetUsd ?? 5, agent_id: input.agentId ?? 'dev', account_id: input.accountId ?? 'default', thread_id: input.threadId ?? null, @@ -468,7 +467,6 @@ export class CronStore { skip_weekends: (patch.skipWeekends ?? existing.skipWeekends) ? 1 : 0, timeout_seconds: patch.timeoutSeconds ?? existing.timeoutSeconds, model: patch.model !== undefined ? (patch.model ?? null) : (existing.model ?? null), - max_budget_usd: patch.maxBudgetUsd ?? existing.maxBudgetUsd, next_run_at_ms: nextRunAtMs ?? null, thread_id: patch.threadId !== undefined ? (patch.threadId ?? null) : (existing.threadId ?? null), thread_root_message_id: patch.threadRootMessageId !== undefined diff --git a/src/cron/types.ts b/src/cron/types.ts index c588173..3ca0fca 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -44,9 +44,13 @@ export interface CronJob { skipWeekends: boolean; // 执行配置 + // + // 注意:预算(maxBudgetUsd)不在此处配置 —— 定时任务复用 agent 级配置 + // (config/agents.json 的 maxBudgetUsd,见 agent/config-loader.ts)。 + // 历史上这里有过 maxBudgetUsd 字段,但 scheduler 从未把它传给 executor, + // 属于「设了以为生效」的死字段,已移除。 timeoutSeconds: number; model?: string; - maxBudgetUsd: number; agentId: string; /** 飞书 bot 账号标识(多 bot 模式下用于路由到正确的 feishu client) */ accountId: string; @@ -81,7 +85,6 @@ export interface CronJobCreate { timeoutSeconds?: number; model?: string; - maxBudgetUsd?: number; agentId?: string; accountId?: string; @@ -100,7 +103,6 @@ export interface CronJobPatch { skipWeekends?: boolean; timeoutSeconds?: number; model?: string; - maxBudgetUsd?: number; threadId?: string | null; threadRootMessageId?: string | null; contextSnapshot?: string | null; diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 9308211..a58dcf7 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -7,7 +7,7 @@ import { forkSession } from '../session/fork.js'; import { taskQueue } from '../session/queue.js'; import { claudeExecutor } from '../claude/executor.js'; import { DEFAULT_IMAGE_PROMPT, DEFAULT_DOCUMENT_PROMPT } from '../claude/types.js'; -import type { TurnInfo, ToolCallInfo, ImageAttachment, DocumentAttachment, ConversationTurn, CompactResult } from '../claude/types.js'; +import type { TurnInfo, ToolCallInfo, ImageAttachment, DocumentAttachment, ConversationTurn, CompactResult, TaskOutcome } from '../claude/types.js'; import { buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildCombinedProgressCard, buildAskUserQuestionCard, buildAskUserAnsweredCard } from './message-builder.js'; import type { AskUserQuestionItem } from './message-builder.js'; import { TOTAL_PHASES } from '../pipeline/types.js'; @@ -2617,7 +2617,7 @@ export async function executeClaudeTask( createTime?: string, messageType?: string, currentImagePaths?: string[], -): Promise { +): Promise { // 1. 解析话题上下文(thread + workingDir + greeting) const resolved = await resolveThreadContext({ prompt: rawPrompt, @@ -3115,7 +3115,12 @@ export async function executeClaudeTask( repository: resolveRepositoryForCwd(result.newWorkingDir!), }).catch((err) => logger.warn({ err }, 'Memory extraction failed')); } - return; + return { + success: restartResult.success, + costUsd: totalCostUsd, + error: restartResult.error, + numTurns: restartResult.numTurns, + }; } // Resume 失败(非 workspace 变更场景):报错给用户,保留 session ID 不动 @@ -3146,7 +3151,12 @@ export async function executeClaudeTask( } else { await feishuClient.replyText(messageId, errorDetail); } - return; + return { + success: false, + costUsd: result.costUsd, + error: result.error || 'Resume failed', + numTurns: result.numTurns, + }; } // 无 restart,正常流程:保存 session ID 用于下次 resume @@ -3189,6 +3199,12 @@ export async function executeClaudeTask( }).catch((err) => logger.warn({ err }, 'Memory extraction failed')); } + return { + success: result.success, + costUsd: result.costUsd, + error: result.error, + numTurns: result.numTurns, + }; } catch (err) { logger.error({ err }, 'Error executing Claude Agent SDK query'); // 合并卡片切换为失败态(best-effort,含 pendingTurn 内容) @@ -3213,6 +3229,7 @@ export async function executeClaudeTask( await feishuClient.replyText(messageId, errorReply); } } + return { success: false, error: (err as Error).message }; } finally { try { sessionManager.setStatus(chatId, userId, 'idle', agentId); @@ -3257,7 +3274,7 @@ export async function executeDirectTask( createTime?: string, options?: { skipQuickAck?: boolean; forceThread?: boolean }, messageType?: string, -): Promise { +): Promise { const agentCfg = agentRegistry.getOrThrow(agentId); const session = sessionManager.getOrCreate(chatId, userId, agentId); const workingDir = config.claude.defaultWorkDir; @@ -3518,6 +3535,12 @@ export async function executeDirectTask( }).catch((err) => logger.warn({ err }, 'Memory extraction failed')); } + return { + success: result.success, + costUsd: result.costUsd, + error: result.error, + numTurns: result.numTurns, + }; } catch (err) { logger.error({ err }, 'Error in executeDirectTask'); const errorReply = `❌ 执行出错: ${(err as Error).message}`; @@ -3526,6 +3549,7 @@ export async function executeDirectTask( } else { await feishuClient.replyText(messageId, errorReply); } + return { success: false, error: (err as Error).message }; } finally { // 移除话题内的待处理表情回复(无论成功/失败都要清理) if (pendingReactionId) { diff --git a/src/index.ts b/src/index.ts index 7d48bd0..5344e5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,14 +143,15 @@ async function main(): Promise { // 初始化定时任务调度器 if (config.cron.enabled) { await initializeCron({ + // 返回 TaskOutcome 让 scheduler 能记账并识别「SDK 直接拒绝」这类不抛异常的失败 executeTask: async (params) => { // 用 runWithAccountId 包裹,确保下游 feishuClient 调用路由到正确的 bot 账号 - await runWithAccountId(params.accountId, async () => { + return runWithAccountId(params.accountId, async () => { const agentCfg = agentRegistry.get(params.agentId as AgentId); const useDirectMode = agentCfg?.replyMode === 'direct'; if (useDirectMode) { - await executeDirectTask( + return executeDirectTask( params.prompt, params.chatId, params.userId, @@ -164,7 +165,7 @@ async function main(): Promise { { skipQuickAck: true }, ); } else { - await executeClaudeTask( + return executeClaudeTask( params.prompt, params.chatId, params.userId,