crmCRM
Nested detail page

队列租约与调度分流 深度解析

这页只看 AgentTask 如何从 API 事件变成 Eve session,以及系统怎样避免重复领取、无限重试和丢失回调。

§ 00 · IMPORT CHAIN

导入链与契约

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 任务。

apps/api/src/agent/agent-trigger.service.ts · apps/api/src/agent/agent-trigger.service.ts:146-216TypeScript
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}
§ 01 · CORE IMPL

核心实现,逐段拆

▸领取时写租约,而不是先读再抢

apps/agent/agent/lib/tasks.ts:41 使用 UPDATE...FROM 子查询,把 dueAt、leasedUntil、attempts 和 kind 过滤都放在数据库侧;apps/agent/agent/lib/tasks.ts:55 的 SKIP LOCKED 是多 dispatcher 安全的关键。

apps/agent/agent/lib/tasks.ts · apps/agent/agent/lib/tasks.ts:28-65TypeScript
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 和头像不会排在长研究后面,用户能更快看到可见字段变好。

apps/agent/agent/lib/dispatch.ts · apps/agent/agent/lib/dispatch.ts:17-137TypeScript
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。

apps/agent/agent/channels/crm.ts · apps/agent/agent/channels/crm.ts:17-29TypeScript
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}
§ 02 · KEY FLOW

关键流程

business event → AgentTriggerService.enqueue → agentTask row due now → claimDue leases with SKIP LOCKED → dispatch lane selects direct vs research → channel continuationToken returns completion/failure

流程里的每一步都有落库状态:packages/db/prisma/schema.prisma:312 保存任务,apps/agent/agent/lib/tasks.ts:110 保存 sessionId,apps/agent/agent/channels/crm.ts:73 监听 session.waiting 并 completeTask。

§ 03 · EDGE

边界 / 失败模式

  • apps/agent/agent/lib/tasks.ts:67 退休耗尽任务,避免坏任务永远循环。
  • apps/api/src/agent/agent-trigger.service.ts:195 poke 失败只 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 重新判断下一步,而不是依赖内存队列。

Generated · 2026-08-04Source · trycompai/crm← 返回 Trending 索引