---
url: /docs/agentbuff-stack/task-lifecycle.md
---
# 任务生命周期

## 当前状态

I4 本地任务闭环已验收。任务记录、幂等提交、待投递恢复、执行租约、取消与有限重试已实现，并通过实际 PostgreSQL 并发验收。业务队列消费者、结果发布和定时恢复已接入同一生产 Worker 入口，真正 workerd + 本地 Queue / R2 + 临时 PostgreSQL 的接口、下载与重启验收通过。个人任务 API、列表与详情工作台已接入主预览，浏览器实际完成文本任务、结果预览 / 下载和刷新恢复。死信消费 / 账号恢复、执行预算耗尽及两份干净新站的任务闭环已通过本地验收。官网仍区分本地能力与云端交付；上述验收不代表已部署云端。

现有 JSON / 文本客户端工具继续同步运行且免费。首个队列处理器将采用确定性的 UTF-8 文本规范化，不调用外部模型，不收积分。I5 已接入可配置报价与事务预扣 / 结算，见[收费教程](./billing-tasks)；外部供应商对账归实际供应商接入时验证。

## 数据与服务边界

I5 增量迁移 `0007_task_charges.sql` 保存任务价格 / 收费状态与账本任务关联，新代码运行前需应用。

增量迁移 `0004_task_lifecycle.sql` 增加三个表，`0005_task_results.sql` 增加执行结果引用与允许零字节结果的文件约束，保留此前迁移与已有数据。更新后的文件删除 / 清理、队列与定时维护会查询这些结构，所以部署新代码前必须依次应用迁移；不要只更新 Worker。自动验收仅迁移脚本创建的临时数据库；本轮另更新已授权的任务自有 loopback 主预览库并重启预览，原三份验收文件保留，真实浏览器任务另新增一份结果。生成站与远程数据库本轮未迁移。

| 表 | 用途 |
| --- | --- |
| `task` | 个人所有者、请求键和指纹、处理器 / 版本、输入快照、参数、状态、次数、执行 / 投递租约、结果引用与稳定错误码 |
| `task_attempt` | 每次领取版本的执行记录与该次预留结果 ID；`task_id + lease_version` 唯一 |
| `task_input_pin` | 活跃任务保留的输入文件；终态释放，历史任务仍保留输入 ID / 名称 / 摘要 |

历史输入 / 结果 ID 没有直接文件外键，避免文件到期删除顺带丢失任务记录。活跃引用有文件外键，账号删除仍遵守当前个人数据级联规则；任务记录不是财务审计账本。

代码按实际职责分开：`api/lib/tasks/store.ts` 负责用户请求与所有权；`delivery.ts` 负责投递；`lifecycle.ts` 负责领取、失败与失联恢复；`consumer.ts` 负责确定性处理与消息结算；`results.ts` 负责结果预留、发布与补偿。首版只接受个人账号和一个现有 `text/plain` 文件，参数严格校验，固定处理器 `text-normalize` 版本 1；没有任意脚本或 URL 执行器。

## 幂等与并发

`user_id + request_key` 唯一。请求键为客户端生成的 UUID，同一次提交重试沿用原键；同键与同输入返回已有任务，同键换输入 / 参数报冲突。终态和原文件删除后，同一请求仍返回原任务，不重新执行。

每账号默认最多五个活跃任务，由 `tasks.maxActive` 控制。创建与手动重试锁定账号行，再核对活跃计数；次数和文件输入不能通过并发请求绕过。单账号事务串行的实际吞吐 / 云端负载还未测试，后续依据负载证据调整，不先添加另一套限流存储。

创建任务和保留输入在同一数据库事务内。提交与删除都先锁定同一文件行，再检查最新状态或引用。删除先赢时文件转为 `deleting`，提交拒绝；提交先赢时引用提交，删除返回 `FILE_IN_USE`。过期清理跳过引用中的文件，终态释放后再正常清理。保留输入不延长用户下载权限；过期文件仍不能由用户直接下载。

## 投递与领取

队列只发送 `{ taskId, dispatchVersion }`，不携带文件内容或报价。任务先持久化为 `pending`，投递采用 30 秒的独立租约；发送失败保留记录，30 秒后可补投。成功投递后记录 `queued` 与时间；两分钟仍未领取的队列记录可以再次投递。维护单批最多检查 20 条。

迟到发送端只更新仍持有自己投递 token 的记录。消费者领取会清除该 token；取消也会清除未执行任务的 token。因此消息已开始执行甚至到达终态后，旧发送端不能把它覆盖成 `queued`。发送失败与业务执行失败分别记录，失败投递不消耗执行次数。

Cloudflare Queues 默认为至少投递一次，可能重复，结果去重由任务服务承担。[官方投递保证](https://developers.cloudflare.com/queues/reference/delivery-guarantees/)（核对：2026-10-10）。`tasks:validate` 已通过实际本地 Queue 投递给真正业务 Worker；`tasks:validate-store` 的受控发送夹具仍仅用于数据库竞态。两者都不证明云端运行。

领取原子更新状态、执行次数与递增版本，并在同一事务插入尝试记录。两分钟执行租约到期后旧版本不得写回，重复消息不能再领取 `running` 或终态任务。数据库约束要求只有 `running` 有执行租约，只有 `succeeded` 有结果引用。

## 重试与取消

单任务总执行预算默认三次，由 `tasks.maxAttempts` 在创建时保存；改配置不增加已有任务的预算。自动和手动尝试共用，不因刷新或手动重试清零。只有已分类的 `STORAGE_UNAVAILABLE` 自动重试，按尝试次数延迟 30 / 60 秒；输入缺失、处理器不可用、存储配额不足或结果过大先失败。手动重试仅允许明确的存储、租约或处理器故障，包含腾出空间后的 `STORAGE_FULL`，重新核对输入有效期 / 摘要、活跃名额和剩余次数。

`pending`、`queued`、`retry_wait` 的取消立即终止并释放输入；`running` 的取消先记录请求，保留输入直到执行器结算或租约恢复。终态重复取消返回原状态，不重新释放。收费任务的取消与返还在同一事务结算；可重试失败仍保留预扣，本人可放弃并返还。

失联恢复只自动重试已知的确定性文本处理器。未知处理器进入 `reconciling`，保留引用并停止自动投递，取消也仅记录请求。这个状态边界不等于已实现供应商查询 / 对账；必须等真实外部服务接入后按其能力交付。

## 执行与结果发布

文本处理器读取活跃引用中的输入，核对所有者、文件状态、大小、元数据摘要和实际字节的 SHA-256，严格解码 UTF-8。它把 CRLF / CR 换行改为 LF，按参数移除行末空格 / 制表符，不添加额外换行。已经受理并保留的输入到期后仍可完成任务，但用户下载权限不延长。

输出复用文件模块的配额预留、私有对象路径、到期策略和下载权限。先在当前租约的事务中预留 `uploading` 文件，关联该次尝试；该结果不出现在文件列表，也不可下载。对象写入成功后，在同一数据库事务标记文件 `ready` 与任务 `succeeded`，结束尝试并释放输入引用。空白文本可转换成零字节结果并正常下载；上传入口仍拒绝空文件。

发布检查当前执行版本、两分钟租约、该次结果引用、所有者、文件状态与有效期。发布时发现取消请求则结算 `canceled` 并丢弃输出；旧执行器不能覆盖新结果。对象写入失败只补偿自己的未发布文件，不删除 `ready` 结果：数据库提交成功但响应丢失时，消息会重试，已提交的结果必须保留。

读取输入对象、读取对象字节和写入结果对象分别有 15 秒等待上限。R2 API 没有这里可用的中止接口，超时不保证底层写入立即停止；迟到写入不能通过过期版本发布，若落成无记录对象，沿用文件模块带一小时宽限的孤立对象清理。删除失败保留文件记录和配额，待定时维护重试。此处没有承诺立即清除迟到对象，也没有新增第二套配额。

定时维护依次恢复过期执行、返还输入失效的失败任务预扣、清理非运行尝试的未发布结果、清理文件、补投到期任务；每批有上限。本地不会自动触发 Cron，验收显式调用真正 Worker 的 scheduled 入口。已分类业务失败按数据库状态重试；未知基础设施错误要求消息延迟十秒重投，原始供应商响应不写入任务记录 / 日志。

### 死信与恢复

业务队列最多追加三次基础设施重投，耗尽后由独立死信消费者处理。`TASK_DEAD_QUEUE_NAME` 必须与 Wrangler 的死信消费者名字一致，本地运行时从配置派生；生成新站时，目标队列、死信消费者与该变量一起重新命名。官方要求为死信配置独立消费者，见[死信队列文档](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/)（核对：2026-10-10）。

迁移 `0006_task_dead_letters.sql` 增加投递版本与 `task_dead_letter` 记录。每次投递、手动恢复或结束旧租约都会推进版本；消费者只领取与数据库当前版本一致的消息。旧消息不能领取新一轮执行，迟到的死信也不能终止新投递、覆盖成功结果或撤销取消。

死信事务先锁定任务，再以 `queue_name + message_id` 唯一保存收据与状态转换，提交成功才确认消息。数据库暂时不可用或提交响应丢失会重投；唯一约束避免重复收据 / 重复转换。记录只含队列 / 消息标识、已核实任务引用、投递版本、时间和分类，不保存原始消息体、输入、验证码或供应商错误。分类为 `applied`（已停止等待任务）、`deferred`（保留运行租约）、`stale`（旧版本 / 终态）、`missing`、`invalid`；无对应任务的消息不保存其原始任务编号。

当前等待任务进入 `failed / DELIVERY_EXHAUSTED`，释放输入保留，停止自动补投。运行任务仍保留租约，可发布真实完成的结果；后续已知失败或租约过期才停止自动重试，未知处理器仍进入 `reconciling`。详情只向任务本人返回历史故障时间，恢复后保留该记录，不向浏览器返回队列名、消息编号或内部版本。管理员集中诊断界面归 I9，当前维护者可按任务编号核对 `task_dead_letter` 与 `task_attempt`。

恢复使用既有 `tasks.retry` 和详情重试按钮，继续要求个人所有权、本站来源、频率限制及已启用的验证码；检查处理器版本、当前名额、原输入状态 / 摘要 / 到期及剩余预算。恢复不清零 `attemptCount`，也不清空死信历史。未领取的基础设施投递失败不计处理次数；已经耗尽处理次数的任务拒绝继续执行，需要本人明确提交新任务，不由后台偷偷重置预算。

死信消费者自身设置十次重投。数据库持续不可用时，有限重投不能承诺无限保存消息；上线前需接入队列失败告警与运维恢复，归 I15。生产资源、部署、保留期限和真实容量仍待环境验收。

## 本地验收

### 在主预览中操作

1. 登录后打开 `/files`，保存一个 UTF-8 文本文件。
2. 打开 `/tasks`，选择已保存、未到期的文本文件；选择是否去掉行末空白，点击开始任务。
3. 提交成功自动进入 `/tasks/<任务 ID>`。等待、执行、重试等待和终态来自服务器；首版没有伪造进度百分比。
4. 仅成功后展示结果文件，可预览和下载。原文件保持不变，结果使用个人文件配额与到期策略。
5. 等待 / 运行中的任务可请求取消；运行中需等执行器结算。符合条件且仍有预算时才显示手动重试，次数不会清零。
6. 刷新、离开后返回仍能看到任务。断线会提示重连并禁止按旧状态操作；恢复联网或回到页面重新核对，活跃列表每五秒、详情每三秒检查；查询出错时暂停自动轮询。

提交响应丢失时，页面保留原请求键与输入，再次提交沿用该键。也可刷新任务列表确认是否已保存。更换文件或处理参数才生成新请求键；不要在未知结果时直接改参数或建立新请求来代替检查。

### 接口与配置

`tasks.submit/list/get/cancel/retry` 均要求有效个人账号，用新鲜数据库核对所有权，组织切换不会扩大任务范围。变更请求的 `Origin` 必须匹配 `APP_ORIGIN`。列表使用 `createdAt + id` 游标，每页二十条，重复时间不会漏页；详情和列表只返回公开状态，不返回租约、输入摘要、队列 token 或请求指纹。越权查询 / 修改返回不存在。

`packages/core/website.ts` 的 `tasks` 控制 `enabled`、`maxActive`、`maxAttempts`、`writesPerMinute`、`readsPerMinute`。关闭新执行仍保留查询与取消。新提交 / 重试还必须存在 `STORAGE` 与 `TASK_QUEUE`，缺失时明确报错，不创建任务；开启任务同时需开启文件存储。个人写入 / 读取使用各自原子计数，批量请求逐项计算，不能通过批量打包绕过限制。

全站 Turnstile 开启时，提交动作固定为 `task_submit`，手动重试为 `task_retry`；取消不消费验证码。token 作为独立请求字段传到服务端验证，不进入请求摘要或数据库。页面显示的只有公开 site key / action，复用文件上传的 SDK 生命周期。真实 widget 仍待环境验收，默认关闭。

tRPC 错误含稳定的 `taskError`、`fileError` 或 `executionError`；限流带 `retryAfterSeconds: 60`。`REQUEST_CONFLICT` 检查请求键是否用于不同输入；`ACTIVE_LIMIT` 先结束或取消旧任务；`NOT_RETRYABLE` 检查状态与次数；`DISABLED` / `NOT_CONFIGURED` 核对策略与绑定。服务器异常只返回通用提示，不返回内部诊断消息 / 调试堆栈；任务入口也不记录原始错误体。实际接口异常不会自动再提交或清零执行预算。

### 自动验收命令

```sh
bun run test -- --run apps/api/lib/tasks/tasks.test.ts apps/api/lib/tasks/results.test.ts
bun tasks:validate-store
bun tasks:validate
bun files:validate
```

Vitest 使用实际迁移的 PGlite 和本地 R2，覆盖幂等冲突、个人隔离、五个活跃名额、输入保留、投递失败 / 迟到确认、并发领取、旧版本写回、有限重试、取消与未知处理器暂停。

`tasks:validate-store` 只允许 development 与 loopback PostgreSQL。它创建临时数据库并应用迁移，通过八个不同 PostgreSQL 会话测试真实锁等待、20 轮输入 / 删除竞争、幂等、次数与名额限制；实际 R2 保存测试输入。结束销毁临时数据库 / R2，正常配置指向的数据库不迁移。输出明确标记：**没有测试业务队列消费者**。

`results.test.ts` 使用实际迁移的 PGlite 与 R2，验证并发 / 重复执行只发布一次、精确字节与摘要、零字节结果、未发布隔离、过期版本拒绝、运行中取消、失败补偿 / 配额、输入实际摘要、到期保留、空间不足后的有限重试，以及提交成功但响应丢失时不误删结果。PGlite 不能代替真实多连接锁竞争，后者仍由 `tasks:validate-store` 验证。

`tasks:validate` 使用当前生产 Worker bundle、临时 PostgreSQL 和实际本地 Queue / R2，验证登录后的真实 HTTP 提交 / 幂等 / 列表 / 详情 / 取消 / 重试及越权 404，消费后私有下载核对原字节 / 摘要 / 响应头。重复消息和已取消消息不重复执行；失联 / 故障状态由内部受控夹具建立，再由实际维护 / 重试入口恢复。死信验收只在临时数据库安装拒绝领取的故障触发器，实际配置经历首次投递及三次十秒重投，保存一次收据并停止自动补投；移除故障后经真实 HTTP 重试恢复和下载。另一个临时数据库故障让实际消费者领取后失联，显式推进租约期限，实际维护 / 队列三轮后耗尽保存预算并拒绝手动重试。生产代码没有验收故障开关，也没有缩短实际队列重投配置。停止该验收 Worker、复用其持久目录重启后，原结果仍可下载且新任务继续执行。结束销毁临时数据库与资源目录，不代表浏览器或真实验证码验收。

`files:validate` 继续验证真正 Worker bundle 的文件流程和定时清理，使用另一个临时数据库。内部服务先保留测试文件，实际 HTTP 删除返回 409，内部取消后删除成功。额外保护检查消费真实写入次数，脚本核对 429 / 重试提示后，仅推进临时用户的计数窗口继续检查，不改变配置上限。

`routers/tasks.test.ts` 通过实际迁移 PGlite / R2 验证个人隔离、禁用 / 缺绑定、来源拒绝、幂等、发送失败保留、相同时间分页、并发限流和验证码动作；真正 tRPC HTTP 批量读取验证每项计数与错误格式。组件测试验证丢失响应沿用请求键、参数变更换键、断线禁止旧状态操作、重连显示结果和运行中取消等待结算。主预览浏览器已完成实际提交、状态轮询、结果预览 / 下载 / 摘要、直接详情刷新；320 / 390 像素任务 / 文件布局已检查实际宽度，无横向溢出，文件长文本对话框在 390 像素保留 64 KiB 提示。

两份新生成站分别在 8410 / 9410 独立安装、应用迁移并构建，使用不同数据库、认证命名空间和 R2 / Queue 名称。两站实际网关接口与浏览器都完成任务、预览 / 下载和直接详情刷新；混合 Cookie 选择本站会话，外站任务 / 取消 / 结果拒绝，详见[迭代记录](./iteration-progress)。生成时源仓库含本轮未提交改动，`template.json` 如实记录该情况，不将其声称为已发布版本。真实执行验证码、云端负载与第三方处理器仍保留后续验收门槛。
