← blog 技术原理与实践 · 2026-10-05

从 fire-and-forget 到可恢复发布:mini-agent 博客链路的持久化状态机重构

一次完整的架构重构复盘:用持久化发布状态机、Transactional Outbox、隔离 Git worktree 和 rebase/CAS,替换掉 fire-and-forget 任务、不可靠的文件 watcher 和"pull --rebase 赌运气"的同步方式,并记录收敛过程中被 review 和故障注入揪出来的每一个真实缺陷。

25 min read

起点:一条”看起来能跑”的发布链路

上一篇排障文章结束在字体门禁与进程树的修复上。那次排障修好了单点故障,但排查过程中暴露出的问题更结构性:我对着一条链路做故障注入式的思考,发现它几乎在每一个”进程恰好在这里死掉”的位置都没有答案。

重构前的链路是这样的:一次 Query/Report/Topic 结束后,ensure_future 把博客生成任务丢进 event loop 就返回了。任务本身要跑 LLM 草稿、语义判重、质量评审、敏感信息扫描、MDX 校验、字体子集生成,最后原子替换文章文件。文件落盘后,watchdog 发现变化,BlogSyncTask 做 20 分钟 debounce,然后 git add / commit / pull --rebase / push。

这条链路有四个致命假设,每个都经不起一句”然后呢”:

假设一:进程不会在 ensure_future 之后、任务完成之前退出。 但部署重启、崩溃、event loop 关闭随时发生。来源已经对用户成功返回,发布却没有任何可恢复的记录——这次发布就静默丢了,连”丢过”这件事都不可见。

假设二:watcher 是可靠消息队列。 但 watchdog 事件、debounce timer、重试计数全部在内存里。事件丢失、或进程恰好在 debounce 的 20 分钟窗口里重启,工作树就留下未同步的修改,没有任何待办任务知道它。启动时也没有 reconcile 去发现它。

假设三:文章和字体是同一个事务。 但它们是两个独立的文件系统动作:字体先安装进 live repo,文章再 Path.replace()。任何一个成功另一个失败,仓库里就多出半套资源。staging/rollback 机制已经把概率压得很低,可”概率低”不等于”有判断依据”——重启之后没有任何持久化记录能回答”这组文件是否应该一起提交”。

假设四:pull --rebase 能解决双节点汇合。 它确实能解决分支级 non-fast-forward,但同一文件的重叠修改会留下一个半完成的 rebase 状态,而当时的重试逻辑会在同一个坏状态上反复执行同一个失败动作——没有冲突状态、没有人工处理入口,只有一条无限重试的日志。

这四个问题不是四个孤立的 bug。文章、字体、校验结果和 Git 同步本来属于同一个发布作业,却分散在多个没有共同事务记录的组件里。 这是缺失的抽象,补丁修不掉。

选型:借思想,不搬协议

重构方案评估过一批分布式系统经典模式,结论是每个都只”借”一部分:

思想借什么不借什么
2PCPrepare/Commit 的两阶段结构:先把一切准备成不可变制品,再统一决定提交跨 Git 远端的真实 2PC——LLM、字体脚本、文件系统、GitHub 根本不是可阻塞的事务参与者,协调者失败会造成无限阻塞
Zab/Raft顺序日志、恢复历史、冲突 fencing 的直觉应用内共识协议——Git remote 已经承担了双节点汇合,两个 writer 不需要 quorum
Transactional Outbox本地状态提交与异步投递解耦;允许投递重复,要求消费幂等完整消息中间件——SQLite 表就是 outbox
Saga外部副作用(push、字体生成)用补偿和重试恢复,不追求全局回滚语义回滚——push 出去的文章收不回来,也不应该收
Temporal事件历史是恢复的真相来源,重启后 replay 已完成步骤引入 Temporal 本体——个人项目不需要这个运维成本,SQLite 事件表实现同样语义

最后落地的组合是:持久化发布状态机 + Transactional Outbox + 隔离 Git worktree + rebase/CAS + 显式冲突状态。

一个重要的角色边界先说清楚:开发机负责项目开发和人工内容变更,生产 ECS 上的 mini-agent 是唯一创建和执行发布作业的节点。Git remote 是共享事实源,两边的变更在 ECS 的 job worktree 里通过 fetch + rebase origin/main + push 汇合。因为只有一个发布执行者,不需要 lease 选主;租约(lease)只用于同进程崩溃后的恢复防抖。

核心对象:一张表就是一条事件历史

每个发布作业是一个 BlogPublication 记录,落在 SQLite(WAL 模式):

blog_publication
  job_id, idempotency_key     -- 不变作业 ID + 请求幂等键
  state                       -- 持久化状态机
  source_sha256, base_commit  -- 脱敏来源哈希 + 起判时的 Git HEAD
  target_path, target_blob_sha256  -- UPDATE 的路径与版本条件(CAS)
  artifact_dir, artifact_sha256    -- 不可变制品目录与 manifest 哈希
  local_commit, remote_commit      -- 带 trailer 的本地提交 + 远端实际提交
  attempt, max_attempts, next_retry_at  -- 恢复预算
  last_recheck_head, last_recheck_delta_sha  -- 已复核的远端 delta 指纹

配套两张表:blog_publication_event 记录每次状态迁移(Temporal 式的事件历史),blog_publication_outbox 承载异步投递。outbox 有两种事件:blog_publish 挂在具体 job 上;external_sync 的 job_id 明确为 NULL、event_id 由 payload 确定性派生(uuid5 + INSERT OR IGNORE),这是给手工编辑、/note 这类”不属于任何发布作业”的变更留的通道——后面会讲到为什么它必须存在。

来源正文写在 data/blog-jobs/<job_id>/source.txt,入库前先脱敏,数据库里只存哈希和路径。这样作业可以完整重放,而运行时凭据不会进日志和事件表。

状态机

stateDiagram-v2
  [*] --> RECEIVED
  RECEIVED --> PREPARING
  PREPARING --> SKIPPED
  PREPARING --> REVIEW
  PREPARING --> READY_TO_COMMIT
  READY_TO_COMMIT --> RECHECK_CANDIDATES: remote content delta
  READY_TO_COMMIT --> LOCAL_COMMITTING
  LOCAL_COMMITTING --> LOCAL_COMMITTED
  LOCAL_COMMITTING --> RECOVERING: crash/unknown result
  LOCAL_COMMITTED --> PUSHING
  PUSHING --> PUBLISHED
  PUSHING --> RETRY_WAIT: network/transient
  PUSHING --> CONFLICT: rebase conflict
  RETRY_WAIT --> PUSHING
  RETRY_WAIT --> FAILED: budget exhausted
  RECHECK_CANDIDATES --> SKIPPED: dedup judge=SKIP
  RECHECK_CANDIDATES --> LOCAL_COMMITTED: decision=KEEP
  RECHECK_CANDIDATES --> CONFLICT: target changed
  RECOVERING --> LOCAL_COMMITTED: commit trailer found
  RECOVERING --> LOCAL_COMMITTING: artifact intact
  RECOVERING --> REVIEW: artifact or repo unsafe

这里有几个定义必须严格,否则整个状态机退化成装饰:

PUBLISHED 的唯一判据是远端 ref 历史中存在 Blog-Job-Id 与 Blog-Manifest-Sha256 都匹配的 commit。 不能用 local_commit 的 hash 判断——rebase 会重写 commit,push 出去的 hash 和本地记的 hash 天然不同。确认成功后把远端实际 hash 记进 remote_commit。

RETRY_WAIT 有上界。 耗尽 max_attempts 转入 FAILED(failure_code=delivery_exhausted),此后按小时级长退避只做 delivery probe(fetch + 远端 trailer 查询),不重置预算、不重新生成内容。probe 成功照样发布——“断电一小时”不该变成”重新跑一遍 LLM”。

CONFLICT 和 REVIEW 是两种不同的”需要人”。 CONFLICT 是文章目标或语义无法安全合并;REVIEW 是内容/规则门禁未过。混在一起,排障时就没法一眼判断该看 Git 还是看内容。

幂等键与内容哈希:两个被混用的概念

这是提案 review 里我被打脸的第一处。初版方案想让 source_sha256 承担”同一篇内容只发一次”的去重,但它和请求幂等是两件事:

  • idempotency_key 表示”同一个请求重试”,做 UNIQUE 约束。客户端重试、进程恢复后重放,同一个 key 找回同一个 job。
  • source_sha256 只是普通索引,供语义判重和诊断使用。隔一周对同一个问题再问一次,是完全合法的新请求——内容哈希相同不代表该被幂等吞掉。

Topic 的幂等键是 session_id + close_revision。close_revision 由独立的 topic_publication_cursor 表在创建 job 的同一个数据库事务里递增:重复的 /query_end 命中同一个 revision、复用 job;重新开题再结束则产生新 revision、新 job。进程重启不会把一次结束误判成两次请求——因为这个游标在磁盘上,不在内存里。

Prepare 与 Commit:把副作用关进 worktree

重构后每次发布在两个阶段里完成。

Prepare(PREPARING → READY_TO_COMMIT):以幂等键创建或复用 job,记录 base_commit,从该快照读取候选文章,跑原有的语义判重、草稿生成、质量评审、安全扫描、MDX 校验和字体门禁——但全部发生在 mini-agent/job/<job_id> 的独立 Git worktree 里。产出的文章与字体输出逐文件记录相对路径和 SHA-256,写成 manifest。任何门禁失败只销毁这个 worktree,live 仓库一个字节都不碰。

Commit(READY_TO_COMMIT → LOCAL_COMMITTED):重新校验 HEAD 和 UPDATE 目标的 blob hash 后,把 manifest 里的文件做成一个 commit,message 带 trailer:

Blog-Job-Id: <job_id>
Blog-Manifest-Sha256: <manifest hash>
Blog-Base-Commit: <base>
Blog-Writer-Node: ECS

Trailer 是整个恢复机制的锚点:进程在”commit 完成”与”状态落库”之间崩溃,reconciler 重启后用 git log --grep 按 trailer 找回 commit,把状态补齐到 LOCAL_COMMITTED——不重跑 LLM,不产生第二篇文章。

UPDATE 路径用 target_blob_sha256 做 CAS:从 base_commit 快照记下目标旧文的 SHA-256,commit 时和 rebase 后各验一次。快照之后被任何人(包括未来的自己)改过的目标,宁可进 CONFLICT 也不覆盖。 这直接兑现了验收条件”任何 UPDATE 都不会覆盖快照之后被别人修改的目标”。

有界 rebase 与 delta 指纹

push 前的 relay 在 job worktree 里执行 fetch → rebase origin/main → push HEAD:main。rebase 之前先算一件事:base_commit..origin/main 之间文章文件的 delta 指纹。

  • 没有新增/修改文章(远端只变了字体、配置),或者这个 delta 指纹已经复核过:直接 rebase + push,不调 LLM。
  • 出现新的文章 delta:进 RECHECK_CANDIDATES,只做一次 dedup-only 语义复核。judge 的输出契约严格二选一:SKIP 或 KEEP——返回 UPDATE、NEW 或任何协议外值都视为”无法判断”,fail closed 进 REVIEW。dedup 复核永远不能升级成重新 draft。

delta 指纹(last_recheck_delta_sha)的记账方式决定了复核次数不会失控:先复核了远端提交 A,后续 rebase 又带入 B 时,第二次只复核 B。否则累计 diff 每变一次就重新复核全部历史,rebase 次数一多就是 LLM 调用的平方级放大。

字体-only 冲突:唯一值得自动重建的场景

中文字体子集是派生资源,几乎每次带新汉字的文章都会重建它,所以”远端字体文件与 job 字体文件冲突”是高频事件,让人来处理既慢又没技术含量。规则是:rebase 冲突的未合并路径全部落在 public/fonts/ 下时,rebase --abort 后改用 rebase -X theirs 合并远端文章内容,用合并后的文章集合重新跑一次子集脚本(生成双方字符的并集),amend 进 job commit,继续 push。不重新 judge,不进 CONFLICT。文章 MDX 路径冲突才需要人。

external_sync:不能被门控吞掉的另一条通道

引入 job manifest 后有个容易忽略的次生问题:博客仓库里还存在大量不属于任何发布作业的合法变更——手工改错别字、管理员在工作树里的修订、/note 直接写入的完整 MDX。如果 reconcile 把所有”未知修改”都转成 review,原本能自动 push 的错别字修复会被卡死。

所以 outbox 里有独立的 external_sync 通道:不做语义判重,不冒充任何 BlogPublication,只对允许的内容路径和字体输出做目录级 add/commit/fetch/rebase/push。watcher 降级为它的触发提示——watcher 事件全部丢失也不再影响可靠性,因为启动 reconcile 会扫描 git status,把发现的内容变更重新登记成确定性 event_id 的 outbox 事件。这条设计后来在 review 中救了我一次,下面会讲。

收敛:代码写完,麻烦才刚开始

主实现提交(e588a32,17 个文件 +1751/−529,tasks/blog_publication.py 约 1200 行)之后是连续五个 fix 提交。值得写下来的是其中三个真实缺陷,每个都不是”看着不对”发现的,而是被测试或经验性复现逼出来的:

git log --format="%H%x00%B" 的均偶配对 bug。 按 \x00 切分后,commit body 和下一个 hash 合并在同一块里,只有第一条记录能正确解析——trailer 查找在多 commit 场景下静默失明。修复成 git log --format=%H --grep ... --fixed-strings 拿列表、逐条 git show -s --format=%B 取 body。教训:序列化格式的解析正确性要用多记录用例验证,单记录测试通过说明不了任何问题。

git log --all 泄漏本地分支。 远端确认本该只查远端 ref,加了 --all 之后本地的 job 分支也进了搜索范围——一个已经 push 过又本地遗留的分支会让”远端确认”给出假阳性。--all 只保留在本地 trailer 查找里。

delivery lease 争用炸掉整个 reconcile。 两个 reconciler 同时 relay 同一个 job,输家拿到的 None 直接变成 KeyError,中止整轮 reconcile——一个 job 的争用让其他所有 job 的恢复停摆。修复后输家静默 no-op:争用是正常并发,不是异常。

锁的粒度:一次用户驱动的重新设计

初版实现用一把 RLock 罩住所有 Git 操作。功能正确,直到一个真实场景推翻了它:两次博客生成请求在相近时间到达,第一次的 push 因为网络原因卡住,第二次的 prepare 被连带阻塞——而 prepare(worktree 组装、LLM、字体)从头到尾不需要 GitHub 连通性。

修复是把锁拆成有全序的三层(delivery > checkout > repo > db,永不逆序):

  • repo 锁:纯本地短操作(worktree 增删、discard、日志读取)。prepare 路径只需要它。
  • delivery 锁:relay 的网络段和 article_delta。
  • checkout 锁:live 仓库的事务(external_sync、watcher 同步、relay 成功后的 fast-forward)。

watcher 同步的重试循环改为每次尝试各持锁、sleep 在锁外——网络断连最多挡住 30 分钟的 push 重试,不能再顺带挡住别人的 prepare。这个”锁的持有范围必须排除无网络依赖的工作”的原则,比锁本身更值得记住。

外部 review 的最后一轮:四条 finding 的判定与修复

重构提交后,我把整段 commit 序列交给独立 review,拿回四条 finding。逐条判定真伪的过程本身就是一次很好的校准——四条里三条半属实,但每条都有细节偏差:

1. 字体导致 recovery 失败(属实,最严重)。 /note 走的是内层 actor,字体门禁对着 live 仓库跑——一篇含新汉字的 note 会在 live checkout 里重生成 public/fonts/ 下的 tracked 文件。而 external_sync 默认只 stage 内容目录,commit 之后 git rebase 面对”有未暂存的 tracked 修改”直接拒绝执行,note 的 commit 被困在本地。我在 scratch repo 里复现了 error: cannot rebase: You have unstaged changes。修复:stage 集合从 blog_fonts.FONT_OUTPUTS 派生,与 watcher 同步的允许路径对齐。review 说”watcher 拆分了文章和字体 commit”是错的——watcher 本来就是一个 commit 打包两者;错的是 durable 路径没对齐。

2. 启动阻塞(基本属实)。 await reconcile_pending(reclaim=True) 排在 Web/scheduler 启动之前,而它会完整重跑 LLM prepare。崩溃在 LLM 中途 → 重启 → 守护进程在重放完成前不可用。修复:改为后台 task,与 300s 周期 reconcile 靠 claim 原子性共存。细节修正:Telegram 早在此前就已启动,真正被挡的是 HTTP 和 scheduler。

3. topic 未脱敏(属实)。 source 正文入库前过 sanitizer,topic 字段却原文落库,还会出现在管理员状态 API 里。用户提问里粘一段 Bearer token,就永久进了 SQLite。修复一行:topic = sanitize_text(topic),两条创建路径都补上。

4. 租约心跳(部分属实)。 “只在整个 workflow 前后续约”的描述不准——实际有三个续约点,真正无心跳的窗口只有 LLM publish 步骤本身。但结构缺口是真的:单步超过 120 分钟 stale 窗口,周期 reconcile 会把 job 偷走、discard 还在写入的 worktree。修复:publish 每 15 分钟续约一次;续约失败(lease 被偷)则取消自身、走既有的 token-mismatch 路径静默退场。后果本身有缓解——被偷的 worker 在下个续约点会自己退出,不会重复发布——所以我把它的严重级别从 High 降到 Medium。

这轮判定给我的最大提醒:review 的结论要逐条对代码验证,既不能照单全收,也不能凭印象反驳。 “基本属实”和”属实”之间的细节偏差(比如续约点数量、Telegram 启动顺序),恰恰是判断修复方案该长什么样的依据。

验证:把验收条件变成故障注入

提案列了 8 条验收条件,全部落成了测试(重构模块 23 个测试,全量回归 589 个):

  1. 任意步骤重启 → job 最终只能是 PUBLISHED/SKIPPED/REVIEW/FAILED/CONFLICT,不能静默丢失;
  2. 相同幂等键重复提交 → 不产生第二个文件或第二个 commit;
  3. 文章和字体要么同 commit、要么都不进仓库(用 git show --name-only 验证 job commit 同时包含 MDX 和 woff2);
  4. UPDATE 的 CAS:目标 blob 变了就是 CONFLICT(这条测试最初用 git hash-object 造期望值——那是 SHA-1 blob id,而代码比较的是 SHA-256 内容哈希,测试通过纯属巧合,被 review 揪出后修正);
  5. watcher 事件全丢 → 启动 reconcile 仍能发现并推送待处理 commit;
  6. push 成功但响应丢失 → 远端 trailer 幂等确认,不重新生成;
  7. 双节点不相交变更 rebase 汇合,同文件冲突进可见 CONFLICT;
  8. 所有持久化文本经过脱敏。

另外两条测试专门锁住这次重构最容易被”顺手优化”破坏的性质:test_prepare_is_not_blocked_by_network_locks 在持有 delivery/checkout 锁的情况下跑 create_worktree,断言它不被阻塞;test_watcher_sync_releases_checkout_lock_between_retries 用一个轮询线程证明 push 重试的 sleep 期间锁是放开的。并发性质要靠专门的测试钉死,否则第一个改锁的人就会把它改回去。

不做的事

架构决策里最有价值的部分往往是明确不做的:

  • 不做跨远端 2PC。 LLM、字体脚本、GitHub 放进一个可阻塞事务,得到的是长时间锁、协调者阻塞和不可恢复的启发式 commit。正确保证来自 Outbox、幂等、CAS 和 reconcile 的组合,而不是假装存在一个全局 rollback。
  • 不做应用内 Raft/Zab。 Git remote 已经是双节点汇合点,job worktree 的 rebase 就是乐观并发控制。两个 writer 不需要 quorum;只有未来需要发布节点故障转移时才值得引入外部 lease。
  • 不把 pull --rebase 叫做共识。 它是版本历史的乐观汇合与冲突检测,判断不了内容是否语义重复——所以幂等键、快照判重和 delta 指纹缺一不可。

这条链路现在的形态:一次发布从 ensure_future 里的一缕运气,变成一条每一步都可重放、可恢复、可观测的持久化状态机。它仍然不是”分布式系统”——一台 ECS、一个 SQLite 文件、一个 Git remote——但它在一个个人项目的尺度上,用对了那些大系统模式里最便宜也最有效的部分。

Sources

  1. 架构提案原文 — mini-agent repo
  2. Transactional Outbox 模式 — microservices.io
  3. Saga 模式 — microservices.io
  4. Temporal Workflows(事件历史与恢复) — Temporal
  5. git-worktree 文档 — Git
  6. git-push 文档(CAS/lease 语义) — Git
  7. SQLite WAL — SQLite
  8. 主实现提交 — GitHub

Related

2026-10-06 · blog

pi durable 是怎么把「杀掉进程也能续跑」做出来的

读 pi 仓库 packages/durable 的源码实现:存储层的原子提交、会话层的事务协议、任务状态机的 checkpoint 与崩溃恢复入口、100ms 节流的部分产出提交,以及三层幂等设计,并修正「数据库式存储 + 状态机可追溯」这类总结里不准确的部分。技术原理与实践
2026-10-09 · blog

拆解 Codex 的 /goal:持久化目标系统、自动续跑与状态一致性

在 Codex 里输入 /goal 得到的不是一条注入模型的 prompt,而是一台持续运转的状态机:TUI 命令穿过 app-server RPC 落进独立 SQLite,运行时逐 token 计量消耗,线程空闲时自动开始下一轮,直到目标完成、卡住或预算耗尽。本文从 TUI 到 SQL 逐层拆开这台机器,重点讲它的核心架构决策——状态写入拆成「模型可写的子集」和「系统独占的派生状态」两条路径,一致性靠 SQL 层守卫与原子计量保证,而不是信任模型的自报。技术原理与实践 · 14 min read
2026-10-06 · blog

pi session tree 的实现原理:/tree、/fork、/clone 到底动了什么

读 pi 源码(session-manager、agent-session、agent-session-runtime、interactive-mode)拆解 session tree 的实现:append-only JSONL 的 id/parentId 指针、leafId 活动指针、getBranch 的路径提取,以及 /tree 移动指针、/fork 提取路径到新文件、/clone 复制活动分支三者的真实差异,并修正「fork 仍在原文件」「clone 复制子树」两处常见误解。技术原理与实践
On this page 起点:一条”看起来能跑”的发布链路选型:借思想,不搬协议核心对象:一张表就是一条事件历史状态机幂等键与内容哈希:两个被混用的概念Prepare 与 Commit:把副作用关进 worktree有界 rebase 与 delta 指纹字体-only 冲突:唯一值得自动重建的场景external_sync:不能被门控吞掉的另一条通道收敛:代码写完,麻烦才刚开始锁的粒度:一次用户驱动的重新设计外部 review 的最后一轮:四条 finding 的判定与修复验证:把验收条件变成故障注入不做的事