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 终态 服务端决定不执行

两件事必须分清:

另外两个贯穿字段: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"]

图上两个虚线分支是本项目的两条明确决定:

两条都成立的前提是:调用方必须从返回值读真实状态,不能假设自己的写生效了。

一次完整运行

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

逐行读:

  1. A 是现场那个状态的正确用法:缺参数时任务就地停在 INPUT_REQUIRED,而不是静默失败。「需要什么」必须和任务一起持久化,否则进程重启后连该问什么都忘了。
  2. B 展示中断态怎么恢复:往同一个 taskId 再发一条消息即可,不需要新建任务。
  3. C 是幂等:终态后再发,状态不变、产物数量不变。一个已经对外承诺过的结论不该被迟到的写改写。
  4. D 是本章最该记住的一条:对终态取消会报错,不是静默成功。理由和「沉默的成功最危险」一样——调用方必须知道自己没生效。
  5. F 是持久化:换一个 TaskStore 实例(模拟进程重启)读同一个文件,任务和产物都还在。现场那个「任务静静消失」的事故,缺的就是这一步。
  6. 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,断言它不受影响。如果你的实现把已完成的任务也回收了,说明终态判定没生效——那会丢掉已经交付的结论。

本章检查点

现在能解释什么

你现在能列出九个状态、分清四个终态与两个中断态,并且知道中断态是最容易被误判的一类。你也看到了三条幂等行为:终态不改写、终态取消报错、历史可裁剪。下一章解决「怎么把进展实时取回来」——流式与重订阅。

进入 keel 阅读