挂起、恢复与幂等Park, resume and idempotency
先说问题The problem
审批点了两下,钱退了两次。或者 worker 被杀,重启后 agent 从头再跑一遍,把已经写过的三处副作用又写了一遍。社区最直白的一句话:checkpoint 不是 durable execution——它只给你存档点,没有失败检测、没有自动恢复、没有重复执行防护。
The approval got clicked twice and the money went out twice. Or the worker was killed, and on restart the agent ran the whole turn again and re-wrote all three side effects. The blunt community line: a checkpoint is not durable execution — it gives you a save point, with no failure detection, no automatic resumption and no duplicate-execution prevention.
先动手Do it first
先在页面上接线——页面只懂规则。然后跑下面那条判题,那才是真会失败的东西。Wire it on the page — the bench only knows the rules. Then run the check below; that is the part that can actually fail.
worker 在审批挂起期间被杀。排出恢复的正确顺序,以及它防住的那一步。
The worker is killed while a refund is parked. Order the recovery, ending with the failure the ordering prevents.
- 审批通过,worker 从 ASKING 恢复同一个 tool_callApproval lands and the worker resumes the same tool_call from ASKING Move up">▲
- 会话状态落成 awaiting_permission,进程可以安全退出The session parks as awaiting_permission and the process may exit Move up">▲
- 重放前先查幂等键,已执行过就跳过Before replaying, check the idempotency key and skip if it already ran Move up">▲
- 退款副作用第一次执行成功,写入 outbox 记录The refund executes once and writes its outbox record Move up">▲
- 进程在挂起点被杀;重启后从存储读回,而不是从头再跑The process dies at the park point; boot reads the state back instead of restarting Move up">▲
- 没有幂等键的第二遍:钱退了两次A second pass with no idempotency key: the money goes out twice Move up">▲
create_app(…)
为什么这样设计Why it is built this way
01挂起是持久化状态Parking is a durable state
工具停在 ToolCallState.ASKING,会话变 awaiting_permission,worker 可以完全消失。恢复靠的是读回上下文,不是靠进程还活着。
The tool parks in ToolCallState.ASKING, the session becomes awaiting_permission, and the worker may vanish entirely. Resumption reads the context back; it does not depend on the process surviving.
02决策要幂等Decisions must be idempotent
同一个 reply_id + tool call 的第二次点击必须是无操作。渠道适配层就靠这一点吃掉重复点击。
A second click on the same reply_id and tool call must be a no-op. The channel adapter relies on exactly this to swallow double clicks.
03副作用要带幂等键Side effects carry an idempotency key
退款、写回、发信,都接一个 idempotency_key。恢复重放时键相同,下游只发生一次。
Refund, write-back and email each take an idempotency_key. On replay the key matches and the downstream happens once.
04杀进程验收Acceptance by killing the process
真正的检查是:跑到第 7 步 kill -9,重启,断言副作用计数没有增加、状态从挂起继续。
The real check: kill -9 at step seven, restart, and assert the side-effect count did not grow and the state resumed from parked.
用到的 APIAPIs in play
代码The code
# 1. find what is parked — reply_id is the ASSISTANT MESSAGE id, not a tool id
parked = parked_call(session_messages(client, agent_id, session_id))
# -> (reply_id, tool_call_block) with block["state"] == "asking"
# 2. resume: the same POST /chat/ that started the run, distinguished by event type
await client.post("/chat/", json={
"agent_id": agent_id, "session_id": session_id,
"input": {
"type": "USER_CONFIRM_RESULT",
"reply_id": reply_id,
"confirm_results": [{"confirmed": True, "tool_call_id": block["id"], "rules": [...]}],
},
})
assert session_status(client, agent_id, session_id)["status"] == "idle"
assert ledger.count("refund") == 1 # clicked twice, or restarted: still one row
判题The check
python lab.py run c5
它断言了什么What it asserts
refund under DEFAULT -> asking on refund_order, 0 ledger rows, session awaiting_permissionworker killed with no shutdown handler; a second process over the same storage still reads awaiting_permission with call refund-1 askingresumed -> idle, 1 ledger row(s) of 4200 cents, tool_result state successthe same reply_id + call answered twice more: still 1 row, the duplicate is swallowedconfirmed=False -> tool_result denied and still 1 row: denial is a real terminal state, not a retry
会踩的坑Where it hurts
把「恢复」实现成「重新跑一遍整个任务」。任务里只要有非幂等副作用,恢复就是一次事故。
Implementing resume as re-running the whole job. One non-idempotent side effect in that job turns a resume into an incident.
费曼闸门Say it back
为什么审批状态必须存在持久化上下文里,而不是存在内存的 pending 队列里?举一个只有前者能活下来的场景。
Why must approval state live in the durable context rather than an in-memory pending queue? Name a scenario only the former survives.
页面只存通行码的哈希。判题没真跑过,这里就解不开。The page stores a hash, not the code. Nothing unlocks until the command really ran.