<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>审批 on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/%E5%AE%A1%E6%89%B9/</link><description>Recent content in 审批 on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Mon, 14 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/%E5%AE%A1%E6%89%B9/index.xml" rel="self" type="application/rss+xml"/><item><title>第十二话｜Run 停了之后怎样接着跑：checkpoint、审批绑定与幂等恢复</title><link>https://zh.renxinblog.cn/post/c12-durable-resume/</link><pubDate>Mon, 14 Sep 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/c12-durable-resume/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/c12-durable-resume-cover.png" alt="Featured image of post 第十二话｜Run 停了之后怎样接着跑：checkpoint、审批绑定与幂等恢复" /&gt;&lt;p&gt;C11 结尾留下一个问题：三种模式都能在当前进程里让 Run 停下来，进入 &lt;code&gt;WAITING_FOR_USER&lt;/code&gt;（等待用户确认）。Trace 里看得到状态、看得到等待原因。但一退出进程，这些东西全跟着内存一起没了。第二天用户回来说「确认」，你手上只剩一句话，和一个空终端——对应哪份纲领、哪个动作、恢复到哪一步，全不知道。&lt;/p&gt;
&lt;p&gt;这篇文章用一段能真正跑起来的跨进程实验回答它。先把结论的一句话版本放在这里，读完正文再回来看会更容易懂：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;恢复一个 Run 时，真正要防的是三件事：找错对象、重复执行、以及失败时一点痕迹都不留。&lt;/strong&gt; 对应的三招分别是：把「批准了什么」冻成快照、给动作一个幂等键、把每次拒绝都写进 Trace。整篇文章就是把这个一句话结论，拆成能跑的代码逐条验证。&lt;/p&gt;
&lt;h2 id="一当前-run-的返回不保证明天能回到同一份状态"&gt;一、当前 Run 的返回，不保证明天能回到同一份状态
&lt;/h2&gt;&lt;p&gt;先回看 C11 的场景。一个最小 Runtime 收到 LLM 输出的 &lt;code&gt;write_brief&lt;/code&gt; proposal（提案），它不确定能不能执行，于是挂起当前 Run 并返回 &lt;code&gt;WAITING_FOR_USER&lt;/code&gt;（等待用户）：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;brief_id=&amp;#34;brief-1&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;status=&amp;#34;WAITING_FOR_USER&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;waiting_reason=&amp;#34;unsafe_action:write_brief&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;单进程里，只要程序不退出，&lt;code&gt;brief_id&lt;/code&gt; 还在内存里，下次调 &lt;code&gt;handle(&amp;quot;confirm&amp;quot;,&amp;quot;brief-1&amp;quot;)&lt;/code&gt; 就能接着走。但实际使用场景不是这样的——用户在 IDE 里点一次运行，Agent 停下来等人决策，然后用户可能关掉终端、下班、第二天再回来看。此时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原进程已退出。&lt;/li&gt;
&lt;li&gt;内存里的 &lt;code&gt;brief_id&lt;/code&gt;、&lt;code&gt;waiting_reason&lt;/code&gt;、已经走到哪一步——全都消失。&lt;/li&gt;
&lt;li&gt;用户的「确认」成了一句话孤儿。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;C11 的 &lt;code&gt;return&lt;/code&gt; 只保证当前调用的控制权交回给你，不保证换一个进程还能找回同一个 Run。&lt;/p&gt;
&lt;p&gt;LangGraph 用 &lt;code&gt;checkpointer&lt;/code&gt; + &lt;code&gt;interrupt()&lt;/code&gt; 解决这个问题：挂起时把完整状态写进持久化后端，恢复时相同 &lt;code&gt;thread_id&lt;/code&gt; 读出状态、从中断节点开头重跑。这是能力层的参考——它证明了「跨进程恢复」是可行的。但 LangGraph 的 &lt;code&gt;checkpointer&lt;/code&gt; 是框架替你管的黑盒，本文要拆开看：你要自己管多少东西，为什么只存一个 &lt;code&gt;run_id&lt;/code&gt; 不够，以及怎样把审批绑到具体版本上。&lt;/p&gt;
&lt;h2 id="二正常主路径一个-run-的生命线靠两样东西跨进程传递"&gt;二、正常主路径：一个 Run 的生命线靠两样东西跨进程传递
&lt;/h2&gt;&lt;p&gt;先从正常路径走一遍——不做故障注入，走整条 &lt;code&gt;start → approve → resume&lt;/code&gt; 时间线。关键机制只有两个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;SQLite 持久化 checkpoints。&lt;/strong&gt; 同一个 &lt;code&gt;run_id&lt;/code&gt; 在不同进程里打开同一份 &lt;code&gt;.db&lt;/code&gt; 文件，读到的是同一个持久 Run 的状态和 Trace（审计日志）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;新 &lt;code&gt;resume&lt;/code&gt; 调用推进同一个 Run，不创建一个新的。&lt;/strong&gt; &lt;code&gt;start run-1&lt;/code&gt; 创建了一个持久 Run。进程退出后再开一个新进程，再执行 &lt;code&gt;resume run-1&lt;/code&gt;——这是一次新的恢复调用，但它读到的、推进的，是 SQLite 中同一个 &lt;code&gt;run_id=run-1&lt;/code&gt; 的记录，不是重新 &lt;code&gt;start&lt;/code&gt; 一个新 Run。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;用一张时序图把这条主线跑通：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
sequenceDiagram
 autonumber
 participant C as Client
 participant R as Runtime
 participant DB as SQLite
 participant GW as Action Gateway

 Note over C,DB: 进程 A
 C-&gt;&gt;R: start run-1
 R-&gt;&gt;DB: INSERT checkpoint&lt;br/&gt;(run-1, WAITING_FOR_USER)
 R--&gt;&gt;C: run_id=run-1, proposal_hash

 C-&gt;&gt;R: approve run-1 --proposal-hash &lt;hash&gt;
 R-&gt;&gt;DB: UPDATE status=APPROVED&lt;br/&gt;save approval_request_id + approval_hash
 R--&gt;&gt;C: approved

 Note over C,GW: 进程退出

 Note over C,GW: 进程 B（新进程）
 C-&gt;&gt;R: resume run-1
 R-&gt;&gt;DB: SELECT run_id=run-1&lt;br/&gt;校验 request_id + proposal_hash
 R-&gt;&gt;GW: execute(write_draft, args)
 GW--&gt;&gt;R: 写入成功（幂等键保护）
 R-&gt;&gt;DB: UPDATE status=COMPLETED
 R--&gt;&gt;C: executed&lt;/pre&gt;&lt;p&gt;虚线标出进程边界，但 &lt;code&gt;run_id&lt;/code&gt; 不变——跨进程恢复的是同一个持久 Run 的状态，不是「重新创建一个」。图中 &lt;code&gt;Action Gateway&lt;/code&gt; 是动作准入与副作用执行的统一入口，沿用 C11 的同一个概念：Runtime 校验完审批绑定后，把动作交给它执行；demo 里它物理上就是 &lt;code&gt;Store&lt;/code&gt; 的 &lt;code&gt;resume&lt;/code&gt; 方法内部那几行写入逻辑，不是独立进程，生产系统才会把它拆成独立组件。&lt;/p&gt;
&lt;p&gt;动手跑一下：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# GYA 仓库 clone 下来后，进到 c12-durable-resume/python&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 进程 A&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; python
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt show-trace run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 从 JSON 输出复制 proposal_hash&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-1 --proposal-hash &lt;span class="s1"&gt;&amp;#39;&amp;lt;hash&amp;gt;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# --- 退出终端、下班、第二天回来 ---&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 进程 B&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt show-trace run-1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Java 侧同语义（GYA-Java 仓库根目录下执行）：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c12-durable-resume:run --no-daemon &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/tmp/c12-java.db /tmp/c12-java-draft.txt start run-1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ... approve ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c12-durable-resume:run --no-daemon &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/tmp/c12-java.db /tmp/c12-java-draft.txt resume run-1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;正常路径下，&lt;code&gt;show-trace&lt;/code&gt; 的 Trace 链为：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;checkpoint_saved（保存检查点） → proposal_recorded（记录提案）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ proposal_verified（提案验证通过） → approval_recorded（记录审批）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ proposal_verified（恢复时再次验证） → resume_started（开始恢复）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ action_executed（动作已执行）
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;两端的 &lt;code&gt;proposal_hash&lt;/code&gt; 同为 &lt;code&gt;2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573&lt;/code&gt;（对「action + args + output」三者一起哈希得到的值，output 取 &lt;code&gt;/tmp/c12-draft.txt&lt;/code&gt;）。这条 Trace 链里反复出现的 &lt;code&gt;proposal_verified&lt;/code&gt;，就是恢复时那道「拿现在对当时」的检查在打点。&lt;/p&gt;
&lt;h2 id="三关键机制批准的那一刻把批准了什么冻成两份快照"&gt;三、关键机制：批准的那一刻，把「批准了什么」冻成两份快照
&lt;/h2&gt;&lt;p&gt;第二节走完正常路径时，&lt;code&gt;runs&lt;/code&gt; 表里其实只发生了一件事，理解它就理解了整个恢复协议：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;用户点「批准」的那一刻，程序把「批准了什么」冻结下来。&lt;/strong&gt; 冻结两份：一份是「批准的是哪一次请求」，一份是「批准的是哪个动作」。这里说的「动作」不是只有 &lt;code&gt;action&lt;/code&gt; 名字——它包含三样：做什么（&lt;code&gt;action&lt;/code&gt;）、带什么参数（&lt;code&gt;args&lt;/code&gt;）、写到哪（&lt;code&gt;output&lt;/code&gt; 路径）。三者一起被哈希，缺一个「批准」就不完整。之后无论隔了多久、换了几个进程，恢复时都拿「现在是什么」去对「当时批准的是什么」，对不上就拒绝。&lt;/p&gt;
&lt;p&gt;先把这两个「对不上就拒」的检查看成一个动作，感受一下它的逻辑，再去看字段名会清楚很多：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;恢复时问自己两句话：
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1. 现在这个 request，还是不是我批准的那个？
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2. 现在这个动作（做什么 + 带什么参数 + 写到哪），还是不是我批准的那个版本？
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 任何一句答「不是」→ 拒绝，不执行。
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;落到 SQLite 里，就是下面这组「当前值 vs 批准值」的对照。左边是每次读取时重新算出来的「现在」，右边是批准那一刻冻结的「当时」：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;检查&lt;/th&gt;
					&lt;th&gt;现在的值（恢复时重算）&lt;/th&gt;
					&lt;th&gt;批准时冻结的值&lt;/th&gt;
					&lt;th&gt;对不上就报错&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;请求对不对&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;request_id&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;approval_request_id&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;approval_request_mismatch&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;动作版本对不对&lt;/td&gt;
					&lt;td&gt;重算 action/args/output 的 SHA-256&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;approval_hash&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;proposal_hash_mismatch&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;写到哪对不对&lt;/td&gt;
					&lt;td&gt;本次传入的 &lt;code&gt;--output&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;表中冻结的 &lt;code&gt;output&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;output_mismatch&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;注意 &lt;code&gt;proposal_hash&lt;/code&gt; 不在这张表里单独出现——它是 &lt;code&gt;start&lt;/code&gt; 时算好、随提案一起存进去的原始哈希，属于「这个 Run 本来要做什么」的一部分，而「批准时冻结」的那份快照叫 &lt;code&gt;approval_hash&lt;/code&gt;。两个 hash 名字像，但角色不同：&lt;code&gt;proposal_hash&lt;/code&gt; 是「提案长这样」，&lt;code&gt;approval_hash&lt;/code&gt; 是「你批准的就是这样」。第二节 CLI 里让你手动传的那个 &lt;code&gt;--proposal-hash&lt;/code&gt;，就是拿「现在」去核对「提案」用的；而防「批准后被篡改」靠的是 &lt;code&gt;approval_hash&lt;/code&gt;，第四节实验二会单独验证它。&lt;/p&gt;
&lt;p&gt;特别说明为什么 &lt;code&gt;output&lt;/code&gt; 要进哈希：如果它不进，「批准写 A 文件」这件事就没被锁住，恢复时换成「写 B 文件」照样能通过审批校验——&lt;code&gt;output&lt;/code&gt; 这个副作用目标就成了一处没人管的缝。把它和 &lt;code&gt;action&lt;/code&gt;、&lt;code&gt;args&lt;/code&gt; 一起哈希，三样里动任何一个，重算的 hash 都会变，对不上冻结的 &lt;code&gt;approval_hash&lt;/code&gt; 就拒。&lt;/p&gt;
&lt;p&gt;除此之外还有两个字段，解决的不是「对不对」，而是「找得到、别重做」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;run_id&lt;/code&gt; + &lt;code&gt;status&lt;/code&gt;：新进程靠它定位到哪一行、停在哪一步。这是「找得到」的最低要求。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;executed&lt;/code&gt;：动作有没有已经做过。做完后重复 &lt;code&gt;resume&lt;/code&gt; 直接返回 &lt;code&gt;already_completed&lt;/code&gt;，不重写文件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;状态本身只有四个合法值：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;状态&lt;/th&gt;
					&lt;th&gt;中文含义&lt;/th&gt;
					&lt;th&gt;合法来源&lt;/th&gt;
					&lt;th&gt;合法出口&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;WAITING_FOR_USER&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;等待用户&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;start&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;approve&lt;/code&gt; / &lt;code&gt;reject&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;APPROVED&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;已批准&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;approve&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;resume&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;REJECTED&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;已拒绝&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;reject&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;终止&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;COMPLETED&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;已完成&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;resume&lt;/code&gt; 完成动作后&lt;/td&gt;
					&lt;td&gt;重复恢复 → &lt;code&gt;already_completed&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这四张状态加两组快照，就是整个 checkpoint 的全部内容。两组快照守不守得住，用篡改实验直接验；&lt;code&gt;executed&lt;/code&gt; 兜底守不守得住，用重放实验验。&lt;/p&gt;
&lt;h2 id="四旧审批为什么不能放行新的-request-或新参数"&gt;四、旧审批为什么不能放行新的 request 或新参数
&lt;/h2&gt;&lt;p&gt;上一节把「批准时冻两份快照」立起来了。现在直接篡改数据库，看这层保护到底拦不拦得住。三个实验各回答「防找错对象」里的一个问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验一：批准之后，把 request 换成另一个。&lt;/strong&gt; 正常 &lt;code&gt;start → approve&lt;/code&gt;，然后直接改 SQLite，把 &lt;code&gt;request_id&lt;/code&gt; 从 &lt;code&gt;approval:run-1&lt;/code&gt; 改成 &lt;code&gt;approval:run-2&lt;/code&gt;——模拟「用户批准的是 A，恢复时却拿着 B 来要执行」。中间那行 &lt;code&gt;UPDATE&lt;/code&gt; 就是这次篡改本身：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-exp1.txt start run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-exp1.txt approve run-1 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --proposal-hash &lt;span class="s1"&gt;&amp;#39;af45d70539c0998f4e3071d23e5501ccd207fb1dd5fa1d3d758307d84b74a8b9&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sqlite3 /tmp/c12-exp1.db &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;UPDATE runs SET request_id=&amp;#39;approval:run-2&amp;#39; WHERE run_id=&amp;#39;run-1&amp;#39;;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-exp1.txt resume run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → approval_request_mismatch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;resume&lt;/code&gt; 发现现在的 &lt;code&gt;request_id&lt;/code&gt; 不等于批准时冻结的 &lt;code&gt;approval_request_id&lt;/code&gt;，拒绝。动作没执行，文件没创建。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验二：把动作内容偷偷换掉，还顺手把 hash 也改成新的。&lt;/strong&gt; 这是更狡猾的篡改——不只是改 &lt;code&gt;args&lt;/code&gt;，还重新算一个和篡改内容配套的 &lt;code&gt;proposal_hash&lt;/code&gt;，让「提案」这一层看起来自洽。但批准时冻结的 &lt;code&gt;approval_hash&lt;/code&gt; 还是篡改前的旧值，改不了：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 先正常 start + approve&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12-exp2.db --output /tmp/c12-exp2.txt start run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12-exp2.db --output /tmp/c12-exp2.txt approve run-1 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --proposal-hash &lt;span class="s1"&gt;&amp;#39;bd3de4cee4ccf82fb98239c875d50dac6455907c76e67cd267949412d08cfe0c&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 篡改者算好篡改内容的新 hash（output 是 start 时冻结的 /tmp/c12-exp2.txt）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;import hashlib,json
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;malicious=json.dumps({&amp;#39;action&amp;#39;:&amp;#39;write_draft&amp;#39;,&amp;#39;args&amp;#39;:{&amp;#39;content&amp;#39;:&amp;#39;unauthorized change&amp;#39;},&amp;#39;output&amp;#39;:&amp;#39;/tmp/c12-exp2.txt&amp;#39;},sort_keys=True,separators=(&amp;#39;,&amp;#39;,&amp;#39;:&amp;#39;))
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;print(hashlib.sha256(malicious.encode()).hexdigest())
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → f92f11517a438bc7384f1486dd4f98e8613dccb0cf760946cb3da118051108a0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 把 args 和 proposal_hash 一起换成篡改版&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sqlite3 /tmp/c12-exp2.db &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;UPDATE runs SET args=&amp;#39;{\&amp;#34;content\&amp;#34;:\&amp;#34;unauthorized change\&amp;#34;}&amp;#39;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; proposal_hash=&amp;#39;f92f11517a438bc7384f1486dd4f98e8613dccb0cf760946cb3da118051108a0&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; WHERE run_id=&amp;#39;run-1&amp;#39;;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12-exp2.db --output /tmp/c12-exp2.txt resume run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → proposal_hash_mismatch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;恢复时重算当前动作的 hash，得到 &lt;code&gt;f92f1151...&lt;/code&gt;，它确实和篡改后的 &lt;code&gt;proposal_hash&lt;/code&gt; 一致——但和批准时冻结的 &lt;code&gt;approval_hash&lt;/code&gt;（&lt;code&gt;bd3de4ce...&lt;/code&gt;）不一致，于是拒绝。这个实验单独证明的是：&lt;strong&gt;&lt;code&gt;approval_hash&lt;/code&gt; 是一道独立的锁&lt;/strong&gt;。篡改者可以把 &lt;code&gt;proposal_hash&lt;/code&gt; 改得再自洽，也过不了「和你批准的那个版本一致」这一关。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验三：换一个输出路径。&lt;/strong&gt; 既然 output 已经进了 proposal，那「写到哪里」也是被批准的一部分。恢复时故意传一个不同的 &lt;code&gt;--output&lt;/code&gt;，等于篡改副作用目标，同样被拦：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12-exp1.db --output /tmp/c12-other.txt resume run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → output_mismatch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这一条和实验一、实验二本质相同——&lt;code&gt;output&lt;/code&gt; 和 &lt;code&gt;request_id&lt;/code&gt;、&lt;code&gt;action/args&lt;/code&gt; 一样，都是「批准了什么」的一部分，动了任何一个都过不了恢复时的核对。&lt;/p&gt;
&lt;p&gt;两个实验（加第三个）合起来，正好对应第三节那张「当前值 vs 批准值」表。下面这张表把三种设计放在一起，看漏掉哪一份快照会放行哪种篡改：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;设计&lt;/th&gt;
					&lt;th&gt;批准后替换 request_id&lt;/th&gt;
					&lt;th&gt;批准后替换 action/args 或 output&lt;/th&gt;
					&lt;th&gt;结论&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;只绑 &lt;code&gt;run_id&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;误放行&lt;/td&gt;
					&lt;td&gt;误放行&lt;/td&gt;
					&lt;td&gt;不可接受&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;只存 &lt;code&gt;proposal_hash&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;误放行（request 变了但 action 没变）&lt;/td&gt;
					&lt;td&gt;拒绝&lt;/td&gt;
					&lt;td&gt;只覆盖一半&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;approval_request_id&lt;/code&gt; + &lt;code&gt;approval_hash&lt;/code&gt; 双绑 + 恢复时重算&lt;/td&gt;
					&lt;td&gt;拒绝&lt;/td&gt;
					&lt;td&gt;拒绝&lt;/td&gt;
					&lt;td&gt;覆盖全部&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;边界要说清：这套保护假设 SQLite 是可信存储。能同时改写 proposal 和 approval 两行记录的人，等于已经攻破了数据库，本文不防。本文防的是更常见的场景：&lt;strong&gt;批准之后、恢复之前，request 或 action/args/output 被人不经过审批地偷偷换掉&lt;/strong&gt;——双绑 + 恢复时重算，能在这条边界内守住。&lt;/p&gt;
&lt;h2 id="五完成后的重复调用与故障窗口是两种重放"&gt;五、完成后的重复调用与故障窗口是两种重放
&lt;/h2&gt;&lt;p&gt;第四节解决了「找错对象」。现在解决三件事里的第二件——&lt;strong&gt;重复执行&lt;/strong&gt;：同一个 &lt;code&gt;resume&lt;/code&gt; 被调两次，怎么保证动作只做一次。&lt;/p&gt;
&lt;p&gt;这里其实有两个不同的「再调一次」，别混在一起：一次是&lt;strong&gt;动作已经完整做完&lt;/strong&gt;之后的重放，一次是&lt;strong&gt;动作做到一半进程就挂了&lt;/strong&gt;的重放。前者有 &lt;code&gt;executed&lt;/code&gt; 标记兜底，后者才是真正麻烦的地方。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;场景一：正常完成后重复 &lt;code&gt;resume&lt;/code&gt;。&lt;/strong&gt; 文件已创建，checkpoint 已更新为 &lt;code&gt;COMPLETED&lt;/code&gt;，用户再调一次 &lt;code&gt;resume run-1&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → already_completed&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;程序看到 &lt;code&gt;executed=1&lt;/code&gt;，直接返回，不再写文件。Trace 记一条 &lt;code&gt;action_replayed&lt;/code&gt;，文件修改时间不变。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;场景二：故障窗口恢复——文件已创建，checkpoint 还没更新成 COMPLETED。&lt;/strong&gt; 这才是协议最痛的一刻：动作本身有副作用（写文件），而「记下动作已完成」这个操作本身是另一步，两步之间有一道缝。缝里挂了，数据库以为没做，文件其实已经写了。先把这道缝画出来：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
stateDiagram-v2
 [*] --&gt; WAITING: start
 WAITING --&gt; APPROVED: approve ✓
 WAITING --&gt; REJECTED: reject ✗
 APPROVED --&gt; COMPLETED: resume → 写文件 + 写 checkpoint

 note right of APPROVED
 故障窗口在此：
 文件已创建、checkpoint 未更新
 重试 → recovered / output_conflict
 end note&lt;/pre&gt;&lt;p&gt;这张图只有三条实线状态转换（图中用短名 &lt;code&gt;WAITING&lt;/code&gt;/&lt;code&gt;APPROVED&lt;/code&gt;/&lt;code&gt;REJECTED&lt;/code&gt;/&lt;code&gt;COMPLETED&lt;/code&gt; 分别对应 &lt;code&gt;WAITING_FOR_USER&lt;/code&gt;（等待用户）/&lt;code&gt;APPROVED&lt;/code&gt;（已批准）/&lt;code&gt;REJECTED&lt;/code&gt;（已拒绝）/&lt;code&gt;COMPLETED&lt;/code&gt;（已完成））——其余场景（未批准恢复、proposal 被替换、request 被替换、重复审批、完成后重复恢复、内容冲突）都不发生状态转换，直接抛异常并在 Trace 中留 &lt;code&gt;proposal_rejected&lt;/code&gt;、&lt;code&gt;resume_rejected&lt;/code&gt;、&lt;code&gt;transition_rejected&lt;/code&gt; 或 &lt;code&gt;action_conflict&lt;/code&gt; 事件。&lt;/p&gt;
&lt;p&gt;关系说清楚后，回到那行最要命的位置：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;action_executed (文件已写入)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓ 此刻崩溃（进程挂了、断电、kill -9）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;STATUS != COMPLETED (checkpoint 还没来得及更新)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;新进程启动后，&lt;code&gt;resume&lt;/code&gt; 看到 &lt;code&gt;status=APPROVED&lt;/code&gt;、&lt;code&gt;executed=0&lt;/code&gt;，于是按正常流程准备写文件——但 &lt;code&gt;FileExistsError&lt;/code&gt;（Python）或 &lt;code&gt;FileAlreadyExistsException&lt;/code&gt;（Java）告诉它：文件已经在了。&lt;/p&gt;
&lt;p&gt;此时不是直接覆写，而是比较内容：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;FileExistsError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;utf-8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;output_conflict&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;recovered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;如果内容相同 → 返回 &lt;code&gt;recovered&lt;/code&gt;，Trace 留 &lt;code&gt;action_recovered&lt;/code&gt;，更新 &lt;code&gt;executed=1&lt;/code&gt;、&lt;code&gt;status=COMPLETED&lt;/code&gt;，文件修改时间不变。&lt;/p&gt;
&lt;p&gt;如果内容不同 → 返回 &lt;code&gt;output_conflict&lt;/code&gt;，Trace 留 &lt;code&gt;action_conflict&lt;/code&gt;，附带同维度的两份内容摘要——&lt;code&gt;expected_content_hash&lt;/code&gt;（本应写入的内容的 SHA-256）和 &lt;code&gt;existing_content_hash&lt;/code&gt;（已存在文件内容的 SHA-256），原文件不被覆盖。这是在说：&lt;strong&gt;同一个幂等键（文件路径）被另一个来源写了不同的内容——这不是故障恢复，是冲突。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;测试里用 &lt;code&gt;fail_after_action=True&lt;/code&gt; 精确注入这个窗口：文件创建后立即抛 &lt;code&gt;RuntimeError(&amp;quot;simulated_crash_after_action&amp;quot;)&lt;/code&gt;，不执行后续的 checkpoint 更新。新实例恢复时不容错地命中 &lt;code&gt;recovered&lt;/code&gt; 路径。&lt;/p&gt;
&lt;h2 id="六动手跑一遍从-cli-到失败路径全遍历"&gt;六、动手跑一遍：从 CLI 到失败路径全遍历
&lt;/h2&gt;&lt;p&gt;本节是完整的运行指南。核心只有一句话：&lt;strong&gt;这里的 &lt;code&gt;write_draft&lt;/code&gt; proposal 是固定的测试夹具，代替上任上游 LLM 的输出，所以不需要 API Key。&lt;/strong&gt; 它只输出 &lt;code&gt;&amp;quot;approved technical brief&amp;quot;&lt;/code&gt; 这一句话到指定文件；不碰 &lt;code&gt;drafts/&lt;/code&gt;、&lt;code&gt;published/&lt;/code&gt; 或 &lt;code&gt;my-blog&lt;/code&gt; 目录。&lt;/p&gt;
&lt;h3 id="python-311标准库-sqlite3"&gt;Python 3.11+（标准库 sqlite3）
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 在 GYA 仓库 clone 下来的根目录下&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; c12-durable-resume/python
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 1. 运行全部测试&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;PYTHONPYCACHEPREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/private/tmp/c12-pycache python3 -B -m unittest -v
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 10/10 通过&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 2. 正常主路径&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → {&amp;#34;run_id&amp;#34;:&amp;#34;run-1&amp;#34;,&amp;#34;status&amp;#34;:&amp;#34;WAITING_FOR_USER&amp;#34;,...}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt show-trace run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 复制 proposal_hash 字段值&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-1 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --proposal-hash &lt;span class="s1"&gt;&amp;#39;2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 模拟换新进程：直接调 resume&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → &amp;#34;result&amp;#34;:&amp;#34;executed&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /tmp/c12-draft.txt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → approved technical brief&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → &amp;#34;result&amp;#34;:&amp;#34;already_completed&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="失败路径"&gt;失败路径
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 未批准恢复&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → approval_required&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 批准时 hash 不一致&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-2 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --proposal-hash &lt;span class="s1"&gt;&amp;#39;0000000000000000000000000000000000000000000000000000000000000000&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → proposal_hash_mismatch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 拒绝后再次批准&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt reject run-2 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --proposal-hash &lt;span class="s1"&gt;&amp;#39;2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-2 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --proposal-hash &lt;span class="s1"&gt;&amp;#39;2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → approval_not_pending&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → approval_rejected&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 内容冲突&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;different content&amp;#34;&lt;/span&gt; &amp;gt; /tmp/c12-draft.txt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt start run-3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt approve run-3 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --proposal-hash &lt;span class="s1"&gt;&amp;#39;2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --db /tmp/c12.db --output /tmp/c12-draft.txt resume run-3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → output_conflict&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /tmp/c12-draft.txt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# → different content（原内容未被覆盖）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;批准后 request/proposal 替换与故障窗口恢复&lt;/strong&gt;这两条路径涉及直接改 SQLite 或注入崩溃，不便用 CLI 逐条演示，改由单元测试覆盖（见第四节和第五节的 &lt;code&gt;sqlite3&lt;/code&gt; 篡改命令，以及 &lt;code&gt;test_main.py&lt;/code&gt; 里的 &lt;code&gt;test_post_approval_request_replacement_is_rejected&lt;/code&gt;、&lt;code&gt;test_post_approval_storage_tampering_is_rejected&lt;/code&gt;、&lt;code&gt;test_crash_after_file_creation_is_recovered_without_rewrite&lt;/code&gt;）。完整命令见第四节实验一/实验二，故障窗口恢复的注入点见第五节。测试定位方式：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 批准后替换 request_id → 恢复被拒&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -B -m unittest test_main.DurableResumeTest.test_post_approval_request_replacement_is_rejected -v
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 批准后同时替换 args + proposal_hash → 恢复被拒&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -B -m unittest test_main.DurableResumeTest.test_post_approval_storage_tampering_is_rejected -v
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 文件创建后注入崩溃 → 恢复返回 recovered，不重写文件&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -B -m unittest test_main.DurableResumeTest.test_crash_after_file_creation_is_recovered_without_rewrite -v
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="java-21"&gt;Java 21
&lt;/h3&gt;&lt;p&gt;Java 版本在 GYA-Java 仓库，用仓库根的 Gradle Wrapper，需要指定 JDK 21。&lt;strong&gt;Java CLI 使用位置参数而非 &lt;code&gt;--proposal-hash&lt;/code&gt; 选项：&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;JAVA_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 在 GYA-Java 仓库 clone 下来的根目录下&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c12-durable-resume:test --no-daemon
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# BUILD SUCCESSFUL, 10/10 通过&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# output 统一用 /tmp/c12-draft.txt，与 Python 侧一致，保证两端 proposal_hash 相等&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c12-durable-resume:run --no-daemon &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/tmp/c12-java.db /tmp/c12-draft.txt start run-1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c12-durable-resume:run --no-daemon &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/tmp/c12-java.db /tmp/c12-draft.txt show-trace run-1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 复制 proposal_hash，传给 approve 的五个位置参数的第 5 位：&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c12-durable-resume:run --no-daemon &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/tmp/c12-java.db /tmp/c12-draft.txt approve run-1 2bba1bb36ae552812b4b9e0d730249a3447adfc3ed100e0f4d92e3a8c28e7573&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c12-durable-resume:run --no-daemon &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/tmp/c12-java.db /tmp/c12-draft.txt resume run-1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;两端的 Trace 遵循同一份事件语义约定：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;checkpoint_saved → proposal_recorded
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ proposal_verified → approval_recorded
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ proposal_verified → resume_started
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ action_executed | action_recovered
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;负向路径按原因产生 &lt;code&gt;proposal_rejected&lt;/code&gt;、&lt;code&gt;resume_rejected&lt;/code&gt;、&lt;code&gt;transition_rejected&lt;/code&gt; 或 &lt;code&gt;action_conflict&lt;/code&gt;。Python 和 Java 的 JSON 字段顺序不要求一致，但事件语义、原因和先后关系一致，两端 &lt;code&gt;proposal_hash&lt;/code&gt; 完全相等。&lt;/p&gt;
&lt;h3 id="常见坑"&gt;常见坑
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Gradle 提示 JDK 25 不兼容。&lt;/strong&gt; 错误信息为 &lt;code&gt;IllegalArgumentException: 25.0.3&lt;/code&gt;。本机默认 JDK 是 25，Gradle 8.14.2 不兼容。确认 &lt;code&gt;JAVA_HOME&lt;/code&gt; 指向 JDK 21 即可：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;JAVA_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;/usr/libexec/java_home -v 21&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Python 找不到 &lt;code&gt;sqlite3&lt;/code&gt; 模块。&lt;/strong&gt; Python 需编译时带 SQLite 支持，绝大多数发行版和 macOS 自带——如果报 &lt;code&gt;ModuleNotFoundError: No module named '_sqlite3'&lt;/code&gt;，用包管理器重新安装带 sqlite 支持的 Python。&lt;/p&gt;
&lt;h2 id="七从本地文件幂等走向生产副作用"&gt;七、从本地文件幂等走向生产副作用
&lt;/h2&gt;&lt;p&gt;SQLite 的 &lt;code&gt;BEGIN; ...; COMMIT&lt;/code&gt; 只保证数据库内部的原子性。&lt;code&gt;resume&lt;/code&gt; 里「创建文件 → 更新 checkpoint」这两步，&lt;strong&gt;不在同一个事务里&lt;/strong&gt;——文件系统是数据库外面的世界。&lt;/p&gt;
&lt;p&gt;本文的解法是&lt;strong&gt;业务幂等键&lt;/strong&gt;：文件路径 &lt;code&gt;output&lt;/code&gt; 就是幂等键——它同时是 proposal 里被哈希锁定的不可变部分（第三节），也是执行时判断「是否已经写过」的依据。&lt;code&gt;FileExistsError&lt;/code&gt;（Python）/ &lt;code&gt;FileAlreadyExistsException&lt;/code&gt;（Java）只表示目标已存在，不是去重——demo 在捕获异常后显式比较内容：相同时视为 &lt;code&gt;recovered&lt;/code&gt;，不同时回绝 &lt;code&gt;output_conflict&lt;/code&gt; 并在 Trace 中记录 &lt;code&gt;expected_content_hash&lt;/code&gt;（本应写入内容的 SHA-256）和 &lt;code&gt;existing_content_hash&lt;/code&gt;（已存在文件内容的 SHA-256），两者对同一份字节内容做摘要、可直接对照。关键条件是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;创建使用 &lt;code&gt;CREATE_NEW&lt;/code&gt; / &lt;code&gt;&amp;quot;x&amp;quot;&lt;/code&gt; 模式——已存在时抛异常，不会静默覆盖。&lt;/li&gt;
&lt;li&gt;内容不同时显式回绝 &lt;code&gt;output_conflict&lt;/code&gt;——不是说「已存在就是成功」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;生产系统要补的东西跟这篇 demo 没有本质冲突，但代价不同。下面这张表里的「生产系统」一列，是超出本文验证范围的&lt;strong&gt;设计候选&lt;/strong&gt;，不是已跑通的实现——本文只证明左边「本文 demo」一列：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;本文 demo&lt;/th&gt;
					&lt;th&gt;生产系统（设计候选，未验证）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;幂等键来源&lt;/td&gt;
					&lt;td&gt;固定文件路径（&lt;code&gt;CREATE_NEW&lt;/code&gt;）+ 纳入 proposal 哈希&lt;/td&gt;
					&lt;td&gt;业务幂等键（订单号、消息 ID）+ 目标系统去重&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;恢复策略&lt;/td&gt;
					&lt;td&gt;文件内容比较&lt;/td&gt;
					&lt;td&gt;outbox + inbox 模式、目标端幂等写入 + 查询&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;审批记录&lt;/td&gt;
					&lt;td&gt;SQLite 单库（可信边界）&lt;/td&gt;
					&lt;td&gt;审计日志表 + 分布式锁 + 签名/加密（防篡改）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;多实例竞争&lt;/td&gt;
					&lt;td&gt;不涉及（本地单实例，无并发）&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 或乐观锁&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;审批超时/取消&lt;/td&gt;
					&lt;td&gt;不涉及&lt;/td&gt;
					&lt;td&gt;过期策略 + 补偿&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;核心判断是：&lt;strong&gt;SQLite 事务只管得了数据库内部。&lt;/strong&gt; 它不能包住「把文件写出去」这个副作用。一条 &lt;code&gt;INSERT&lt;/code&gt; 和一次 &lt;code&gt;os.write()&lt;/code&gt; 之间，隔着一道事务边界。本文只证明了&lt;strong&gt;这一层本地文件幂等&lt;/strong&gt;——&lt;code&gt;CREATE_NEW&lt;/code&gt; 加内容比对，能在「文件已存在」时区分「上次已成功」和「内容冲突」。至于跨系统、跨进程、跨服务的端到端 exactly-once，涉及目标端去重、分布式事务或 outbox 等本文没有实现的机制，不在这里下结论。右侧每个候选方案具体怎么落地、在哪些场景失效，是后续工程化扩展的话题，本文不展开。&lt;/p&gt;
&lt;h2 id="八回到开头的那三个洞"&gt;八、回到开头的那三个洞
&lt;/h2&gt;&lt;p&gt;这篇文章从头到尾只做了一件事：在 C11 证明「当前 Run 能停」之后，证明「同一个 Run 跨进程还能安全地继续」。它不靠框架、不用 API Key、不扯分布式——只靠 SQLite 单库 + 两组快照 + 本地文件幂等键。把开头的结论收回来，三个洞各有一个明确的堵法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;找错对象。&lt;/strong&gt; 批准那一刻冻结 &lt;code&gt;approval_request_id&lt;/code&gt;（批准的是哪次请求）和 &lt;code&gt;approval_hash&lt;/code&gt;（批准的是哪个动作，含 action/args/output）两份快照，恢复时拿「现在」去对「当时」，对不上就拒——第四节的实验一、实验二、实验三分别验证了这两份快照各自挡得住什么。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;重复执行。&lt;/strong&gt; 正常重放靠 &lt;code&gt;executed&lt;/code&gt; 标记兜底；最痛的那道缝（文件写了、checkpoint 没写）靠文件幂等键兜底——第五节的两个场景分别验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无声失败。&lt;/strong&gt; 每次拒绝都在 Trace 里留一条带 &lt;code&gt;reason&lt;/code&gt; 的事件，附上被拒的原始输入（尝试的决定、提交的 hash、当前的 request/action/output），审计时能还原「拒了什么、为什么拒」。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;三件事讲完，这套恢复协议在&lt;strong&gt;本地单实例、无并发竞争&lt;/strong&gt;的边界内是自洽的：一份 SQLite、一个待执行动作、一个用户在操作，恢复靠「换一个新进程、打开同一份 &lt;code&gt;.db&lt;/code&gt;」来完成——这跟「进程会退出、第二天再回来」的场景正好对上。往边界外走一步——多个 Agent 拆成不同角色协同，一个用户批准了 Agent A 的请求，Agent B 怎么知道？同一个 &lt;code&gt;run_id&lt;/code&gt; 跨 Agent 传递会不会串？这份「冻快照 + 幂等键 + Trace」的协议，在多 Agent 世界里还能不能原样成立，是下一篇要回答的问题。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="参考资料"&gt;参考资料
&lt;/h2&gt;&lt;p&gt;正文引用的来源，按首次出现顺序排列：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LangGraph. Interrupts. &lt;a class="link" href="https://docs.langchain.com/oss/python/langgraph/interrupts" target="_blank" rel="noopener"
 &gt;https://docs.langchain.com/oss/python/langgraph/interrupts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;LangGraph. Persistence. &lt;a class="link" href="https://docs.langchain.com/oss/python/langgraph/persistence" target="_blank" rel="noopener"
 &gt;https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;SQLite. Transaction. &lt;a class="link" href="https://www.sqlite.org/lang_transaction.html" target="_blank" rel="noopener"
 &gt;https://www.sqlite.org/lang_transaction.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;SQLite. Atomic Commit. &lt;a class="link" href="https://www.sqlite.org/atomiccommit.html" target="_blank" rel="noopener"
 &gt;https://www.sqlite.org/atomiccommit.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;配套 Python 与 Java 实现在 &lt;a class="link" href="https://github.com/renxin2024/GYA/tree/main/c12-durable-resume" target="_blank" rel="noopener"
 &gt;GYA 仓库&lt;/a&gt;与 &lt;a class="link" href="https://github.com/renxin2024/GYA-Java/tree/main/c12-durable-resume" target="_blank" rel="noopener"
 &gt;GYA-Java 仓库&lt;/a&gt;的 &lt;code&gt;c12-durable-resume&lt;/code&gt; 目录，按 README 的命令跑，不复制这篇文章里零散的代码块。&lt;/p&gt;</description></item></channel></rss>