worker 静默退出:kanban worker rc=0 但不回调 kanban_complete 的协议盲区
本文最后更新于 2026年9月24日 凌晨
进程退出码 0,日志没报错,任务却在看板上原地卡死——这是 Hermes Kanban 最让人挠头的一类失败:worker 明明把活儿干完了,就是没打那通”收尾电话”。
现象
看板上一张卡的状态停在 running 不动,worker_pid 指向一个已经回收的子进程。日志最后一行风平浪静,worker 自信地打印完最后一段总结就退出了,没有任何栈溢出、没有任何 OOM、没有任何 provider 错误。下一次调度 tick 扫过来时,dispatcher 才发现这个 PID 已经死了。
这就是”静默退出”——工作完成度 100%,流程完成度 0%。
根因:kanban 协议是双通道的
Hermes Kanban 的协议要求 worker 必须做一次”终端板卡调用”(terminal board call)才能算结束:
kanban_complete(summary=..., metadata=...)— 任务真正完成kanban_block(reason=...)— 阻塞等人工介入kanban_request_review(...)— 提请审查
只要 worker 没调这三个之一就直接退出,无论它内部做得多漂亮,dispatcher 都只能看到一个 rc=0 但 status=running 的孤儿任务。源码里这个分支写得很直白(hermes_cli/kanban_db_dispatch.py:1076):
1 | |
进程退出码 0 意味着进程已正常终止(clean exit),但任务状态仍停留在 running,没有任何一方把它推进到终态。这是两个完全不同的语义维度,混在一起就是协议盲区。
dispatcher 的应对:有限重试 + 显式提示
dispatcher 的处理分三步走:
第一步:分类_classify_dead_worker_exit 把退出映射为四类(kanban_db_dispatch.py:249-257):
| 退出码 | 分类 | 语义 |
|---|---|---|
| 0 | clean_exit |
协议违规(活干完了但没交卷) |
KANBAN_RATE_LIMIT_EXIT_CODE |
rate_limited |
配额墙,不算失败 |
KANBAN_TERMINAL_PROVIDER_EXIT_CODE |
terminal_provider |
provider 拒了凭证/模型 |
| 其他非零 | nonzero_exit |
真崩溃 |
第二步:注入修正指令
协议违规时,dispatcher 会把这段提示塞进下一次 retry 的 worker context(_PROTOCOL_VIOLATION_ERROR,kanban_db_dispatch.py:960):
worker exited cleanly (rc=0) without kanban_complete, kanban_block or kanban_request_review — protocol violation. If the prior run already did the work, verify it and report it via kanban_complete (or kanban_request_review); a run without a terminal kanban call counts as failed no matter what it did.
重点是最后一句:“没打终端电话的 run 一律算失败,不管它实际做了什么”。这是协议层的硬性约束,不是建议。
第三步:有界重试
协议违规享有独立预算,与 consecutive_failures 完全解耦(kanban_db_dispatch.py:920-923):
1 | |
这两个数字是 Hermes 源码里的硬编码默认值(kanban_db_dispatch.py:923/926),不是推导出来的——它们的设计意图是把”偶发手滑”和”系统性 bug”区分开:3 次以内的协议违规被认为是个别 worker 写漏了,值得重试;连续 3 次就几乎可以肯定是系统性问题(比如某个 skill 模板压根没提终端调用),继续重试只是浪费配额。50 条的扫描窗口则是性能与覆盖的平衡——窗口太小可能漏掉跨多次 requeue 的违规,太大则在每次熔断判断时都要扫全表。
_protocol_violation_streak 从最近的 50 条 closed run 中倒序数”连续的纯协议违规”,到 3 就触发熔断。其他类型的失败不会消耗这个预算,配额墙(rate_limited)更是被显式跳过——系统知道”你没钱了”和”你忘了交卷”是两回事。
为什么协议层不允许”默认成功”
一个朴素的设计思路是:worker 退出码 0 就默认它成功了。Hermes 没走这条路,原因在 kanban-worker/SKILL.md:174 那句提醒:
Complete a task you didn’t actually finish. Block it instead.
完成与否必须由 worker 主动声明,不能由退出码推断。理由:
- 退出码太粗。
set -e下exit 0可能是某个早退分支触发的,不代表主流程跑完。 - metadata 必须存在。下游的 reviewer、聚合器、调度器要从
metadata里读changed_files、tests_run、recommendation这些结构化字段,不是从自然语言总结里抠。 - 审计链需要闭环。看板的 SQLite 行要记录”这次 run 产出了什么”,一个空的 metadata 等于没审计。
如果允许 rc=0 默认成功,整张看板的下游都是无米之炊。
实战启示
写 kanban worker 时,把”终端调用”当作函数签名的一部分而不是可选返回值:
1 | |
更进一步,可以借助静态类型检查(mypy、pyright)在 CI 阶段拦截:把 worker 的返回值声明为 KanbanOutcome,并把这个类型设计成只能通过 kanban_complete 等终端调用才能构造的 capability token(构造器对外部不可见)。静态检查器发现 worker 函数直接 return None 或返回无法构造的类型时就会报错。但这只能作为辅助手段——Python 是动态语言,类型注解在运行时会被解释器忽略,真正的兜底仍然依赖 dispatcher 的协议违规检测。
结论
rc=0 只能证明进程没死,证明不了任务完成。Kanban 协议的”终端调用”是把”完成”这个事实从 worker 进程内部显式提升到看板层的动作——这个动作缺失,整个调度闭环就断裂。dispatcher 用”协议违规 + 有限重试 + 显式提示”三步,把这个盲区从”静默失败”降级为”可恢复的协议错误”。
下次再看板上某张卡停在 running 不动,先看 worker 日志最后一行是不是 kanban_complete——十有八九,它只是忘了说再见。