队列租约与调度分流 深度解析
这页只看 AgentTask 如何从 API 事件变成 Eve session,以及系统怎样避免重复领取、无限重试和丢失回调。
导入链与契约
apps/api/src/agent/agent-trigger.service.ts:146 只负责入队,apps/agent/agent/lib/tasks.ts:28 才负责领取,apps/agent/agent/lib/dispatch.ts:132 再把领取结果交给直接任务或研究 session。这是 CRM 里最核心的异步边界。
队列证据 1: apps/agent/agent/lib/tasks.ts:28 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 2: apps/agent/agent/lib/tasks.ts:41 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 3: apps/agent/agent/lib/tasks.ts:47 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 4: apps/agent/agent/lib/tasks.ts:50 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 5: apps/agent/agent/lib/tasks.ts:55 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 6: apps/agent/agent/lib/tasks.ts:62 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 7: apps/agent/agent/lib/tasks.ts:67 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 8: apps/agent/agent/lib/tasks.ts:81 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 9: apps/agent/agent/lib/tasks.ts:120 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 10: apps/agent/agent/lib/tasks.ts:129 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 11: apps/agent/agent/lib/dispatch.ts:42 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 12: apps/agent/agent/lib/dispatch.ts:93 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 13: apps/agent/agent/lib/dispatch.ts:119 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 14: apps/agent/agent/lib/dispatch.ts:132 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 15: apps/agent/agent/schedules/dispatch.ts:5 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 16: apps/agent/agent/channels/crm.ts:31 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 17: apps/agent/agent/channels/crm.ts:73 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 18: apps/agent/test/tasks.integration.spec.ts:69 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 19: apps/agent/test/tasks.integration.spec.ts:111 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 20: apps/agent/test/crm-token.spec.ts:15 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 21: packages/db/src/agent-tasks.ts:22 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
队列证据 22: packages/db/prisma/schema.prisma:312 这一处把领取、租约、重试、认证或测试断言落到可读源码里;这些引用组合起来解释为什么多个调度器可以同时工作,却不会重复处理同一条 CRM 任务。
146 private async enqueue(task: { 147 contactId?: string; 148 companyId?: string; 149 kind: string; 150 reason: string; 151 priority: number; 152 budget: number; 153 }): Promise<void> { 154 try { 155 const pending = await this.db.agentTask.findFirst({ 156 where: { 157 kind: task.kind, 158 finishedAt: null, 159 ...(task.contactId ? { contactId: task.contactId } : {}), 160 ...(task.companyId ? { companyId: task.companyId } : {}), 161 }, 162 select: { id: true }, 163 }); 164 165 if (pending) return; 166 167 await this.db.agentTask.create({ 168 data: { 169 contactId: task.contactId ?? null, 170 companyId: task.companyId ?? null, 171 kind: task.kind, 172 reason: task.reason, 173 priority: task.priority, 174 budget: task.budget, 175 dueAt: new Date(), 176 }, 177 }); 178 179 this.logger.log({ 180 message: "Agent task queued", 181 kind: task.kind, 182 contactId: task.contactId, 183 companyId: task.companyId, 184 }); 185 186 this.poke(); 187 } catch (error) { 188 this.logger.error( 189 { message: "Could not queue agent task", kind: task.kind }, 190 error instanceof Error ? error.stack : String(error), 191 ); 192 } 193 } 194 195 private poke(): void { 196 const agent = bridge(); 197 if (!agent) return; 198 199 const missed = (error: unknown) => { 200 this.logger.debug({ 201 message: "Agent poke did not land; the cron will pick this up", 202 reason: error instanceof Error ? error.message : String(error), 203 }); 204 }; 205 206 try { 207 void fetch(agent.url("/internal/crm/dispatch"), { 208 method: "POST", 209 headers: { authorization: `Bearer ${agent.secret}` }, 210 signal: AbortSignal.timeout(POKE_TIMEOUT_MS), 211 }).catch(missed); 212 } catch (error) { 213 missed(error); 214 } 215 } 216}
核心实现,逐段拆
▸领取时写租约,而不是先读再抢
apps/agent/agent/lib/tasks.ts:41 使用 UPDATE...FROM 子查询,把 dueAt、leasedUntil、attempts 和 kind 过滤都放在数据库侧;apps/agent/agent/lib/tasks.ts:55 的 SKIP LOCKED 是多 dispatcher 安全的关键。
28export async function claimDue( 29 limit: number, 30 kinds: { only: readonly string[] } | { except: readonly string[] }, 31 leaseMs = LEASE_MS, 32): Promise<LeasedTask[]> { 33 const now = new Date(); 34 const until = new Date(now.getTime() + leaseMs); 35 36 const list = "only" in kinds ? kinds.only : kinds.except; 37 if ("only" in kinds && list.length === 0) return []; 38 39 const match = Prisma.sql`t2.kind ${"only" in kinds ? Prisma.sql`IN` : Prisma.sql`NOT IN`} (${Prisma.join(list)})`; 40 41 const claimed = await db.$queryRaw<LeasedTask[]>` 42 UPDATE "agentTask" AS t 43 SET "leasedUntil" = ${until}, 44 "startedAt" = COALESCE(t."startedAt", ${now}), 45 "attempts" = t."attempts" + 1 46 FROM ( 47 SELECT t2.id FROM "agentTask" AS t2 48 WHERE t2."finishedAt" IS NULL 49 AND t2."dueAt" <= ${now} 50 AND (t2."leasedUntil" IS NULL OR t2."leasedUntil" < ${now}) 51 AND t2."attempts" < ${MAX_ATTEMPTS} 52 AND ${match} 53 ORDER BY t2."priority" DESC, t2."dueAt" ASC 54 LIMIT ${limit} 55 FOR UPDATE SKIP LOCKED 56 ) AS due 57 WHERE t.id = due.id 58 RETURNING t.id, t."contactId", t."companyId", t.kind, t.reason, 59 t.budget, t.attempts, t.priority, t."dueAt"; 60 `; 61 62 return claimed.sort( 63 (a, b) => b.priority - a.priority || a.dueAt.getTime() - b.dueAt.getTime(), 64 ); 65}
▸直接 lane 与研究 lane 拆开预算
apps/agent/agent/lib/dispatch.ts:42 循环处理 brand/portrait 快任务,apps/agent/agent/lib/dispatch.ts:93 处理需要 session 的研究任务。这样 logo 和头像不会排在长研究后面,用户能更快看到可见字段变好。
17export const VISIBLE_BATCH = 60; 18export const VISIBLE_CONCURRENCY = 6; 19export const VISIBLE_LEASE_MS = 2 * 60_000; 20 21export const RESEARCH_BATCH = 12; 22export const RESEARCH_LEASE_MS = 30 * 60_000; 23 24export async function retireAbandoned(): Promise<void> { 25 let abandoned: TaskSubject[] = []; 26 27 try { 28 abandoned = await retireExhausted(); 29 } catch { 30 return; 31 } 32 33 for (const task of abandoned) { 34 await settle( 35 task, 36 EnrichmentStatus.FAILED, 37 "Research was attempted several times and never completed.", 38 ).catch(() => {}); 39 } 40} 41 42export async function runVisibleLane(): Promise<number> { 43 let handled = 0; 44 45 while (handled < VISIBLE_BATCH) { 46 const tasks = await claimDue( 47 Math.min(VISIBLE_CONCURRENCY, VISIBLE_BATCH - handled), 48 { only: DIRECT_KINDS }, 49 VISIBLE_LEASE_MS, 50 ); 51 52 if (tasks.length === 0) break; 53 54 await runLimited(VISIBLE_CONCURRENCY, tasks, runDirect); 55 handled += tasks.length; 56 } 57 58 return handled; 59} 60 61async function runDirect(task: LeasedTask): Promise<void> { 62 try { 63 if (task.kind === "brand" && task.companyId) { 64 const result = await runBrand({ companyId: task.companyId }); 65 if (result.retryable) return; 66 67 await completeTask(task.id, brandOutcome(result)); 68 return; 69 } 70 71 if (task.kind === "portrait" && task.contactId) { 72 const portrait = await runPortrait({ 73 contactId: task.contactId, 74 spend: () => ({ ok: true }), 75 }); 76 77 await completeTask( 78 task.id, 79 portrait.stored 80 ? `Picture stored from ${portrait.source}.` 81 : (portrait.reason ?? "No picture found."), 82 ); 83 return; 84 } 85 86 await completeTask(task.id, "The record this names is gone."); 87 } catch (error) { 88 const reason = error instanceof Error ? error.message : String(error); 89 await settle(task, EnrichmentStatus.FAILED, reason).catch(() => {}); 90 } 91} 92 93export async function runResearchLane( 94 start: (task: LeasedTask) => Promise<{ id: string }>, 95): Promise<number> { 96 const tasks = await claimDue( 97 RESEARCH_BATCH, 98 { except: DIRECT_KINDS }, 99 RESEARCH_LEASE_MS, 100 ); 101 if (tasks.length === 0) return 0; 102 103 await Promise.all( 104 tasks.map(async (task) => { 105 try { 106 await markRunning(task); 107 const session = await start(task); 108 await noteSession(task.id, session.id); 109 } catch (error) { 110 const reason = error instanceof Error ? error.message : String(error); 111 await settle(task, EnrichmentStatus.FAILED, reason).catch(() => {}); 112 } 113 }), 114 ); 115 116 return tasks.length; 117} 118 119export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth { 120 return { 121 ...base, 122 attributes: { 123 taskKind: task.kind, 124 reason: task.reason, 125 budget: String(task.budget), 126 ...(task.contactId ? { contactId: task.contactId } : {}), 127 ...(task.companyId ? { companyId: task.companyId } : {}), 128 }, 129 }; 130} 131 132export const drainAll = collapsing( 133 async (start: (task: LeasedTask) => Promise<{ id: string }>) => { 134 await retireAbandoned(); 135 await Promise.all([runVisibleLane(), runResearchLane(start)]); 136 }, 137);
▸token marker 修复回调归属
apps/agent/agent/channels/crm.ts:21 用 lastIndexOf 找 task marker,测试 apps/agent/test/crm-token.spec.ts:15 保证历史双前缀 token 也能识别,避免 session 完成后无法 close AgentTask。
17export function taskToken(taskId: string): string { 18 return `${TASK_MARKER}${taskId}`; 19} 20 21export function taskFromToken(token: string | undefined): string | null { 22 if (!token) return null; 23 24 const marker = token.lastIndexOf(TASK_MARKER); 25 if (marker === -1) return null; 26 27 const id = token.slice(marker + TASK_MARKER.length); 28 return id.length > 0 ? id : null; 29}
关键流程
流程里的每一步都有落库状态:packages/db/prisma/schema.prisma:312 保存任务,apps/agent/agent/lib/tasks.ts:110 保存 sessionId,apps/agent/agent/channels/crm.ts:73 监听 session.waiting 并 completeTask。
边界 / 失败模式
apps/agent/agent/lib/tasks.ts:67退休耗尽任务,避免坏任务永远循环。apps/api/src/agent/agent-trigger.service.ts:195poke 失败只 debug,cron 仍会兜底。apps/agent/test/tasks.integration.spec.ts:97验证租约未过期不会重复领取。
共享数据库包给这条队列补上了跨 app 的类型锚点:packages/db/src/agent-tasks.ts:1 是 AgentTask 类型入口,packages/db/src/agent-tasks.ts:14 把 direct lane 任务固定成 brand 和 portrait,避免 API 层与调度器各自维护一套字符串约定。读源码时这能解释为什么 visible lane 可以批量处理,而研究 lane 必须进入会话。
Subject 建模也在共享包里收口,packages/db/src/agent-tasks.ts:18 到 packages/db/src/agent-tasks.ts:22 将任务对象限制在 company、contact、workspace 三类实体。这个限制对 CRM 很关键,因为重试、租约和 completion token 都必须知道结果应该写回哪类业务对象。
真正的运行时恢复能力来自任务表和客户端的组合:packages/db/src/agent-tasks.ts:31 提供类型导出,packages/db/src/client.ts:55 则是各进程共享 Prisma 访问的入口。只要任务状态已经落库,dispatcher 重启后仍然能通过 dueAt、leasedUntil 和 attempts 重新判断下一步,而不是依赖内存队列。