C11 结尾留下一个问题:三种模式都能在当前进程里让 Run 停下来,进入 WAITING_FOR_USER(等待用户确认)。Trace 里看得到状态、看得到等待原因。但一退出进程,这些东西全跟着内存一起没了。第二天用户回来说「确认」,你手上只剩一句话,和一个空终端——对应哪份纲领、哪个动作、恢复到哪一步,全不知道。
这篇文章用一段能真正跑起来的跨进程实验回答它。先把结论的一句话版本放在这里,读完正文再回来看会更容易懂:
恢复一个 Run 时,真正要防的是三件事:找错对象、重复执行、以及失败时一点痕迹都不留。 对应的三招分别是:把「批准了什么」冻成快照、给动作一个幂等键、把每次拒绝都写进 Trace。整篇文章就是把这个一句话结论,拆成能跑的代码逐条验证。
一、当前 Run 的返回,不保证明天能回到同一份状态
先回看 C11 的场景。一个最小 Runtime 收到 LLM 输出的 write_brief proposal(提案),它不确定能不能执行,于是挂起当前 Run 并返回 WAITING_FOR_USER(等待用户):
brief_id="brief-1"
status="WAITING_FOR_USER"
waiting_reason="unsafe_action:write_brief"
单进程里,只要程序不退出,brief_id 还在内存里,下次调 handle("confirm","brief-1") 就能接着走。但实际使用场景不是这样的——用户在 IDE 里点一次运行,Agent 停下来等人决策,然后用户可能关掉终端、下班、第二天再回来看。此时:
- 原进程已退出。
- 内存里的
brief_id、waiting_reason、已经走到哪一步——全都消失。 - 用户的「确认」成了一句话孤儿。
C11 的 return 只保证当前调用的控制权交回给你,不保证换一个进程还能找回同一个 Run。
LangGraph 用 checkpointer + interrupt() 解决这个问题:挂起时把完整状态写进持久化后端,恢复时相同 thread_id 读出状态、从中断节点开头重跑。这是能力层的参考——它证明了「跨进程恢复」是可行的。但 LangGraph 的 checkpointer 是框架替你管的黑盒,本文要拆开看:你要自己管多少东西,为什么只存一个 run_id 不够,以及怎样把审批绑到具体版本上。
二、正常主路径:一个 Run 的生命线靠两样东西跨进程传递
先从正常路径走一遍——不做故障注入,走整条 start → approve → resume 时间线。关键机制只有两个:
- SQLite 持久化 checkpoints。 同一个
run_id在不同进程里打开同一份.db文件,读到的是同一个持久 Run 的状态和 Trace(审计日志)。 - 新
resume调用推进同一个 Run,不创建一个新的。start run-1创建了一个持久 Run。进程退出后再开一个新进程,再执行resume run-1——这是一次新的恢复调用,但它读到的、推进的,是 SQLite 中同一个run_id=run-1的记录,不是重新start一个新 Run。
用一张时序图把这条主线跑通:
虚线标出进程边界,但 run_id 不变——跨进程恢复的是同一个持久 Run 的状态,不是「重新创建一个」。图中 Action Gateway 是动作准入与副作用执行的统一入口,沿用 C11 的同一个概念:Runtime 校验完审批绑定后,把动作交给它执行;demo 里它物理上就是 Store 的 resume 方法内部那几行写入逻辑,不是独立进程,生产系统才会把它拆成独立组件。
动手跑一下:
# GYA 仓库 clone 下来后,进到 c12-durable-resume/python
# 进程 A
cd python
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-1
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt show-trace run-1
# 从 JSON 输出复制 proposal_hash
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-1 --proposal-hash '<hash>'
# --- 退出终端、下班、第二天回来 ---
# 进程 B
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt show-trace run-1
Java 侧同语义(GYA-Java 仓库根目录下执行):
./gradlew :c12-durable-resume:run --no-daemon \
--args='/tmp/c12-java.db /tmp/c12-java-draft.txt start run-1'
# ... approve ...
./gradlew :c12-durable-resume:run --no-daemon \
--args='/tmp/c12-java.db /tmp/c12-java-draft.txt resume run-1'
正常路径下,show-trace 的 Trace 链为:
checkpoint_saved(保存检查点) → proposal_recorded(记录提案)
→ proposal_verified(提案验证通过) → approval_recorded(记录审批)
→ proposal_verified(恢复时再次验证) → resume_started(开始恢复)
→ action_executed(动作已执行)
两端的 proposal_hash 同为 2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573(对「action + args + output」三者一起哈希得到的值,output 取 /tmp/c12-draft.txt)。这条 Trace 链里反复出现的 proposal_verified,就是恢复时那道「拿现在对当时」的检查在打点。
三、关键机制:批准的那一刻,把「批准了什么」冻成两份快照
第二节走完正常路径时,runs 表里其实只发生了一件事,理解它就理解了整个恢复协议:
用户点「批准」的那一刻,程序把「批准了什么」冻结下来。 冻结两份:一份是「批准的是哪一次请求」,一份是「批准的是哪个动作」。这里说的「动作」不是只有 action 名字——它包含三样:做什么(action)、带什么参数(args)、写到哪(output 路径)。三者一起被哈希,缺一个「批准」就不完整。之后无论隔了多久、换了几个进程,恢复时都拿「现在是什么」去对「当时批准的是什么」,对不上就拒绝。
先把这两个「对不上就拒」的检查看成一个动作,感受一下它的逻辑,再去看字段名会清楚很多:
恢复时问自己两句话:
1. 现在这个 request,还是不是我批准的那个?
2. 现在这个动作(做什么 + 带什么参数 + 写到哪),还是不是我批准的那个版本?
任何一句答「不是」→ 拒绝,不执行。
落到 SQLite 里,就是下面这组「当前值 vs 批准值」的对照。左边是每次读取时重新算出来的「现在」,右边是批准那一刻冻结的「当时」:
| 检查 | 现在的值(恢复时重算) | 批准时冻结的值 | 对不上就报错 |
|---|---|---|---|
| 请求对不对 | request_id | approval_request_id | approval_request_mismatch |
| 动作版本对不对 | 重算 action/args/output 的 SHA-256 | approval_hash | proposal_hash_mismatch |
| 写到哪对不对 | 本次传入的 --output | 表中冻结的 output | output_mismatch |
注意 proposal_hash 不在这张表里单独出现——它是 start 时算好、随提案一起存进去的原始哈希,属于「这个 Run 本来要做什么」的一部分,而「批准时冻结」的那份快照叫 approval_hash。两个 hash 名字像,但角色不同:proposal_hash 是「提案长这样」,approval_hash 是「你批准的就是这样」。第二节 CLI 里让你手动传的那个 --proposal-hash,就是拿「现在」去核对「提案」用的;而防「批准后被篡改」靠的是 approval_hash,第四节实验二会单独验证它。
特别说明为什么 output 要进哈希:如果它不进,「批准写 A 文件」这件事就没被锁住,恢复时换成「写 B 文件」照样能通过审批校验——output 这个副作用目标就成了一处没人管的缝。把它和 action、args 一起哈希,三样里动任何一个,重算的 hash 都会变,对不上冻结的 approval_hash 就拒。
除此之外还有两个字段,解决的不是「对不对」,而是「找得到、别重做」:
run_id+status:新进程靠它定位到哪一行、停在哪一步。这是「找得到」的最低要求。executed:动作有没有已经做过。做完后重复resume直接返回already_completed,不重写文件。
状态本身只有四个合法值:
| 状态 | 中文含义 | 合法来源 | 合法出口 |
|---|---|---|---|
WAITING_FOR_USER | 等待用户 | start | approve / reject |
APPROVED | 已批准 | approve | resume |
REJECTED | 已拒绝 | reject | 终止 |
COMPLETED | 已完成 | resume 完成动作后 | 重复恢复 → already_completed |
这四张状态加两组快照,就是整个 checkpoint 的全部内容。两组快照守不守得住,用篡改实验直接验;executed 兜底守不守得住,用重放实验验。
四、旧审批为什么不能放行新的 request 或新参数
上一节把「批准时冻两份快照」立起来了。现在直接篡改数据库,看这层保护到底拦不拦得住。三个实验各回答「防找错对象」里的一个问题。
实验一:批准之后,把 request 换成另一个。 正常 start → approve,然后直接改 SQLite,把 request_id 从 approval:run-1 改成 approval:run-2——模拟「用户批准的是 A,恢复时却拿着 B 来要执行」。中间那行 UPDATE 就是这次篡改本身:
python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-exp1.txt start run-1
python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-exp1.txt approve run-1 \
--proposal-hash 'af45d70539c0998f4e3071d23e5501ccd207fb1dd5fa1d3d758307d84b74a8b9'
sqlite3 /tmp/c12-exp1.db \
"UPDATE runs SET request_id='approval:run-2' WHERE run_id='run-1';"
python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-exp1.txt resume run-1
# → approval_request_mismatch
resume 发现现在的 request_id 不等于批准时冻结的 approval_request_id,拒绝。动作没执行,文件没创建。
实验二:把动作内容偷偷换掉,还顺手把 hash 也改成新的。 这是更狡猾的篡改——不只是改 args,还重新算一个和篡改内容配套的 proposal_hash,让「提案」这一层看起来自洽。但批准时冻结的 approval_hash 还是篡改前的旧值,改不了:
# 先正常 start + approve
python3 main.py --db /tmp/c12-exp2.db --output /tmp/c12-exp2.txt start run-1
python3 main.py --db /tmp/c12-exp2.db --output /tmp/c12-exp2.txt approve run-1 \
--proposal-hash 'bd3de4cee4ccf82fb98239c875d50dac6455907c76e67cd267949412d08cfe0c'
# 篡改者算好篡改内容的新 hash(output 是 start 时冻结的 /tmp/c12-exp2.txt)
python3 -c "
import hashlib,json
malicious=json.dumps({'action':'write_draft','args':{'content':'unauthorized change'},'output':'/tmp/c12-exp2.txt'},sort_keys=True,separators=(',',':'))
print(hashlib.sha256(malicious.encode()).hexdigest())
"
# → f92f11517a438bc7384f1486dd4f98e8613dccb0cf760946cb3da118051108a0
# 把 args 和 proposal_hash 一起换成篡改版
sqlite3 /tmp/c12-exp2.db \
"UPDATE runs SET args='{\"content\":\"unauthorized change\"}',
proposal_hash='f92f11517a438bc7384f1486dd4f98e8613dccb0cf760946cb3da118051108a0'
WHERE run_id='run-1';"
python3 main.py --db /tmp/c12-exp2.db --output /tmp/c12-exp2.txt resume run-1
# → proposal_hash_mismatch
恢复时重算当前动作的 hash,得到 f92f1151...,它确实和篡改后的 proposal_hash 一致——但和批准时冻结的 approval_hash(bd3de4ce...)不一致,于是拒绝。这个实验单独证明的是:approval_hash 是一道独立的锁。篡改者可以把 proposal_hash 改得再自洽,也过不了「和你批准的那个版本一致」这一关。
实验三:换一个输出路径。 既然 output 已经进了 proposal,那「写到哪里」也是被批准的一部分。恢复时故意传一个不同的 --output,等于篡改副作用目标,同样被拦:
python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-other.txt resume run-1
# → output_mismatch
这一条和实验一、实验二本质相同——output 和 request_id、action/args 一样,都是「批准了什么」的一部分,动了任何一个都过不了恢复时的核对。
两个实验(加第三个)合起来,正好对应第三节那张「当前值 vs 批准值」表。下面这张表把三种设计放在一起,看漏掉哪一份快照会放行哪种篡改:
| 设计 | 批准后替换 request_id | 批准后替换 action/args 或 output | 结论 |
|---|---|---|---|
只绑 run_id | 误放行 | 误放行 | 不可接受 |
只存 proposal_hash | 误放行(request 变了但 action 没变) | 拒绝 | 只覆盖一半 |
approval_request_id + approval_hash 双绑 + 恢复时重算 | 拒绝 | 拒绝 | 覆盖全部 |
边界要说清:这套保护假设 SQLite 是可信存储。能同时改写 proposal 和 approval 两行记录的人,等于已经攻破了数据库,本文不防。本文防的是更常见的场景:批准之后、恢复之前,request 或 action/args/output 被人不经过审批地偷偷换掉——双绑 + 恢复时重算,能在这条边界内守住。
五、完成后的重复调用与故障窗口是两种重放
第四节解决了「找错对象」。现在解决三件事里的第二件——重复执行:同一个 resume 被调两次,怎么保证动作只做一次。
这里其实有两个不同的「再调一次」,别混在一起:一次是动作已经完整做完之后的重放,一次是动作做到一半进程就挂了的重放。前者有 executed 标记兜底,后者才是真正麻烦的地方。
场景一:正常完成后重复 resume。 文件已创建,checkpoint 已更新为 COMPLETED,用户再调一次 resume run-1:
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
# → already_completed
程序看到 executed=1,直接返回,不再写文件。Trace 记一条 action_replayed,文件修改时间不变。
场景二:故障窗口恢复——文件已创建,checkpoint 还没更新成 COMPLETED。 这才是协议最痛的一刻:动作本身有副作用(写文件),而「记下动作已完成」这个操作本身是另一步,两步之间有一道缝。缝里挂了,数据库以为没做,文件其实已经写了。先把这道缝画出来:
这张图只有三条实线状态转换(图中用短名 WAITING/APPROVED/REJECTED/COMPLETED 分别对应 WAITING_FOR_USER(等待用户)/APPROVED(已批准)/REJECTED(已拒绝)/COMPLETED(已完成))——其余场景(未批准恢复、proposal 被替换、request 被替换、重复审批、完成后重复恢复、内容冲突)都不发生状态转换,直接抛异常并在 Trace 中留 proposal_rejected、resume_rejected、transition_rejected 或 action_conflict 事件。
关系说清楚后,回到那行最要命的位置:
action_executed (文件已写入)
↓ 此刻崩溃(进程挂了、断电、kill -9)
STATUS != COMPLETED (checkpoint 还没来得及更新)
新进程启动后,resume 看到 status=APPROVED、executed=0,于是按正常流程准备写文件——但 FileExistsError(Python)或 FileAlreadyExistsException(Java)告诉它:文件已经在了。
此时不是直接覆写,而是比较内容:
except FileExistsError:
if self.output.read_text(encoding="utf-8") != content:
raise ValueError("output_conflict")
recovered = True
如果内容相同 → 返回 recovered,Trace 留 action_recovered,更新 executed=1、status=COMPLETED,文件修改时间不变。
如果内容不同 → 返回 output_conflict,Trace 留 action_conflict,附带同维度的两份内容摘要——expected_content_hash(本应写入的内容的 SHA-256)和 existing_content_hash(已存在文件内容的 SHA-256),原文件不被覆盖。这是在说:同一个幂等键(文件路径)被另一个来源写了不同的内容——这不是故障恢复,是冲突。
测试里用 fail_after_action=True 精确注入这个窗口:文件创建后立即抛 RuntimeError("simulated_crash_after_action"),不执行后续的 checkpoint 更新。新实例恢复时不容错地命中 recovered 路径。
六、动手跑一遍:从 CLI 到失败路径全遍历
本节是完整的运行指南。核心只有一句话:这里的 write_draft proposal 是固定的测试夹具,代替上任上游 LLM 的输出,所以不需要 API Key。 它只输出 "approved technical brief" 这一句话到指定文件;不碰 drafts/、published/ 或 my-blog 目录。
Python 3.11+(标准库 sqlite3)
# 在 GYA 仓库 clone 下来的根目录下
cd c12-durable-resume/python
# 1. 运行全部测试
PYTHONPYCACHEPREFIX=/private/tmp/c12-pycache python3 -B -m unittest -v
# 10/10 通过
# 2. 正常主路径
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-1
# → {"run_id":"run-1","status":"WAITING_FOR_USER",...}
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt show-trace run-1
# 复制 proposal_hash 字段值
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-1 \
--proposal-hash '2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573'
# 模拟换新进程:直接调 resume
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
# → "result":"executed"
cat /tmp/c12-draft.txt
# → approved technical brief
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
# → "result":"already_completed"
失败路径
# 未批准恢复
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-2
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-2
# → approval_required
# 批准时 hash 不一致
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-2 \
--proposal-hash '0000000000000000000000000000000000000000000000000000000000000000'
# → proposal_hash_mismatch
# 拒绝后再次批准
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt reject run-2 \
--proposal-hash '2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573'
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-2 \
--proposal-hash '2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573'
# → approval_not_pending
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-2
# → approval_rejected
# 内容冲突
echo "different content" > /tmp/c12-draft.txt
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-3
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-3 \
--proposal-hash '2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573'
python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-3
# → output_conflict
cat /tmp/c12-draft.txt
# → different content(原内容未被覆盖)
批准后 request/proposal 替换与故障窗口恢复这两条路径涉及直接改 SQLite 或注入崩溃,不便用 CLI 逐条演示,改由单元测试覆盖(见第四节和第五节的 sqlite3 篡改命令,以及 test_main.py 里的 test_post_approval_request_replacement_is_rejected、test_post_approval_storage_tampering_is_rejected、test_crash_after_file_creation_is_recovered_without_rewrite)。完整命令见第四节实验一/实验二,故障窗口恢复的注入点见第五节。测试定位方式:
# 批准后替换 request_id → 恢复被拒
python3 -B -m unittest test_main.DurableResumeTest.test_post_approval_request_replacement_is_rejected -v
# 批准后同时替换 args + proposal_hash → 恢复被拒
python3 -B -m unittest test_main.DurableResumeTest.test_post_approval_storage_tampering_is_rejected -v
# 文件创建后注入崩溃 → 恢复返回 recovered,不重写文件
python3 -B -m unittest test_main.DurableResumeTest.test_crash_after_file_creation_is_recovered_without_rewrite -v
Java 21
Java 版本在 GYA-Java 仓库,用仓库根的 Gradle Wrapper,需要指定 JDK 21。Java CLI 使用位置参数而非 --proposal-hash 选项:
export JAVA_HOME=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
# 在 GYA-Java 仓库 clone 下来的根目录下
./gradlew :c12-durable-resume:test --no-daemon
# BUILD SUCCESSFUL, 10/10 通过
# output 统一用 /tmp/c12-draft.txt,与 Python 侧一致,保证两端 proposal_hash 相等
./gradlew :c12-durable-resume:run --no-daemon \
--args='/tmp/c12-java.db /tmp/c12-draft.txt start run-1'
./gradlew :c12-durable-resume:run --no-daemon \
--args='/tmp/c12-java.db /tmp/c12-draft.txt show-trace run-1'
# 复制 proposal_hash,传给 approve 的五个位置参数的第 5 位:
./gradlew :c12-durable-resume:run --no-daemon \
--args='/tmp/c12-java.db /tmp/c12-draft.txt approve run-1 2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573'
./gradlew :c12-durable-resume:run --no-daemon \
--args='/tmp/c12-java.db /tmp/c12-draft.txt resume run-1'
两端的 Trace 遵循同一份事件语义约定:
checkpoint_saved → proposal_recorded
→ proposal_verified → approval_recorded
→ proposal_verified → resume_started
→ action_executed | action_recovered
负向路径按原因产生 proposal_rejected、resume_rejected、transition_rejected 或 action_conflict。Python 和 Java 的 JSON 字段顺序不要求一致,但事件语义、原因和先后关系一致,两端 proposal_hash 完全相等。
常见坑
Gradle 提示 JDK 25 不兼容。 错误信息为 IllegalArgumentException: 25.0.3。本机默认 JDK 是 25,Gradle 8.14.2 不兼容。确认 JAVA_HOME 指向 JDK 21 即可:
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
Python 找不到 sqlite3 模块。 Python 需编译时带 SQLite 支持,绝大多数发行版和 macOS 自带——如果报 ModuleNotFoundError: No module named '_sqlite3',用包管理器重新安装带 sqlite 支持的 Python。
七、从本地文件幂等走向生产副作用
SQLite 的 BEGIN; ...; COMMIT 只保证数据库内部的原子性。resume 里「创建文件 → 更新 checkpoint」这两步,不在同一个事务里——文件系统是数据库外面的世界。
本文的解法是业务幂等键:文件路径 output 就是幂等键——它同时是 proposal 里被哈希锁定的不可变部分(第三节),也是执行时判断「是否已经写过」的依据。FileExistsError(Python)/ FileAlreadyExistsException(Java)只表示目标已存在,不是去重——demo 在捕获异常后显式比较内容:相同时视为 recovered,不同时回绝 output_conflict 并在 Trace 中记录 expected_content_hash(本应写入内容的 SHA-256)和 existing_content_hash(已存在文件内容的 SHA-256),两者对同一份字节内容做摘要、可直接对照。关键条件是:
- 创建使用
CREATE_NEW/"x"模式——已存在时抛异常,不会静默覆盖。 - 内容不同时显式回绝
output_conflict——不是说「已存在就是成功」。
生产系统要补的东西跟这篇 demo 没有本质冲突,但代价不同。下面这张表里的「生产系统」一列,是超出本文验证范围的设计候选,不是已跑通的实现——本文只证明左边「本文 demo」一列:
| 维度 | 本文 demo | 生产系统(设计候选,未验证) |
|---|---|---|
| 幂等键来源 | 固定文件路径(CREATE_NEW)+ 纳入 proposal 哈希 | 业务幂等键(订单号、消息 ID)+ 目标系统去重 |
| 恢复策略 | 文件内容比较 | outbox + inbox 模式、目标端幂等写入 + 查询 |
| 审批记录 | SQLite 单库(可信边界) | 审计日志表 + 分布式锁 + 签名/加密(防篡改) |
| 多实例竞争 | 不涉及(本地单实例,无并发) | SELECT ... FOR UPDATE 或乐观锁 |
| 审批超时/取消 | 不涉及 | 过期策略 + 补偿 |
核心判断是:SQLite 事务只管得了数据库内部。 它不能包住「把文件写出去」这个副作用。一条 INSERT 和一次 os.write() 之间,隔着一道事务边界。本文只证明了这一层本地文件幂等——CREATE_NEW 加内容比对,能在「文件已存在」时区分「上次已成功」和「内容冲突」。至于跨系统、跨进程、跨服务的端到端 exactly-once,涉及目标端去重、分布式事务或 outbox 等本文没有实现的机制,不在这里下结论。右侧每个候选方案具体怎么落地、在哪些场景失效,是后续工程化扩展的话题,本文不展开。
八、回到开头的那三个洞
这篇文章从头到尾只做了一件事:在 C11 证明「当前 Run 能停」之后,证明「同一个 Run 跨进程还能安全地继续」。它不靠框架、不用 API Key、不扯分布式——只靠 SQLite 单库 + 两组快照 + 本地文件幂等键。把开头的结论收回来,三个洞各有一个明确的堵法:
- 找错对象。 批准那一刻冻结
approval_request_id(批准的是哪次请求)和approval_hash(批准的是哪个动作,含 action/args/output)两份快照,恢复时拿「现在」去对「当时」,对不上就拒——第四节的实验一、实验二、实验三分别验证了这两份快照各自挡得住什么。 - 重复执行。 正常重放靠
executed标记兜底;最痛的那道缝(文件写了、checkpoint 没写)靠文件幂等键兜底——第五节的两个场景分别验证。 - 无声失败。 每次拒绝都在 Trace 里留一条带
reason的事件,附上被拒的原始输入(尝试的决定、提交的 hash、当前的 request/action/output),审计时能还原「拒了什么、为什么拒」。
三件事讲完,这套恢复协议在本地单实例、无并发竞争的边界内是自洽的:一份 SQLite、一个待执行动作、一个用户在操作,恢复靠「换一个新进程、打开同一份 .db」来完成——这跟「进程会退出、第二天再回来」的场景正好对上。往边界外走一步——多个 Agent 拆成不同角色协同,一个用户批准了 Agent A 的请求,Agent B 怎么知道?同一个 run_id 跨 Agent 传递会不会串?这份「冻快照 + 幂等键 + Trace」的协议,在多 Agent 世界里还能不能原样成立,是下一篇要回答的问题。
参考资料
正文引用的来源,按首次出现顺序排列:
- LangGraph. Interrupts. https://docs.langchain.com/oss/python/langgraph/interrupts
- LangGraph. Persistence. https://docs.langchain.com/oss/python/langgraph/persistence
- SQLite. Transaction. https://www.sqlite.org/lang_transaction.html
- SQLite. Atomic Commit. https://www.sqlite.org/atomiccommit.html
配套 Python 与 Java 实现在 GYA 仓库与 GYA-Java 仓库的 c12-durable-resume 目录,按 README 的命令跑,不复制这篇文章里零散的代码块。

留言
欢迎分享你的想法。评论提交后会在审核通过后显示。