<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>HITL on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/hitl/</link><description>Recent content in HITL 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/hitl/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><item><title>第十一话｜Agent 为什么会停下来：从模型信号到 Runtime 状态迁移</title><link>https://zh.renxinblog.cn/post/c11-suspend-protocol/</link><pubDate>Sat, 04 Jul 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/c11-suspend-protocol/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/c11-suspend-protocol-cover.png" alt="Featured image of post 第十一话｜Agent 为什么会停下来：从模型信号到 Runtime 状态迁移" /&gt;&lt;p&gt;给一个 Agent 下达这样的任务：&lt;strong&gt;根据给定主题和本地资料，整理一份有依据的技术文章创作纲领，然后停下来等用户确认；确认之前，不生成正文草稿。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 Codex、WorkBuddy 这类产品里，这个任务的表现可以直接观察到：它检索、读资料、写出一份纲领，然后&lt;strong&gt;停下&lt;/strong&gt;，把纲领和一句「是否继续」摆在你面前；你回复之后，它接着往下做。&lt;/p&gt;
&lt;p&gt;停这件事看起来理所当然，其实需要解释。模型的输出是一段文本或一段 JSON。它在里面写「请确认」四个字，不会让 Python 的 &lt;code&gt;while&lt;/code&gt; 循环条件变成 False，也不会让 Java 的线程进入等待。语言表达能力，和程序控制流，是两码事。&lt;/p&gt;
&lt;p&gt;上一话把问题停在「下一步到底由谁拍板」。这个问题的答案里有一类决定和工具动作不同：工具动作有对应的执行路径，而「停下来等用户」没有对应动作可执行——它要求的是一次&lt;strong&gt;状态变化&lt;/strong&gt;。模型已经说了「请确认」，程序为什么不像往常那样继续循环？&lt;/p&gt;
&lt;p&gt;整篇只回答这一件事：&lt;strong&gt;模型只产生候选信号，Runtime 才拥有状态迁移权。&lt;/strong&gt; 前面所有的机制、对照、证据，最后都汇到这句话上。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="一请确认不是指令"&gt;一、「请确认」不是指令
&lt;/h2&gt;&lt;p&gt;先回答第一个问题：模型说出「请确认」，为什么本身不能停掉任何东西？&lt;/p&gt;
&lt;p&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-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kc"&gt;True&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;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decide&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="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&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="c1"&gt;# 没有处理 REQUEST_USER_INPUT：它只是一行被打印出来的输出&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&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;while&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;RunStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RUNNING&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;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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;transition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&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="n"&gt;transition&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;RunStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WAITING_FOR_USER&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;return&lt;/span&gt; &lt;span class="n"&gt;transition&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;差别只有一个分支。缺陷版把决定当数据打印出来，控制流继续；正确版把决定交给 Runtime，由 Runtime 迁移状态、再由调度循环按状态退出。&lt;/p&gt;
&lt;p&gt;把这两个版本放回真实 demo 上，用同一批离线夹具各跑三种控制器，输出是这样的（数字取自本仓库 &lt;code&gt;traces/offline-python-normal.log&lt;/code&gt; 与 &lt;code&gt;traces/offline-python-naive.log&lt;/code&gt;，Java 侧逐字段一致）。为了不在一开始就淹没在代号里，先只看两列最要紧的：最终状态，和「草稿到底写没写出来」。&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;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;缺陷版 react&lt;/td&gt;
					&lt;td&gt;还在跑（RUNNING）&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;缺陷版 plan_and_execute&lt;/td&gt;
					&lt;td&gt;报完成（COMPLETED）&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;缺陷版 workflow&lt;/td&gt;
					&lt;td&gt;报完成（COMPLETED）&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;正确版 react&lt;/td&gt;
					&lt;td&gt;等用户（WAITING_FOR_USER）&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;正确版 plan_and_execute&lt;/td&gt;
					&lt;td&gt;等用户（WAITING_FOR_USER）&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;正确版 workflow&lt;/td&gt;
					&lt;td&gt;等用户（WAITING_FOR_USER）&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一张表就能看懂差别：正确版三种模式都停在「等用户」，缺陷版没有一行停对——要么还在跑，要么更糟，报了个「完成」却没写出任何东西。&lt;/p&gt;
&lt;p&gt;缺陷版那两种走法，各自说不上停：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;react&lt;/code&gt; 把预算耗光了（一路反复读资料读到 8 步用尽），状态停在 &lt;code&gt;RUNNING&lt;/code&gt;，但日志里却有一个「说过要等」的标记。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;plan_and_execute&lt;/code&gt; 越过了等待点，一路走到 &lt;code&gt;write_draft&lt;/code&gt;（写草稿），被准入检查拦下，然后以 &lt;code&gt;COMPLETED&lt;/code&gt; 收场——一份&lt;strong&gt;没有被写出来的草稿&lt;/strong&gt;配一个完成状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第二种走法值得单独看一眼：它既没有写任何东西，也没有停下来，最后还报了个完成。这就是「没有写入不等于已经暂停」的最小复现。缺陷版里那个「说过要等」的标记只是一个数据记录，它记录「模型说过要等」，不记录「循环已经退出」。&lt;/p&gt;
&lt;p&gt;结论落在这一步：语言表达本身不能暂停程序。能被处理的，是被 Runtime 识别出来的结构化信号。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="二停下来的那一刻长什么样"&gt;二、停下来的那一刻，长什么样
&lt;/h2&gt;&lt;p&gt;上一节证明了「信号不能自动停程序」。这一节反过来问：当程序&lt;strong&gt;真的&lt;/strong&gt;停下来时，我们凭什么说它停了？换句话说，什么是「停」的判据。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;status == WAITING_FOR_USER&lt;/code&gt;（状态标记为等用户）本身不构成证明。它只说明 Runtime 写了一个值；写值的那一刻，和「写完之后没有新调用」，是两件事。要证明停，得看等待之后有没有新的调用发生。&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;def&lt;/span&gt; &lt;span class="nf"&gt;is_run_suspended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ModeResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;final_state&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;model_delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;calls_after_wait&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;return&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;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;RunStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WAITING_FOR_USER&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;model_delta&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;tool_delta&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;四个条件同时成立才算停：状态是 &lt;code&gt;WAITING_FOR_USER&lt;/code&gt;、留了 pending request（待处理的请求）、等待之后没有新的模型调用、没有新的工具尝试。缺陷版会在这条判据上失败——这正是测试发现它的方式，而不是只证明正确版能通过。&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;def&lt;/span&gt; &lt;span class="nf"&gt;snapshot_wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&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="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model_calls_at_wait&lt;/span&gt; &lt;span class="o"&gt;=&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;model_calls&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_attempts_at_wait&lt;/span&gt; &lt;span class="o"&gt;=&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;tool_attempts&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这不是可有可无的一行。如果只在循环结束时同步计数，等待后的增量就会被算成全部调用——判据看起来还在测，其实已经失效。这个缺陷在重构时出现过一次，被测试逮住。&lt;/p&gt;
&lt;p&gt;判据要落到一组断言上才算验收。这次 Python 与 Java 各 20 项测试，覆盖的是同一批语义：&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;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;等待后模型调用数不再增加&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;client.calls&lt;/code&gt; 在迁移之后不再变化&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;等待后业务工具尝试数不再增加&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;tool_attempts&lt;/code&gt; 在迁移之后不再变化&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;受保护写入没有被尝试&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;write_draft&lt;/code&gt; 从未成功落库&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;pending request 完整&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;request_id&lt;/code&gt; / &lt;code&gt;question&lt;/code&gt; / &lt;code&gt;state_before&lt;/code&gt; 都在&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;方法已返回调用方&lt;/td&gt;
					&lt;td&gt;调度循环以 &lt;code&gt;run_exited&lt;/code&gt; 结束，不是预算耗尽&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;非法等待信号被拒&lt;/td&gt;
					&lt;td&gt;缺失状态下 &lt;code&gt;REQUEST_USER_INPUT&lt;/code&gt; 不产生迁移&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&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;signal_received ← 信号由谁产生（signal_source）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;runtime_validated ← Runtime 是否接受（runtime_decision）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;state_transition ← state_before → state_after
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pending_recorded ← pending_request_id
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;run_exited ← Scheduler 退出当前 Run
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;三个信号来源产生的都是这同一个序列，类型和顺序一致。真正回答「停了吗」的是最后一个事件：&lt;code&gt;run_exited&lt;/code&gt; 而非 &lt;code&gt;budget_exhausted&lt;/code&gt;。前者是调度器主动退出，后者是把预算用完了。&lt;/p&gt;
&lt;p&gt;到这里，我们已经知道「停」长什么样、怎么判。但还没说：&lt;strong&gt;谁有资格做那个把状态改掉的动作？&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="三agent-内部谁有资格让-run-停"&gt;三、Agent 内部，谁有资格让 Run 停
&lt;/h2&gt;&lt;p&gt;这一节回答的是全文最关键的一处分工：Agent 的模型、Runtime、调度器、界面四层，各自能做什么、不能做什么。&lt;/p&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;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&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;tr&gt;
					&lt;td&gt;Runtime&lt;/td&gt;
					&lt;td&gt;解析模型输出，校验这条信号在当前状态是否合法，决定状态迁移&lt;/td&gt;
					&lt;td&gt;不生成内容、不选业务动作&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Scheduler / Agent Loop&lt;/td&gt;
					&lt;td&gt;按 Runtime 给出的状态决定继续循环还是退出当前 Run&lt;/td&gt;
					&lt;td&gt;不解释模型语义、不做合法性判断&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;UI / Continuation&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;消息&lt;/strong&gt;（或一个领域事件）；Runtime 是&lt;strong&gt;消费者 + 状态机&lt;/strong&gt;；Scheduler 是&lt;strong&gt;任务调度器&lt;/strong&gt;；UI 是&lt;strong&gt;等待外部命令的入口&lt;/strong&gt;。消息本身不会被执行，它要被消费、被解释、被判断，才产生后果。&lt;/p&gt;
&lt;p&gt;链上只有一个位置能写 &lt;code&gt;status&lt;/code&gt;，就是 Runtime。其余三层都可以提请求，但提请求和改状态是两回事。&lt;/p&gt;
&lt;p&gt;这不是某一家的实现偏好，Anthropic 把它写进了 Messages API 的接口契约：每个响应都带一个 &lt;code&gt;stop_reason&lt;/code&gt; 字段，说明模型为什么结束生成；调用方检查这个字段，再决定是原样使用、继续对话、重试，还是回退到别的模型。字段的取值里没有任何一个是「程序已暂停」——&lt;code&gt;tool_use&lt;/code&gt; 的含义是「我在调工具，你去执行」，&lt;code&gt;end_turn&lt;/code&gt; 的含义是「我说完了」。模型交出的是一个原因码，怎么处理由外层决定。&lt;/p&gt;
&lt;p&gt;同一份契约在 &lt;code&gt;pause_turn&lt;/code&gt; 上表现得更清楚：服务端工具循环达到迭代上限时，响应会带 &lt;code&gt;pause_turn&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; ↓
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Runtime 解析决定类型
&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;Runtime 检查当前状态是否允许这条决定
&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;状态从 RUNNING 迁移到 WAITING_FOR_USER
&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;记录 pending request
&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;Scheduler 看到 WAITING，退出当前 Run
&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;UI 展示问题
&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&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;新的 Run / continuation 根据关联标识继续
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart TB
 M["模型&lt;br/&gt;只提出决定"] --&gt;|"决策"| R["Runtime&lt;br/&gt;解析 → 校验 → 迁移"]
 R --&gt;|"CONTINUE"| M
 R --&gt;|"WAITING_FOR_USER"| S["Scheduler&lt;br/&gt;退出当前 Run"]
 S --&gt; U["UI / Continuation&lt;br/&gt;展示问题、接收输入"]
 U -.-&gt;|"下一次 Run"| M&lt;/pre&gt;&lt;p&gt;最后一步「根据关联标识继续」在这里只做到单进程的程度，跨进程的部分留到下一话。&lt;/p&gt;
&lt;h3 id="信号要能被识别得先有一个共同的格式"&gt;信号要能被识别，得先有一个共同的格式
&lt;/h3&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;class&lt;/span&gt; &lt;span class="nc"&gt;DecisionType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&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;EXECUTE_TOOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;EXECUTE_TOOL&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;REQUEST_USER_INPUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;REQUEST_USER_INPUT&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FINISH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;FINISH&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;FAIL&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;模型、Planner、Workflow 产出的都是同一个结构，而不是执行结果：&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="nd"&gt;@dataclass&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ControlDecision&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="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DecisionType&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;字段的可选性是刻意的。&lt;code&gt;REQUEST_USER_INPUT&lt;/code&gt; 用 &lt;code&gt;question&lt;/code&gt;，&lt;code&gt;EXECUTE_TOOL&lt;/code&gt; 用 &lt;code&gt;action&lt;/code&gt; 和 &lt;code&gt;args&lt;/code&gt;；某一类里字段缺失是正常输入，不应该在解析阶段就崩，校验属于 Runtime 的职责。&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;while&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;RunStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RUNNING&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;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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;transition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&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="n"&gt;transition&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;SUSPEND&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;COMPLETE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;FAIL&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="k"&gt;break&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;controller.decide()&lt;/code&gt; 只提出决定；&lt;code&gt;runtime.handle()&lt;/code&gt; 才拥有状态迁移权。这句分工是后面所有代码的前提，它把「谁让 Run 停」从模型和模式里移出来，交给了一个有名字的组件。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;runtime.handle()&lt;/code&gt; 内部按五个动作走：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart TB
 A["原始输出"] --&gt; B["1-2 解析 + 类型校验&lt;br/&gt;解析失败即明确失败"]
 B --&gt; C["3 状态前置条件校验&lt;br/&gt;当前状态是否允许这条决定"]
 C --&gt;|"不满足"| D["拒绝并记账"]
 C --&gt;|"满足"| E["4 迁移状态 + 记录 pending request"]
 E --&gt; F["5 返回 transition&lt;br/&gt;Scheduler 据此决定是否退出"]&lt;/pre&gt;&lt;p&gt;第 3 步是新增的一层，也是这里唯一需要做取舍的地方：模型请求等待时，Runtime 要不要校验它此刻有没有等待的对象？如果只做类型校验，模型可以在任何时刻宣称「我要等用户」，而那时根本不存在被等待的东西。所以 &lt;code&gt;REQUEST_USER_INPUT&lt;/code&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;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;DecisionType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUEST_USER_INPUT&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brief_created&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;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;wait_signal_without_pending_object&amp;#34;&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="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&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;strip&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;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;wait_signal_without_question&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;非法的等待信号不会悄悄过去：&lt;code&gt;rejected_signals&lt;/code&gt;（被拒信号数）加一，Run 转成明确失败。合法的等待信号则走完整的迁移，留下 pending request：&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="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pending_seq&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PendingRequest&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;request_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;approval_&lt;/span&gt;&lt;span class="si"&gt;{&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;_pending_seq&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;03d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&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;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&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;strip&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;state_before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state_before&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="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;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RunStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WAITING_FOR_USER&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot_wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;state_before&lt;/code&gt; 记下迁移前的状态，&lt;code&gt;question&lt;/code&gt; 记下要问用户什么，&lt;code&gt;request_id&lt;/code&gt; 是用户回来时用来关联的标识。三者一起构成「这一次等待是谁、为了什么、从哪个状态发起」。&lt;/p&gt;
&lt;p&gt;这也正好对上 OpenAI Agents SDK 的用法：那里批准一项工具调用的方式是围绕 &lt;code&gt;interruptions&lt;/code&gt; 展开的——运行中断后，结果里带回一个待处理数组和一份可恢复的状态，批准就是在这个状态上做的决定。两种写法用词不同，位置一样：&lt;strong&gt;改状态的那一步在框架（Runtime）里，不在模型输出里。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="四等待信号从哪来"&gt;四、等待信号从哪来
&lt;/h2&gt;&lt;p&gt;现在回到上一节开头埋下的问题：模型不是唯一会提「要停」的角色。同一个 Runtime 入口，等待信号可以来自三个地方。三种控制器共用同一套工具、同一个 ActionGateway、同一个 Runtime，差异只留在「谁产生信号」这一处。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ReAct&lt;/strong&gt;：模型每次看到工具结果后重新提出一条决定。等待信号由模型自己在上下文里提出，比如输出 &lt;code&gt;{&amp;quot;type&amp;quot;: &amp;quot;REQUEST_USER_INPUT&amp;quot;, &amp;quot;question&amp;quot;: &amp;quot;纲领已生成，是否继续写正文？&amp;quot;}&lt;/code&gt;。Runtime 认出来就停。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;计划步&lt;/strong&gt;：Planner 一次生成整份计划，其中一步是等待。Executor 执行到那一步，把它原样交给 Runtime。计划可以声明等待点，但计划文本本身没有暂停能力。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Workflow 节点&lt;/strong&gt;：节点序列写在代码里，等待是其中固定的一站。节点内部的内容仍然由模型生成，代码固定的是「走到哪个节点」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;三种来源最后汇入同一个入口：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart LR
 R["ReAct&lt;br/&gt;模型提出"] --&gt; RT["Runtime.handle()&lt;br/&gt;接收 ControlDecision"]
 P["计划步&lt;br/&gt;Planner 写入计划"] --&gt; RT
 W["Workflow 节点&lt;br/&gt;代码固定位置"] --&gt; RT
 RT --&gt; ST["RunState&lt;br/&gt;RUNNING → WAITING_FOR_USER"]
 ST --&gt; SC["Scheduler&lt;br/&gt;退出当前 Run"]&lt;/pre&gt;&lt;p&gt;这就是三种模式在这篇文章里真正的位置：它们是「信号来源」，不是「三种对等的 Agent 设计」。谁先提不重要，重要的是&lt;strong&gt;提信号和停程序是两件事&lt;/strong&gt;——无论信号来自模型、计划还是代码，改状态的那一步都在 Runtime。&lt;/p&gt;
&lt;p&gt;三个来源的模型调用次数本来就不同，这一点在输出里也如实保留：正确版 &lt;code&gt;react&lt;/code&gt; 是 5 次，&lt;code&gt;plan_and_execute&lt;/code&gt; 和 &lt;code&gt;workflow&lt;/code&gt; 各 1 次。决策粒度不同，调用次数就不该被凑成一个数。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;两条补充，都是旧版说错、这次改对的，不影响主线：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;关于「模型能不能主动请求确认」，这次跑了一组真实实验：&lt;code&gt;deepseek-v4-flash&lt;/code&gt;，6 次，日志在 &lt;code&gt;traces/live-run-1..6.log&lt;/code&gt;。结果是&lt;strong&gt;这 6 次里没有一次&lt;/strong&gt;产出可被 Runtime 解释的等待决定——3 次把 8 步预算耗在反复读同一份资料上，另外 3 次的输出无法解析（模型把决定多包了一层 &lt;code&gt;{&amp;quot;type&amp;quot;:&amp;quot;json_object&amp;quot;,&amp;quot;content&amp;quot;:{...}}&lt;/code&gt; 信封，Runtime 只认顶层的 &lt;code&gt;type&lt;/code&gt;，认不出就判失败）。这段证据的强度有明确边界：它只说明在指定模型、提示词、工具协议和输入下模型没有产生预期结构，不能推广成「模型不会请求确认」。同一个服务端在 &lt;code&gt;temperature=0&lt;/code&gt; 下输出并不可复现：这 6 次里预算耗尽 3 次、解析失败 3 次，两次分批运行的分布还不同。&lt;/p&gt;
&lt;p&gt;关于 ReAct 的旧结论也要改：之前把「绕圈」归因为「每一步只拿到当前观察，看不到任务整体」，这与代码不符——每次请求传入的是完整的观察累积历史，不是最新一条。当前能写的只有：在本次模型、提示词、动作协议和 8 步预算下，模型连续重复读取了同一份资料；Trace 证明了重复行为，不足以把原因唯一归结为 ReAct 模式。&lt;/p&gt;
&lt;p&gt;另外两处旧版的不实也一并纠正：Planner 的提示词曾硬编码「先读 material_1，再读 material_2」，而 ReAct 必须自己从工具结果里发现顺序，那不是对等的比较——现在三个控制器拿到同一份初始清单，只知道有哪些资料、不知道先读哪一份；Workflow 曾有一个假分支——代码无条件读取第二份资料，却在 Trace 里标成「读到线索之后触发的分支」，现在第 3 个节点做真实判断，没有线索就不读第二份，&lt;code&gt;branch_taken&lt;/code&gt; 如实记为 &lt;code&gt;false&lt;/code&gt;。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;三种实现可以组合，它们不在同一个对立面上：Workflow 固定阶段，ReAct 在阶段内部按线索选资料，Planner 负责跨子任务的覆盖。组合之后，「停」这件事仍然只有 Runtime 一处负责。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="五为什么不能只用一个布尔值"&gt;五、为什么不能只用一个布尔值
&lt;/h2&gt;&lt;p&gt;前面一直在用 &lt;code&gt;status&lt;/code&gt; 这个枚举。这一节讲为什么它必须是枚举，不能是 &lt;code&gt;waiting=true&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-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&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;RUNNING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;RUNNING&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;WAITING_FOR_USER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&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 class="n"&gt;COMPLETED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;COMPLETED&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;FAILED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;FAILED&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;用布尔 &lt;code&gt;waiting=true&lt;/code&gt; 表示等待，会丢掉两件需要区分的事：一是「等待」和「完成」「失败」是并列的终态关系，布尔表达不了；二是标记为真和调度退出是两步，布尔把两步压成一步，就再也分不出「标记了但还在跑」这种状态。缺陷版 &lt;code&gt;plan_and_execute&lt;/code&gt; 那一行正是这个压缩的后果：它有个「说过要等」的标记，状态却一路跑到了「完成」。&lt;/p&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;terminal_reason&lt;br/&gt;（结束原因）&lt;/th&gt;
					&lt;th&gt;status&lt;br/&gt;（状态）&lt;/th&gt;
					&lt;th&gt;waiting_marker&lt;br/&gt;（说过要等）&lt;/th&gt;
					&lt;th&gt;model_calls&lt;br/&gt;（模型调用）&lt;/th&gt;
					&lt;th&gt;tool_attempts&lt;br/&gt;（工具尝试）&lt;/th&gt;
					&lt;th&gt;rejected_actions&lt;br/&gt;（被拦动作）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;缺陷版 react&lt;/td&gt;
					&lt;td&gt;budget_exhausted&lt;/td&gt;
					&lt;td&gt;RUNNING&lt;/td&gt;
					&lt;td&gt;true&lt;/td&gt;
					&lt;td&gt;8&lt;/td&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;0&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;缺陷版 plan_and_execute&lt;/td&gt;
					&lt;td&gt;completed&lt;/td&gt;
					&lt;td&gt;COMPLETED&lt;/td&gt;
					&lt;td&gt;true&lt;/td&gt;
					&lt;td&gt;1&lt;/td&gt;
					&lt;td&gt;5&lt;/td&gt;
					&lt;td&gt;1&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;缺陷版 workflow&lt;/td&gt;
					&lt;td&gt;completed&lt;/td&gt;
					&lt;td&gt;COMPLETED&lt;/td&gt;
					&lt;td&gt;true&lt;/td&gt;
					&lt;td&gt;1&lt;/td&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;0&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;正确版 react&lt;/td&gt;
					&lt;td&gt;wait_suspended&lt;/td&gt;
					&lt;td&gt;WAITING_FOR_USER&lt;/td&gt;
					&lt;td&gt;false&lt;/td&gt;
					&lt;td&gt;5&lt;/td&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;0&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;正确版 plan_and_execute&lt;/td&gt;
					&lt;td&gt;wait_suspended&lt;/td&gt;
					&lt;td&gt;WAITING_FOR_USER&lt;/td&gt;
					&lt;td&gt;false&lt;/td&gt;
					&lt;td&gt;1&lt;/td&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;0&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;正确版 workflow&lt;/td&gt;
					&lt;td&gt;wait_suspended&lt;/td&gt;
					&lt;td&gt;WAITING_FOR_USER&lt;/td&gt;
					&lt;td&gt;false&lt;/td&gt;
					&lt;td&gt;1&lt;/td&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;0&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;正确版三行的其余字段完全相同：&lt;code&gt;pending_request_id=approval_001&lt;/code&gt;、&lt;code&gt;draft_written=false&lt;/code&gt;、&lt;code&gt;approval_granted=false&lt;/code&gt;、&lt;code&gt;model_calls_after_wait=0&lt;/code&gt;、&lt;code&gt;tool_calls_after_wait=0&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;waiting_marker=true&lt;/code&gt; 在那三行缺陷版里都是一个数据标记，它记录「模型说过要等」，不记录「循环已经退出」——正是布尔值把这两件事压成一个所付出的代价。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="六和动作能不能执行是两回事"&gt;六、和「动作能不能执行」是两回事
&lt;/h2&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;Action Gateway&lt;/th&gt;
					&lt;th&gt;Runtime&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;/td&gt;
					&lt;td&gt;当前 Run 要不要继续&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;tr&gt;
					&lt;td&gt;拒绝的后果&lt;/td&gt;
					&lt;td&gt;这个动作不执行，Run 可以继续&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;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&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;def&lt;/span&gt; &lt;span class="nf"&gt;_write_draft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approval_granted&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;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;blocked_by_action_gateway&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&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;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;draft_written&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;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save_draft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;draft_1&amp;#34;&lt;/span&gt;&lt;span class="p"&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;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;draft_written&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;未获批直接返回，不碰 store。这条检查把「写不进去」办到了，但它对「Run 停不停」一句话也没说。缺陷版 &lt;code&gt;plan_and_execute&lt;/code&gt; 那一行就是证据：&lt;code&gt;rejected_actions=1&lt;/code&gt;，写入被拦住了，循环继续跑到 &lt;code&gt;completed&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;Codex 的官方文档把这两种控制分开，用词几乎一样：沙盒模式定义的是「模型生成的命令在技术上可以做哪些操作」，审批策略定义的是「Codex 在执行某项操作之前必须何时停下来询问用户」。文档里那句话可以直接搬过来——沙盒定义技术边界，审批策略决定越界前何时必须停止并询问用户。前者对应 Gateway，后者才对应 Run 的继续与停止。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="七这些产品其实都这么做"&gt;七、这些产品，其实都这么做
&lt;/h2&gt;&lt;p&gt;前面把机制讲完了，这一节把视角拉远：这些机制不是我们自说自话，几个主流框架的文档描述的是同一件事。&lt;/p&gt;
&lt;p&gt;产品上的现象是清楚的：这些 Agent 会结束当前回合并把问题展示给用户，用户输入之后可以开启后续执行。这说明它们存在某种&lt;strong&gt;回合终止与 continuation 机制&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这一段能写到什么强度，取决于有没有官方来源。Codex 是有文档的：审批策略是运行配置，取 &lt;code&gt;on-request&lt;/code&gt; 表示默认在沙盒内工作、需要越界时询问；&lt;code&gt;approvals_reviewer&lt;/code&gt; 的默认值是 &lt;code&gt;&amp;quot;user&amp;quot;&lt;/code&gt;，即审批请求路由到用户，也可以设为 &lt;code&gt;auto_review&lt;/code&gt; 由审查智能体处理。这说明「停下来问谁」是运行时配置的一部分，而不是模型自身的能力开关。&lt;/p&gt;
&lt;p&gt;OpenAI Agents SDK 的文档把暂停这一步写得最直白：当一次工具调用需要审批时，&lt;strong&gt;SDK 暂停这次运行&lt;/strong&gt;，返回待处理项，之后可以从同一个 &lt;code&gt;RunState&lt;/code&gt; 恢复。暂停的执行者是 SDK，不是模型——分工和前面一致，只是它把 Runtime 的角色交给了框架。&lt;/p&gt;
&lt;p&gt;LangGraph 的 interrupt 走的是同一路线，并且把持久化的一环显式化：在节点里调用 &lt;code&gt;interrupt&lt;/code&gt; 之后，图执行在调用点挂起，状态由持久化层保存并无限期等待，恢复时用同一 &lt;code&gt;thread_id&lt;/code&gt; 重新调用图。文档同时说明，恢复时整个节点会从头重跑，&lt;code&gt;interrupt&lt;/code&gt; 之前的代码会再执行一次。这条细节很有用——它说明「挂着的那一步」和「恢复时怎么重放」是两个要分开设计的约束。&lt;/p&gt;
&lt;p&gt;从这些来源能推出来的，只到这里：&lt;strong&gt;被观察到的产品行为，与官方框架文档描述的「运行时暂停 + 携带状态恢复」是同一类机制。&lt;/strong&gt; 再往下不能推：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不能写它们内部一定用了某个类、某张表或某个具体状态机；&lt;/li&gt;
&lt;li&gt;不能写 WorkBuddy 与 Codex 的实现相同——WorkBuddy 这一段只有可观察行为，没有可引用的实现契约；&lt;/li&gt;
&lt;li&gt;不能把框架文档的接口承诺当成产品内部实现的证明。文档承诺的是「怎么用」，不是「里面怎么写的」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这条边界不是保守，是证据强度决定的：官方文档能证明「这套机制存在且被这样描述」，证明不了「这个产品内部就是那样实现的」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="八停只是前半场"&gt;八、停只是前半场
&lt;/h2&gt;&lt;p&gt;三种来源都在同一处收尾：纲领生成，Run 进入 &lt;code&gt;WAITING_FOR_USER&lt;/code&gt;，本次调用返回。Trace 里能看到状态、看到等待原因、看到哪些动作被拒绝，唯独看不到一件东西——&lt;strong&gt;这次调用退出之后，谁记得有一份纲领在等确认。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;单进程里 &lt;code&gt;return&lt;/code&gt; 退出的是当前这一次函数调用。&lt;code&gt;pending&lt;/code&gt;、&lt;code&gt;brief_id&lt;/code&gt;、已经走到哪一步，全在内存里，程序一退就没了。用户几分钟后回来，或者进程中途重启，系统凭什么找到对应的纲领、对应到哪一次等待、恢复到哪个进度？&lt;/p&gt;
&lt;p&gt;这一步不是加一行持久化就能收尾的，它牵出四个互相牵连的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;等待请求怎么存下来，重启之后还能按标识找回来；&lt;/li&gt;
&lt;li&gt;审批结果绑定到哪个对象——是这次 Run，还是被批准的那个具体动作；&lt;/li&gt;
&lt;li&gt;恢复之后怎样保证不重复产生副作用；&lt;/li&gt;
&lt;li&gt;重复审批、超时、用户直接取消，各自该走到哪个状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Anthropic 那句 &lt;code&gt;pause_turn&lt;/code&gt; 的语义在这里反过来读也成立：调用方必须把没处理完的内容原样发回去才能接着跑。也就是说，「恢复」不是自动发生的，它需要有人带着上下文重新发起。&lt;/p&gt;
&lt;p&gt;下一话接着讲：Run 停下来之后，怎样把状态存到进程之外，又怎样安全地接着跑。&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;Anthropic. Stop reasons and fallback. &lt;a class="link" href="https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons" target="_blank" rel="noopener"
 &gt;https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Anthropic. How tool use works. &lt;a class="link" href="https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/how-tool-use-works" target="_blank" rel="noopener"
 &gt;https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/how-tool-use-works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenAI. Guardrails and human review. &lt;a class="link" href="https://developers.openai.com/api/docs/guides/agents/guardrails-approvals" target="_blank" rel="noopener"
 &gt;https://developers.openai.com/api/docs/guides/agents/guardrails-approvals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenAI Agents SDK. Human-in-the-loop. &lt;a class="link" href="https://openai.github.io/openai-agents-js/guides/human-in-the-loop" target="_blank" rel="noopener"
 &gt;https://openai.github.io/openai-agents-js/guides/human-in-the-loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenAI. Codex: Agent approvals &amp;amp; security. &lt;a class="link" href="https://developers.openai.com/codex/agent-approvals-security" target="_blank" rel="noopener"
 &gt;https://developers.openai.com/codex/agent-approvals-security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenAI. Codex: Sandbox. &lt;a class="link" href="https://developers.openai.com/codex/concepts/sandboxing" target="_blank" rel="noopener"
 &gt;https://developers.openai.com/codex/concepts/sandboxing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;LangChain. Plan-and-Execute Agents. &lt;a class="link" href="https://www.langchain.com/blog/planning-agents" target="_blank" rel="noopener"
 &gt;https://www.langchain.com/blog/planning-agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;LangGraph. Workflows and agents. &lt;a class="link" href="https://docs.langchain.com/oss/python/langgraph/workflows-agents" target="_blank" rel="noopener"
 &gt;https://docs.langchain.com/oss/python/langgraph/workflows-agents&lt;/a&gt;&lt;/li&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;Yao et al., 2022. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629. &lt;a class="link" href="https://arxiv.org/abs/2210.03629" target="_blank" rel="noopener"
 &gt;https://arxiv.org/abs/2210.03629&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/c11-suspend-protocol" target="_blank" rel="noopener"
 &gt;GYA&lt;/a&gt; 与 &lt;a class="link" href="https://github.com/renxin2024/GYA-Java/tree/main/c11-suspend-protocol" target="_blank" rel="noopener"
 &gt;GYA-Java&lt;/a&gt; 两个仓库的 &lt;code&gt;c11-suspend-protocol&lt;/code&gt; 目录，按 README 的命令跑。&lt;/p&gt;</description></item></channel></rss>