KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · Task 生命周期:一次委派为什么必须留下句柄 — keel 龙骨
## 现场:任务在等一句话,编排层却以为它结束了
现场:任务在等一句话,编排层却以为它结束了
诊断任务跑到一半,远端 Agent 回了一个状态:TASK_STATE_INPUT_REQUIRED,附一句「请提供 service 名称」。
编排层的状态机不认识这个值。它只判断「是不是 TASK_STATE_COMPLETED」,于是把这个任务归进了「未完成但不再变化」的一类,不再轮询。任务就那么挂着,一直到 TTL 被清掉。用户那边显示「处理中」,三天后自动消失,没人知道为什么。
事后复盘只有一句话:编排层把中断态当成了终态。
这类 bug 的隐蔽之处在于它不报错。任务没有失败,也没有成功,只是静静地待在那里——而协议早就明确定义了哪些状态是终态、哪些不是。
直觉模型:短请求返回答案,长任务返回承诺
| 短请求 | 长任务 | |
|---|---|---|
| 时长 | 毫秒到秒 | 秒到小时 |
| 返回 | 直接是结果(Message) | 一个句柄加当前状态(Task) |
| 中途要输入 | 不需要 | 需要,要能停下来等 |
| 进程崩了 | 重来一次即可 | 必须能从落盘状态继续 |
| 重复提交 | 一般无副作用 | 必须幂等 |
短请求返回答案,长任务返回承诺。 承诺的内容是一个可以在之后被查询、被补全、被取消的稳定句柄。
精确定义:九个状态,四个终态,两个中断
规范 v1.0.1 的 TaskState 共 9 个值(检索于 2026-10-05):
| 状态 | 类别 | 含义 |
|---|---|---|
TASK_STATE_UNSPECIFIED |
零值 | 未知或未设置(Proto 枚举的默认值) |
TASK_STATE_SUBMITTED |
进行中 | 已受理,尚未开始 |
TASK_STATE_WORKING |
进行中 | 正在处理 |
TASK_STATE_INPUT_REQUIRED |
中断 | 需要额外用户输入才能继续 |
TASK_STATE_AUTH_REQUIRED |
中断 | 需要认证才能继续 |
TASK_STATE_COMPLETED |
终态 | 成功完成 |
TASK_STATE_FAILED |
终态 | 以错误结束 |
TASK_STATE_CANCELED |
终态 | 在完成前被取消 |
TASK_STATE_REJECTED |
终态 | 服务端决定不执行 |
两件事必须分清:
- 终态(4 个):进入之后状态与产物都不该再被改写。这是幂等性的起点。
- 中断态(2 个):任务没结束,只是在等外部输入。现场那个事故就是把中断态当终态处理。
另外两个贯穿字段:taskId 是这次工作的句柄;contextId 由服务端生成,用来把多次相关交互逻辑上归为一组——它让「补一次输入」和「换一次对话」区分得开。
状态机与持久化
flowchart TD
SUB["SUBMITTED"] --> WORK["WORKING"]
WORK --> INPUT["INPUT_REQUIRED"]
WORK --> AUTH["AUTH_REQUIRED"]
WORK --> DONE["COMPLETED"]
INPUT -->|补发消息| WORK
AUTH -->|补凭据| WORK
SUB --> FAIL["FAILED"]
WORK --> FAIL
SUB --> CANCEL["CANCELED"]
INPUT --> CANCEL
WORK --> CANCEL
SUB --> REJ["REJECTED"]
DONE --> STORE[("tasks.json 任务快照")]
FAIL --> STORE
CANCEL --> STORE
REJ --> STORE
INPUT --> STORE
DONE -.->|再次完成 静默忽略| DONE
CANCEL -.->|对终态取消 报错| ERR["-32002 TASK_NOT_CANCELABLE"]
图上两个虚线分支是本项目的两条明确决定:
- 终态后再要求完成:静默返回既有快照,产物不被第二次写覆盖。
- 终态后再取消:明确报错
TASK_NOT_CANCELABLE,不做沉默的成功。
两条都成立的前提是:调用方必须从返回值读真实状态,不能假设自己的写生效了。
一次完整运行
python courses/foundation/a2a-protocol-engineering/course/project/examples/04_task_lifecycle.py
实跑输出:
A. 缺 service,任务先停住: TASK_STATE_INPUT_REQUIRED
B. 补上 service 后继续: TASK_STATE_COMPLETED | artifacts: ['artifact-02']
C. 终态后再发一次: TASK_STATE_COMPLETED | artifacts 数量保持不变: 1
D. 完成后取消: -32002 TASK_NOT_CANCELABLE
E. 中断态任务取消: TASK_STATE_CANCELED
F. 换个实例读同一个文件: TASK_STATE_COMPLETED | artifacts: 1
G. historyLength=0 时带回 history: False
逐行读:
- A 是现场那个状态的正确用法:缺参数时任务就地停在
INPUT_REQUIRED,而不是静默失败。「需要什么」必须和任务一起持久化,否则进程重启后连该问什么都忘了。 - B 展示中断态怎么恢复:往同一个
taskId再发一条消息即可,不需要新建任务。 - C 是幂等:终态后再发,状态不变、产物数量不变。一个已经对外承诺过的结论不该被迟到的写改写。
- D 是本章最该记住的一条:对终态取消会报错,不是静默成功。理由和「沉默的成功最危险」一样——调用方必须知道自己没生效。
- F 是持久化:换一个
TaskStore实例(模拟进程重启)读同一个文件,任务和产物都还在。现场那个「任务静静消失」的事故,缺的就是这一步。 - G 是 history 裁剪:
historyLength=0时服务端不带历史。历史是可选的,别把关键事实只放在历史里。
失败注入
注入 A:把中断态当终态
把编排层的终止条件写成 if state != TASK_STATE_COMPLETED: 视为结束,然后跑一次需要补输入的任务。任务会永远停在 INPUT_REQUIRED,而编排层认为已经处理完了。
正确的写法是显式判断集合:
if state in TERMINAL_STATES: # 只有这四个算结束
settle(task)
elif state in INTERRUPTED_STATES: # 这两个要主动补输入或补凭据
ask_for_more(task)
注入 B:不落盘
TaskStore() 不传路径就是纯内存字典。让它在任务处于 INPUT_REQUIRED 时「重启」(换个实例),任务消失。修复是传一个路径:TaskStore(path)。
但落盘只是第一步,生产上还缺三样:
| 缺失项 | 为什么必须有 |
|---|---|
| 租约(lease) | 进程持有任务后崩溃,需要心跳过期即释放,让别的进程接管 |
| 结果 TTL | 产物不能永久存(尤其含个人数据) |
| 游标分页的列表 | 客户端丢了 task id 不等于丢了工作(v1.0 的 ListTasks,见第 09 章) |
生产边界
| 教学实现 | 生产替换 |
|---|---|
| 内存 dict 加整体写 JSON | 任务表 + 事务 + 乐观锁 |
| 无租约 | 心跳与租约 + 超时回收 |
| 无 TTL | 结果保留策略与清理任务 |
tasks.json 单文件 |
分库分表或专用任务存储,按 contextId 建索引 |
| 整体重写(非原子) | 行级更新(教学写法写到一半断电会得到残缺 JSON) |
练习与验收
练习(有可观察结果):给 TaskStore 加一个 reclaim(timeout_seconds) 方法:扫描所有非终态任务,把超过 timeout_seconds 未更新的(需要给快照加 updated_at)标记为 TASK_STATE_FAILED,并返回一份能被解释清楚的清单。
验收标准:创建一个任务、等到超时、调用 reclaim,断言它变成 TASK_STATE_FAILED;再对一个 COMPLETED 的任务调用 reclaim,断言它不受影响。如果你的实现把已完成的任务也回收了,说明终态判定没生效——那会丢掉已经交付的结论。
本章检查点
- 现场那个事故里,编排层少判断了什么?把中断态当终态和把终态当中断态,哪个更危险?
- 「终态后再取消」为什么选择报错而不是静默返回?
- 落盘之后还缺哪三样东西,任务才算真的能在生产里存活?
现在能解释什么
你现在能列出九个状态、分清四个终态与两个中断态,并且知道中断态是最容易被误判的一类。你也看到了三条幂等行为:终态不改写、终态取消报错、历史可裁剪。下一章解决「怎么把进展实时取回来」——流式与重订阅。