【trigger.dev】Waitpoint 与 Snapshot 状态机深度解析
【trigger.dev】Waitpoint 与 Snapshot 状态机深度解析
一个跑了 3 天、中间等了 2 次人工审批的 Agent 任务,凭什么能在服务器重启 5 次之后还准确地从第 4 步继续?
答案不在”重试逻辑”里,而在 trigger.dev 把「等待」做成了数据库里一行可以被别人 UPDATE 的记录。
一、引子:Agent 工程里最贵的一行代码是 await
先看一段几乎所有人都写过的 Agent 代码:
1 | const draft = await llm.generate(prompt); // 30 秒 |
这段代码在逻辑上完美无缺,在工程上是一场灾难。因为中间那个 await 意味着:
- 一个 Node.js 进程(连着内存里的
draft、prompt、闭包、连接池)要空转 3 天。 - 这 3 天里,进程崩了、机器被回收了、Pod 被驱逐了 —— 前面 30 秒的 LLM 调用(真金白银)全部作废。
- 你不敢重启部署,因为不知道有多少个进程正卡在这一行上。
这就是 Harness Engineering 里 Workflow 组件要解决的核心矛盾:
业务逻辑想要”一条直线的代码”,物理世界只提供”随时会死的进程”。
2026-06-30 我写过 Restate 如何用 journal replay(日志重放) 解决这个问题 —— 每个副作用记到日志里,进程死了就重放日志把状态”追回来”。今天要拆的 triggerdotdev/trigger.dev(16,050⭐,Apache-2.0,2026-08-17 仍在高频提交)走的是完全不同的第二条路:
不重放,而是把「等待」这个动作本身,做成数据库里一个可被第三方写入的一等公民对象 —— Waitpoint(等待点)。
这条路线的架构后果非常深远,也是本文的主线。
二、项目定位:它到底在 Harness 矩阵的哪一格
trigger.dev 常被误解为”又一个 cron / 后台任务平台”。看它的 topics 就知道野心更大:ai-agent-framework / orchestration / mcp-server / workflow-automation。
| 维度 | 事实 |
|---|---|
| Star | 16,050 |
| 语言 | TypeScript(monorepo,7288 个文件) |
| License | Apache-2.0(真开源,可自托管) |
| 首次提交 | 2022-11-30(近 4 年积累,不是 2026 新玩具) |
| 最近提交 | 2026-08-17(日活) |
| Fork | 1,407 |
在 Harness 6 件套矩阵里,它的位置很明确:
graph TB
subgraph Harness6["🛠️ Harness 6 件套"]
R["📋 Rule<br/>团队政策"]
SK["📚 Skill<br/>SOP 流程"]
SA["🧠 Sub-Agent<br/>角色隔离"]
WF["⚙️ Workflow<br/>接力赛协议"]
SC["✅ Script<br/>硬关卡"]
MC["🔌 MCP<br/>外部桥接"]
end
subgraph TD["🚀 trigger.dev 覆盖范围"]
T1["⏸️ Waitpoint<br/>等待即对象"]
T2["📸 Snapshot<br/>状态机日志"]
T3["💾 Checkpoint<br/>进程冻结"]
T4["🔢 Fair Queue<br/>多租户公平"]
end
WF ==>|"主战场"| T1
T1 --> T2 --> T3
WF -.->|"附带能力"| T4
MC -.->|"内置 MCP Server"| TD
style R fill:#F5F5F5,stroke:#BDBDBD,color:#333
style SK fill:#F5F5F5,stroke:#BDBDBD,color:#333
style SA fill:#F5F5F5,stroke:#BDBDBD,color:#333
style WF fill:#FFB3C6,stroke:#E57391,color:#333
style SC fill:#F5F5F5,stroke:#BDBDBD,color:#333
style MC fill:#FFDAB9,stroke:#FFAB76,color:#333
style T1 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style T2 fill:#E8D5F5,stroke:#CE93D8,color:#333
style T3 fill:#B5EAD7,stroke:#80CBC4,color:#333
style T4 fill:#FFF9C4,stroke:#F9A825,color:#333价值主张一句话:让”等 3 天”和”等 3 毫秒”在业务代码里长得一模一样,而在物理层面付出的成本相差 6 个数量级。
三、核心机制一:Waitpoint —— 把「等待」变成数据库行
3.1 三种 Waitpoint,一套统一抽象
trigger.dev 的 Waitpoint 表有三种 type,覆盖了 Agent 场景下几乎所有”等”:
| type | 谁来完成它 | Agent 场景 |
|---|---|---|
DATETIME | 系统定时器到点自动完成 | 「等 24 小时后重新检查」 |
MANUAL | 外部 HTTP 调用(人 / 另一个系统) | 「等运营点『批准』」、「等 Webhook 回调」 |
RUN(隐式) | 子任务跑完自动完成 | 「等 5 个子 Agent 都返回」 |
关键洞察在这里:这三种在数据模型上是同一张表。区别只在”谁有权把 status 从 PENDING 改成 COMPLETED“。这就是”机制与策略分离”的教科书写法 —— 机制是「一个可被完成的记录 + 一条阻塞边」,策略是「谁来完成」。
3.2 阻塞的真相:一条边 + 一次计数
来看 blockRunWithWaitpoint 的真实源码(internal-packages/run-engine/src/engine/systems/waitpointSystem.ts)。这段代码里藏着一个被注释写得极其明白的并发设计:
1 | // 源码位置:internal-packages/run-engine/src/engine/systems/waitpointSystem.ts |
这段代码里最值钱的一句注释,原文如下:
The pending check is a SEPARATE store call (not folded into the edge write) on purpose: under PostgreSQL READ COMMITTED each statement gets its own snapshot, so if a concurrent
completeWaitpointcommits between the edge write and the check, this fresh query still sees the COMPLETED status.
翻译成人话:故意不把”写边”和”数 PENDING”合成一条 SQL。因为 PostgreSQL 的 READ COMMITTED 隔离级别下,每条语句各拿一次快照。如果并发的 completeWaitpoint 恰好在两条语句之间提交,第二条新语句能看到最新的 COMPLETED —— 反而不会漏。
如果为了”性能”把它们合成一条 CTE,就会退化成同一个语句快照,丢掉这次并发窗口的观测能力,导致任务永久卡死。这是典型的”看起来在优化,实际在制造幽灵 bug”。
3.3 完成侧:为什么要读主库而不读副本
completeWaitpoint 里有一段更狠的注释,是被生产事故打出来的:
1 | // 1. 先把 waitpoint 从 PENDING 改成 COMPLETED |
三个工程细节值得单独拎出来讲:
where: { id, status: "PENDING" }条件更新 —— 重复调用completeToken不会把已完成的结果覆盖掉。幂等性做在 SQL 的 WHERE 里,而不是应用层的 if 里,这是唯一真正抗并发的做法。findWaitpointOnPrimary—— 刚写完就读,读副本会因为复制延迟返回 “not found”,父任务就会永久挂死。这个 bug 在分布式系统里极其常见,且几乎无法在测试环境复现。id: continueRunIfUnblocked:${runId}去抖 —— 一个 run 同时等 100 个子任务时,100 个完成事件会入队 100 次,但因为 job id 相同,实际只执行一次。用队列的幂等 key 当去抖器,比自己写 debounce 干净得多。
3.4 完整数据流
sequenceDiagram
participant U as 👤 业务代码
participant SDK as 📦 SDK wait.forToken
participant E as ⚙️ Run Engine
participant DB as 🗄️ Waitpoint 表
participant W as 🖥️ Worker 进程
participant X as 🌐 外部系统
U->>SDK: await wait.forToken(token)
SDK->>E: POST waitpoints/tokens/{id}/wait
E->>DB: 写阻塞边 (ON CONFLICT DO NOTHING)
E->>DB: countPendingWaitpoints (独立查询)
DB-->>E: pendingCount = 1
E->>E: 新建 Snapshot EXECUTING_WITH_WAITPOINTS
E->>W: sendNotificationToWorker
W->>W: 冻结进程 → 上传 Checkpoint
W->>E: createCheckpoint
E->>E: 新建 Snapshot SUSPENDED
E->>E: releaseAllConcurrency 释放并发额度
Note over W,E: 🕒 此处可以过 3 天,零成本
X->>E: POST tokens/{id}/complete
E->>DB: UPDATE status PENDING→COMPLETED
E->>DB: findWaitpointOnPrimary 回读主库
E->>E: enqueue continueRunIfUnblocked (去抖 50ms)
E->>W: 从 Checkpoint 恢复进程
W->>U: await 返回,代码继续往下跑四、核心机制二:双状态机 —— 执行态与业务态的正交分离
这是 trigger.dev 架构里我认为最被低估的设计。
绝大多数工作流引擎只有一套状态:PENDING / RUNNING / SUCCESS / FAILED。trigger.dev 有两套完全正交的状态机。
4.1 执行状态(TaskRunExecutionStatus)—— 给基础设施看的
来自 internal-packages/run-engine/src/engine/statuses.ts,这个文件只有 1950 字节,但信息密度极高:
1 | // 源码:internal-packages/run-engine/src/engine/statuses.ts |
注意 QUEUED_EXECUTING 这个状态 —— 它同时”在队列里”又”在执行中”。听起来自相矛盾,实际是”进程还活着,但已经排队等重新调度”的中间态,用于热重试(不用等 checkpoint 完成就能重新入队)。这种状态不可能出现在只有一套状态机的设计里。
4.2 业务状态(TaskRunStatus)—— 给用户看的
1 | const finalStatuses: TaskRunStatus[] = [ |
为什么必须分开? 举个具体例子:
一个 Agent 任务因为 OOM 被杀,正在换更大的机器重试。这时候:
- 执行状态 =
QUEUED(基础设施视角:在队列里等调度) - 业务状态 =
RETRYING_AFTER_FAILURE(用户视角:还在努力,别慌)
如果只有一套状态,你必须在这两个含义里选一个牺牲掉 —— 要么用户看到”排队中”一脸茫然,要么调度器看到”重试中”不知道该不该捞。
graph LR
subgraph EXEC["⚙️ 执行状态机(基础设施视角)"]
E1["📥 RUN_CREATED"]
E2["⏳ QUEUED"]
E3["🚀 EXECUTING"]
E4["⏸️ EXECUTING_WITH_WAITPOINTS"]
E5["💾 SUSPENDED"]
E6["🔄 QUEUED_EXECUTING"]
E7["✅ FINISHED"]
end
subgraph BIZ["📊 业务状态机(用户视角)"]
B1["🎯 COMPLETED_SUCCESSFULLY"]
B2["⚠️ COMPLETED_WITH_ERRORS"]
B3["💥 CRASHED"]
B4["⏰ TIMED_OUT"]
B5["🚫 EXPIRED"]
end
E1 --> E2 --> E3
E3 -->|"遇到 wait"| E4
E4 -->|"checkpoint 成功"| E5
E5 -->|"waitpoint 完成"| E6
E6 --> E3
E3 --> E7
E7 -.->|"正交映射"| BIZ
style E1 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style E2 fill:#FFDAB9,stroke:#FFAB76,color:#333
style E3 fill:#E8D5F5,stroke:#CE93D8,color:#333
style E4 fill:#FFF9C4,stroke:#F9A825,color:#333
style E5 fill:#F5F5F5,stroke:#BDBDBD,color:#333
style E6 fill:#FFDAB9,stroke:#FFAB76,color:#333
style E7 fill:#B5EAD7,stroke:#80CBC4,color:#333
style B1 fill:#B5EAD7,stroke:#80CBC4,color:#333
style B2 fill:#FFF9C4,stroke:#F9A825,color:#333
style B3 fill:#FFB3C6,stroke:#E57391,color:#333
style B4 fill:#FFB3C6,stroke:#E57391,color:#333
style B5 fill:#F5F5F5,stroke:#BDBDBD,color:#3334.3 Execution Snapshot:append-only 的状态迁移日志
两套状态的每一次变化,都不是 UPDATE 一行,而是新增一条 TaskRunExecutionSnapshot:
1 | // 每条 snapshot 都携带完整上下文 |
这带来三个直接好处:
- 可审计 —— 每一次状态变化都有
description说明原因,UI 上直接渲染成时间线。 - 可乐观并发 —— worker 上报进度时必须带上
snapshotId,引擎校验”这是不是最新的那条”,不是就丢弃。 - 可回放调试 —— 出事故时,
previousSnapshotId链条可以完整还原”这个任务到底经历了什么”。
第 2 点的代码在 checkpointSystem.ts 里写得非常干净:
1 | // 源码:internal-packages/run-engine/src/engine/systems/checkpointSystem.ts |
注意它是”丢弃 + 发事件”,不是”抛异常”。一个迟到的 checkpoint 不是错误,是分布式系统的常态。把它当异常处理会让告警面板永远是红的。
五、核心机制三:5 秒阈值背后的成本工程
这是我在这个项目里最喜欢的一个细节,因为它把架构决策和商业模型直接焊在了一起。
看 SDK 侧 packages/trigger-sdk/src/v3/wait.ts:
1 | // 源码:packages/trigger-sdk/src/v3/wait.ts |
为什么阈值是 5 秒? 因为 checkpoint 本身有成本:
| 等待时长 | 走哪条路 | 成本构成 |
|---|---|---|
| 2 秒 | setTimeout 就地睡 | 2 秒的 CPU/内存计费,但零 checkpoint 开销 |
| 5 分钟 | Waitpoint + Checkpoint | ~1-3 秒的冻结/上传/恢复开销,但中间 5 分钟不计费 |
| 3 天 | Waitpoint + Checkpoint | 同上,节省 3 天计算费用 |
冻结进程(dump 内存 → 上传到对象存储 → 释放并发额度 → 恢复时下载 → restore)本身要花 1-3 秒。如果只等 2 秒,为了省 2 秒而花 3 秒,纯亏。
这个 DURATION_WAIT_CHARGE_THRESHOLD_MS = 5000 是一行经过成本核算的常量,不是随手写的魔法数字。这就是 Harness Engineering 里”外部物理世界必需”的典型案例 —— 模型永远学不会”什么时候挂起划算”,这必须由 harness 层硬编码。
5.1 挂起时释放并发额度
Checkpoint 成功后有一行容易被忽略但极其关键的代码:
1 | // 源码:checkpointSystem.ts |
含义:一个挂起等审批的任务,不再占用你的并发配额。
这条如果做不到,Waitpoint 就毫无意义了 —— 假设你的并发限制是 100,有 100 个任务在等人工审批,那么整个系统就彻底堵死了。“挂起”必须同时意味着”归还资源”,否则只是把内存占用换成了配额占用。
六、核心机制四:OOM 触发的机器规格升级重试
internal-packages/run-engine/src/engine/retrying.ts 里有一个我第一次见到的重试策略:
1 | // 源码:internal-packages/run-engine/src/engine/retrying.ts |
为什么这个设计对 Agent 特别重要?
Agent 任务的内存占用极难预估。同一个”总结这份文档”的任务,输入 1 页 PDF 用 200MB,输入 800 页 PDF 用 4GB。传统重试逻辑遇到 OOM 会原地重试 3 次然后失败 —— 因为机器规格没变,OOM 必然复现。
trigger.dev 的做法是:把 OOM 当成”资源判断失误”而不是”代码错误”,重试时换更大的机器。这一条能把大量”看起来是随机失败”的 Agent 任务救回来。
另外 MAX_TASK_RUN_ATTEMPTS = 250 这个数字也很有意思 —— 不是常见的 3 或 5,而是 250。这暗示它的目标场景是长时间运行的、允许大量重试的 Agent 任务,而不是”HTTP 请求失败重试 3 次”。
七、核心机制五:Fair Queue 多租户公平调度
fairQueueSelectionStrategy.ts 解决的是一个纯粹的 Harness 层问题:当一个大客户提交 10000 个任务时,小客户的 1 个任务要等多久?
1 | // 源码:internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts |
三个设计亮点:
- 两级调度 —— 先在”环境”之间公平选择,再在环境内部按队列年龄排序。大客户的 10000 个任务全在一个环境里,它们互相竞争,而不是挤占别人。
seedrandom可复现随机 —— 用seedrandom(options.seed)而不是Math.random(),意味着调度决策在测试里可以完全复现。这是把”随机算法”变成”可测试算法”的标准手法。- 默认全部偏向为 0 —— 开箱即用是”纯公平”,付费差异化需要显式配置。默认值站在最不容易出事的一侧。
八、横向对比:三种 Workflow 持久化路线
trigger.dev 走的路和另外两个主流方案有本质区别。这不是功能多少的问题,是世界观不同。
| 维度 | trigger.dev | Restate | Temporal |
|---|---|---|---|
| 核心抽象 | Waitpoint(可被外部完成的记录) | Journal(副作用日志) | Event History(事件历史) |
| 恢复方式 | 💾 进程 Checkpoint 恢复 | 🔄 代码重放 + 日志跳过 | 🔄 代码重放 + 事件回放 |
| 代码约束 | ✅ 几乎无约束,随便写 | ⚠️ 必须确定性(禁 Date.now()/Math.random()) | ⚠️ 必须确定性 + 版本化 |
| 等待人工审批 | ✅ 一等公民(MANUAL waitpoint + HTTP complete) | ⚠️ 需用 awakeable 手工搭 | ⚠️ 需用 Signal 手工搭 |
| 改代码后旧任务 | ✅ 从内存快照恢复,与代码无关 | ❌ 重放可能撞上新逻辑 | ❌ 需要 Workflow Versioning |
| 单次挂起开销 | ⚠️ 1-3 秒(内存 dump + 上传) | ✅ 毫秒级(只写日志) | ✅ 毫秒级 |
| 多租户公平调度 | ✅ 内置 Fair Queue | ❌ 不在范围内 | ⚠️ 需 Task Queue 手工规划 |
| 对 Agent 友好度 | ✅ OOM 换机器 / 250 次重试 / 内置 MCP | ⚠️ 通用引擎,不懂 LLM | ⚠️ 通用引擎,不懂 LLM |
8.1 最关键的差异:确定性约束
这一条足以决定选型。
Restate / Temporal 的重放模型要求你的代码是确定性的。这意味着:
1 | // ❌ 在 Restate / Temporal 里这是错的 |
问题在于:LLM 调用天生是非确定性的。同一个 prompt 两次调用返回不同结果。重放模型必须把每次 LLM 调用的完整输出记进日志,然后重放时”假装再调了一次”。这能work,但你得处处小心。
trigger.dev 的 checkpoint 模型完全没有这个约束。它保存的是进程内存镜像,恢复时内存里的 draft 变量就是原来那个 draft。你可以随便用 Math.random()、随便调 LLM、随便读全局变量。
代价是:挂起/恢复慢一个数量级(1-3 秒 vs 毫秒),且需要底层 checkpoint 基础设施(这也是为什么自托管 trigger.dev 比自托管 Restate 复杂得多)。
8.2 一句话总结三条路
- Restate:「我帮你把副作用记下来,你保证代码可重放」—— 适合有大量幂等步骤的确定性流程。
- Temporal:「我帮你把事件历史记下来,你按我的规矩写代码」—— 适合金融级、需要严格审计的长事务。
- trigger.dev:「你随便写,我帮你把进程冻起来」—— 适合代码不确定、要等人、要等外部系统的 Agent 场景。
对 AI Agent 来说,第三条路的心智负担明显最低。这也解释了为什么它的 topics 里明确写着 ai-agent-framework。
九、优缺点分析
9.1 架构简洁性 / 扩展性 / 易用性
| 维度 | 评价 | 依据 |
|---|---|---|
| 架构简洁性 | ⚠️ 中等 | run-engine 拆成 12 个 System(waitpoint / checkpoint / dequeue / enqueue / batch / debounce / delayedRun / ttl / runAttempt / pendingVersion / executionSnapshot / raceSimulation),职责切得很干净;但 engine/index.ts 有 103,934 字节,run-queue/index.ts 有 203,623 字节,单文件体量惊人 |
| 扩展性 | ✅ 优秀 | 17 个 eventBus 事件(runCreated / runAttemptFailed / executionSnapshotCreated / incomingCheckpointDiscarded 等)全是 typed listener;RunQueueSelectionStrategy 是可替换接口,公平策略可自己实现 |
| 易用性 | ✅ 优秀 | 业务侧只需要 await wait.forToken(token),全部复杂度隐藏在 harness 层。5 秒阈值自动分流,用户不需要知道 checkpoint 存在 |
9.2 性能 / 复杂度 / 维护性
| 维度 | 评价 | 依据 |
|---|---|---|
| 性能 | ⚠️ 有明确代价 | 单次挂起 1-3 秒;Waitpoint 输出走 chunk 分批读(WAITPOINT_CHUNK_SIZE = 100),因为源码注释记录过真实事故:24 个 snapshot × 236 个 waitpoint × 100KB = ~570MB,直接触发 “Failed to convert rust String into napi string” |
| 复杂度 | ❌ 很高 | 双状态机 + append-only snapshot + 双数据库残局(源码里大量 LEGACY / NEW residency 路由注释)+ Redis 公平队列。自托管门槛显著高于 Restate |
| 维护性 | ✅ 意外地好 | 关键并发决策全部有注释解释”为什么不能换一种写法”(READ COMMITTED 快照、必须读主库、去抖靠 job id)。这种注释密度在开源项目里非常罕见,是真正的资产 |
9.3 最诚实的一条缺点
源码里有大量这样的注释:
Co-location invariant: a DATETIME wait waitpoint lives on the same run-ops DB as the run that blocks on it… The minted waitpoint id is always a cuid, so without
coLocateWithRunIdthe upsert would always route to LEGACY and a run-ops run on NEW would hang.
这是正在进行的数据库拆分迁移留下的复杂度。LEGACY / NEW 两套 run-ops 库共存,每个查询都要考虑”路由到哪个库”、”会不会跨库导致永久挂死”。
对使用者来说这是透明的;但对想读懂源码或自托管的人来说,这是一层沉重的、纯历史包袱的认知负担。它是一个大规模生产系统真实的样子,不是教学示例。
十、从零搭建启示:我自己复刻 Waitpoint 需要什么
如果只想在自己的 Agent 项目里实现「可挂起 3 天的等待」,不需要照抄整个 trigger.dev。最小可行实现(MVP)只要 3 张表 + 2 个接口。
10.1 MVP 数据模型(PostgreSQL)
1 | -- 表 1:等待点(核心,一等公民) |
10.2 MVP 核心逻辑(Python,可直接运行)
1 | """ |
这份代码可以直接跑。它不含 checkpoint(进程冻结),因为那需要 CRIU / Firecracker 级别的基础设施 —— 但只要你的业务侧接受”重新拉起一个进程 + 从 waitpoint 输出重建上下文”,这个 MVP 已经能支撑真实的人工审批流。
10.3 哪些能省,哪些不能省
| 组件 | 能省吗 | 理由 |
|---|---|---|
| Waitpoint 表 | ❌ 绝不能省 | 整个模型的地基 |
| 阻塞边表 | ❌ 绝不能省 | 「等 5 个子任务全完成」必须靠边的计数 |
| Execution Snapshot | ⚠️ 可退化成单行 status | 但会永久失去审计和乐观并发能力,出事故时无法定位。强烈建议留着 |
| 条件 UPDATE 幂等 | ❌ 绝不能省 | 省了就等着数据被重复回调覆盖 |
| 超时兜底 job | ❌ 绝不能省 | 没有它,一个没人点的审批会挂到宇宙尽头 |
| 进程 Checkpoint | ✅ 可以省 | 改成”退出进程 + 恢复时重建上下文”,代价是业务侧要能重建 |
| Fair Queue | ✅ 可以省 | 单租户场景用普通 FIFO 就够 |
| 双状态机 | ✅ 初期可省 | 但等你开始做”OOM 换机器重试”就会被迫补回来 |
10.4 踩坑预警(照着 trigger.dev 的源码注释抄的血泪教训)
- 别把”写边”和”数 PENDING”合成一条 SQL。READ COMMITTED 下各自快照才是你的朋友。合并 = 制造永久挂死的幽灵 bug。
- 完成后回读必须打主库。读副本会因复制延迟返回 not found,父任务永久挂起。这个 bug 在测试环境基本不可能复现。
- 多 waitpoint 场景一定要给去抖 key。100 个子任务同时完成会触发 100 次”尝试继续”,用同一个 job id 让队列帮你合并。
- waitpoint 的 output 可能很大。trigger.dev 的注释记录了真实事故:24 snapshot × 236 waitpoint × 100KB ≈ 570MB,直接把 Node.js 的字符串转换打爆。必须分批读(chunk = 100),且只读最新一条 snapshot 的 waitpoints。
- 挂起时一定要释放并发配额。否则 100 个等审批的任务会把你的并发池彻底堵死 —— “挂起”就完全失去意义了。
- 迟到的 checkpoint 要丢弃而不是抛异常。分布式系统里迟到是常态,当异常处理会让告警永远是红的。发个事件记录一下就好。
十一、设计哲学:它符合 Bitter Lesson 吗
Harness Engineering 有个绕不开的拷问:你写的这些代码,会不会在下一代模型出来后全部作废?
我按四条原则给 trigger.dev 打分:
| 原则 | 评价 | 理由 |
|---|---|---|
| 极简性 | ⚠️ 6/10 | 12 个 System 划分清晰,但单文件 20 万字节 + 双库迁移残局,谈不上极简 |
| 可拆卸性 | ✅ 9/10 | RunQueueSelectionStrategy 可替换、17 个 eventBus 事件全 typed、SDK 与引擎彻底解耦 |
| 模型无关性 | ✅ 10/10 | 整个 run-engine 里没有一行 LLM 相关代码。它管的是进程、队列、状态机 |
| 面向进化 | ✅ 9/10 | append-only snapshot 让”加一个新执行状态”不需要迁移历史数据 |
关于 Bitter Lesson,我的判断是:trigger.dev 写的几乎全是”模型永远学不会的代码”。
理由很直白 —— 无论模型多聪明,它都无法:
- 让一个被 SIGKILL 的进程自己活过来;
- 决定”这次等待挂起划不划算”(需要知道 checkpoint 的真实成本);
- 在 10000 个租户之间做公平调度;
- 在 OOM 时给自己换一台更大的机器。
这些是外部物理世界的约束,不是智能问题。 这也正是 Harness 层最应该存在的地方。
反过来看,如果一个 Harness 项目里塞满了”prompt 模板优化”、”任务拆解策略”、”该调哪个工具的启发式规则” —— 那才是真正会被下一代模型碾平的代码。trigger.dev 在这方面非常克制。
graph TB
subgraph BL["🎯 Bitter Lesson 分界线"]
L1["🧠 模型会越来越擅长的<br/>任务拆解 / 工具选择 / prompt 优化"]
L2["⚙️ 模型永远学不会的<br/>进程生命周期 / 并发配额 / 公平调度"]
end
subgraph TD2["🚀 trigger.dev 实际写的代码"]
C1["⏸️ Waitpoint 状态机"]
C2["💾 Checkpoint 冻结恢复"]
C3["🔢 Fair Queue 调度"]
C4["📸 Snapshot 审计链"]
end
L2 ==>|"全部落在这一侧 ✅"| TD2
L1 -.->|"零涉及"| TD2
style L1 fill:#FFB3C6,stroke:#E57391,color:#333
style L2 fill:#B5EAD7,stroke:#80CBC4,color:#333
style C1 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C2 fill:#E8D5F5,stroke:#CE93D8,color:#333
style C3 fill:#FFF9C4,stroke:#F9A825,color:#333
style C4 fill:#FFDAB9,stroke:#FFAB76,color:#333十二、总结与行动建议
回到开头那个问题:一个跑了 3 天、等了 2 次审批的 Agent 任务,凭什么能在服务器重启 5 次后从第 4 步继续?
答案是 trigger.dev 做了一个看起来很小、影响极大的决定:
不要试图让「等待」变成一段代码的执行状态,而要让它变成数据库里一条别人能改的记录。
一旦「等待」成了一等公民,剩下的全部自然推导出来:等待可以被外部完成(MANUAL)→ 等待可以超时(DATETIME 兜底)→ 等待期间进程该冻结(Checkpoint)→ 冻结时该归还配额(releaseAllConcurrency)→ 状态变化该留痕(append-only Snapshot)。
分场景建议
| 你的情况 | 建议 |
|---|---|
| 代码非确定性重(LLM 调用满天飞)+ 要等人工 | ✅ 直接上 trigger.dev。checkpoint 模型让你不用管确定性 |
| 流程确定性强、步骤幂等、追求毫秒级挂起 | 选 Restate。journal 重放开销低一个数量级 |
| 金融级审计 + 严格版本管理 | 选 Temporal。生态和治理能力最成熟 |
| 只想在自己项目里加「等 3 天」能力 | 抄第十节的 3 张表 + 4 个函数,一天能跑通 |
| 自托管顾虑 | ⚠️ 认真评估。双库路由 + Redis 公平队列 + checkpoint 存储,运维成本远高于 Restate |
三个立刻能做的动作
- 读一遍
statuses.ts(只有 1950 字节)。它会让你重新思考自己项目里那个”一套状态混着用”的 enum 到底埋了多少雷。 - 检查你的重试逻辑有没有区分 OOM。如果 OOM 也在原地重试 3 次,那 3 次必然全挂 —— 改成换更大的机器。
- 给你的每次状态变化加一个
description字段。这是成本最低、事故时回报最高的一次改动。
最后留一个值得琢磨的问题:
trigger.dev 用
DURATION_WAIT_CHARGE_THRESHOLD_MS = 5000把”等多久才值得挂起”硬编码成了一个常量。这个决策未来会不会被模型自己学会?我的答案是不会 —— 因为它不取决于智能,取决于你的对象存储带宽和机器规格价格表。Harness 存在的意义,就是把这类”与智能无关的物理事实”从模型的肩上卸下来。
项目地址:https://github.com/triggerdotdev/trigger.dev
本文分析版本:main 分支,2026-08-17 最新提交(16,050⭐ / Apache-2.0)
核心源码路径:internal-packages/run-engine/src/engine/systems/(waitpointSystem / checkpointSystem / executionSnapshotSystem)