ARTICLE · 1060238
LoopX 源码解析(二):状态内核——任务进度怎么存才不会丢、不会乱
并发写坏。 两个 agent 同时认领同一个待办、同时改同一份状态,后写的覆盖先写的,进度丢失。任务一旦跨天,并发几乎必然出现:你早上开了一个 Claude Code 没关,下午又起了个 Codex 崩溃丢半。 写文件写到一半进程死了,文件处于损坏状态,既不是旧状态也不是新状态。长任务运行几天,进程重启、机器断电、部署更新都是常态而不是意外 静默覆盖。 人类改了状态文件,agent 不知道,拿着旧认知继续干活;或者反过来——两边各写各的,谁都没发现分歧。这个故障最隐蔽:没有报错,没有崩溃,只是任务悄悄走错 证据造假。 执行结果声称"测试通过了",但这个声称本身没有东西校验,坏结果混进状态里往下传。单个坏证据不致命,致命的是后续回合都基于它继续——错误会沿着待办链放大
LoopX 的状态内核就是防这四种故障的一套文件结构加写入规则。
状态内核是什么
一句话:LoopX 把任务状态按类型拆成几类文件,每类数据只有一个权威文件;写入用原子写加文件锁,关键数据附带摘要校验;两边数据对不上时报错,不自动覆盖。
各类文件的分工:
.loopx/registry.json → goal 的注册和配置,以这份文件为准ACTIVE_GOAL_STATE.md → 待办、交接、进度,人和 agent 直接读写runtime 下的 JSONL 事件流 → 状态变化的完整追加记录,重放它可还原任何时刻的状态task-leases/{todo}.json → 每个待办的租约:现在谁在做、什么时候过期spend/slot JSONL → 配额消耗记录
两个基础写入规则先交代清楚,后面所有机制都建立在它们之上:
原子写。状态文件从不直接打开修改,而是先把完整新内容写进同目录的临时文件,确保写满并落盘后,一步替换旧文件。替换这个动作在文件系统层面是不可分割的——要么还是旧文件,要么已经是完整的新文件,不存在中间态。崩溃丢半的问题在这一层就被解决。
文件锁。任何修改状态的流程都要先拿到对应文件的排它锁,拿不到就等待或失败。多个流程不会同时写一份文件——这是并发问题从入口的收窄。这个锁的实现值得说透,因为它是后面所有并发安全的底座:
锁存在操作系统内核里,不在文件内容里。 加锁是调用内核的文件锁接口(POSIX 的 flock,Windows 用等价接口),.lock文件只是锁的锚点。两个进程同时申请同一个锁,内核做仲裁,只允许一个成功——和线程抢互斥锁是同一类原语,只是互斥锁以内存地址为键,文件锁以文件为键。判定在内核,应用层没有"检查再写入"的竞态窗口拿不到锁的进程轮询重试,超时报错。 不会永远等死;超时按操作类型分档(改状态等得久一点,巡检类等得短) 进程崩溃,内核自动释放锁。 进程退出时内核关闭它的全部文件描述符,锁随之消失。这解决了"往文件里写持有记录"方案的最大问题——宕机后记录残留导致死锁 锁旁边有持有者信息文件,记录谁在什么时候为什么操作持锁,用于排障 它是劝告锁:只约束走同一套加锁代码的进程。外部程序绕过 LoopX 直接改文件,锁拦不住——所以摘要校验是必须的第二道防线
没有总数据库,每类数据各有唯一的权威文件——这就是理念一。
每类文件里实际存什么
用深色模式任务的场景,看每类文件的真实内容(字段名来自源码,值是示例;Markdown 状态文件为简化示意)。
registry.json——goal 的注册和配置。它不存进度,只存"这个 goal 是什么、归谁管、允许干什么",进度数据只放指针:
{”schema_version”: ”0.1”,”goals”: [{”id”: ”dark-mode”,”display_name”: ”为商城前端添加深色模式”,”domain”: ”frontend”,”status”: ”active”,”repo”: ”/work/mall-frontend”,”state_file”: ”.codex/goals/dark-mode/ACTIVE_GOAL_STATE.md”,”spawn_policy”: { ”mode”: ”manual”, ”max_children”: 2 },”coordination”: {”write_scope”: [”src/theme/**”, ”tests/theme/**”],”requires_parent_approval”: [”write”, ”publish”, ”production-action”]},”quota”: { ”compute”: 0.4, ”window_hours”: 24, ”slot_minutes”: 1 }}]}
读这份文件能知道:任务是什么、状态文件在哪、只允许写 src/theme 这片范围、推送和发布要人批准、每天最多 40% 的时间槽可用。
ACTIVE_GOAL_STATE.md——人和 agent 直接读写的进度面。打开是一份当前快照:待办清单、每项的状态和证据、给下一回合的交接(简化示意):
---goal_id: dark-modehandoff_mode: hard_leaseupdated_at: 2026-09-16T22:40:00Z---## Todos- [x] T1 调研现有样式结构 → 证据: dark-mode-notes.md- [x] T2 设计 CSS 变量方案 → 证据: docs/theme-vars.md- [ ] T3 改造组件(认领者: claude-code, 租约至 22:55)## Handoff(给下一个回合)选了 CSS 变量而不是主题类:避免逐组件覆盖。已试过直接覆盖样式,测试不稳定,放弃。下一步:T3 剩 6 个组件,从 Button 开始。
JSONL 事件流——机器用的完整历史。每行一个事件,只追加不修改。重放全部行可以还原任何时刻的状态:
{”event_id”: ”e-0142”, ”goal_id”: ”dark-mode”, ”event_type”: ”todo.claimed”,”recorded_at”: ”2026-09-16T22:40:00Z”, ”actor_agent_id”: ”claude-code”,”payload”: {”todo_id”: ”T3”, ”lease_epoch”: 2}}{”event_id”: ”e-0143”, ”goal_id”: ”dark-mode”, ”event_type”: ”todo.completed”,”recorded_at”: ”2026-09-16T23:05:00Z”, ”actor_agent_id”: ”claude-code”,”payload”: {”todo_id”: ”T2”, ”result_hash”: ”a3f2…”}}
task-leases/{todo_id}.json——单个待办的租约。谁在做、什么时候过期、第几代,一望即知:
{”schema_version”: ”task_lease_v0”,”goal_id”: ”dark-mode”,”todo_id”: ”T3”,”owner”: ”claude-code”,”write_scopes”: [”src/theme/**”, ”tests/theme/**”],”version”: 5,”lease_epoch”: 2,”acquired_at”: ”2026-09-16T22:40:00Z”,”expires_at”: ”2026-09-16T22:55:00Z”,”status”: ”active”}
spend/slot JSONL——配额消耗账本。每条记录一个回合的预算消耗,只追加,错了用冲销记录抵消而不是删改:
{”goal_id”: ”dark-mode”, ”effect_ref”: ”turn-0081”, ”action”: ”spent”,”slots”: 1, ”recorded_at”: ”2026-09-16T22:41:00Z”}{”goal_id”: ”dark-mode”, ”effect_ref”: ”turn-0083”, ”action”: ”voided”,”slots”: 1, ”recorded_at”: ”2026-09-16T23:10:00Z”,”reason”: ”turn failed validation”}
第二行是一个真实场景:某个回合结算时验证没通过,它的配额消耗被冲销——账本靠追加冲销记录保持准确,而不是修改历史。
四条设计理念
理念一:每类数据只有一个权威源
配置的权威文件是 registry,进度的权威文件是状态文件加事件流,任务归属的权威文件是租约,消耗的权威文件是账本。上层代码(调度器等)读状态时拿到的都是从权威文件生成的只读快照,拿不到可以直接修改的引用。
为什么按类型拆开,而不是建一个总数据库?因为这几类数据的读写模式完全不同:goal 配置极少变、读得多;待办状态高频更新、人和 agent 都要直接读写;配额账本只追加不修改、每次决策都要重算;租约要求快速加锁判定归属。一个存储要同时满足这四种模式,只能互相妥协;拆开之后每个文件按自己的模式选最简单的结构。
这条理念防的是"状态到底以谁为准"的争论。多个副本各有读写路径的系统,迟早出现两份数据对不上、没人说得清哪份是对的。LoopX 的做法是从结构上消灭第二份可写副本。
理念二:人用的版本和机器用的版本分开存,摘要互锁
待办和进度的状态文件是 Markdown,人和 agent(Claude Code、Codex)直接读写。同时每一步变化以事件形式追加进 JSONL 事件流,机器重放事件流可以还原任何时刻的状态。
为什么需要两个版本?因为两类读者的需求相反:人和 agent 运行时要的是当前快照——打开就能看到待办清单和交接内容,Markdown 对它们最友好,也方便 git 追踪和 diff;机器审计要的是完整历史——不光要知道现在什么样,还要知道每一步怎么变的、谁改的、什么时候改的,这只有追加式的事件流能给。
事件流的追加规则只有两条,合起来就是它的全部智能:
同一个事件 ID、同样的内容摘要、再次提交——判定为重放,安全忽略(幂等) 同一个事件 ID、不同的内容摘要——判定为冲突,直接报错
这两条规则意味着:任何事件要么是之前那个事件的重放,要么是必须被看见的冲突,不存在"悄悄不一样"的第三种状态。配合"从 Markdown 回填事件"的逆向通道,两个版本互相提供恢复路径。
两个版本怎么保证内容一致?关键数据绑定摘要(sha256):比如一个待办的完成验证声明,带着它生成时的摘要。两边一致就是正常;对不上立即报错,绝不自动选一边覆盖。配套规则是安全代码的典型写法:
验证声明文件缺失,属于可用性问题,可以从投影重建;文件存在但摘要对不上,属于损坏或篡改证据,直接抛错,不允许第二个来源掩盖故障。
理念三:并发安全按故障模型分层
先说清楚租约(lease)本身是什么。认领一个待办,不只是在待办上写个名字——那无法回答"认领的人还在吗"。租约是一个带有效期的小文件:持有者是谁、什么时候过期、第几代。持有者要定期续约,过期不续就视为不在了,待办回到可认领状态。这就是"谁在做"从声明变成可检测事实的机制。
防并发不是加一个锁就完事。LoopX 对租约用了三层机制,每层防的是一种不同的故障:
三层机制的区分值得展开:版本号数的是"内容改了几次",代际数的是"主人换了几任"——一次成功的续写版本号加一,但代际不变;待办易主时代际加一,旧持有者的版本号再怎么对也会被代际挡住。两者不可互相替代:只用版本号,挡不住"超时旧持有者回来续写"(他的版本号可能是对的);只用代际,挡不住同一代际内的并发写。栅栏防的则是第三种完全不同的故障:LoopX 正在从旧实现迁移到新实现,两个版本的代码可能同时在跑,栅栏保证旧路径在新逻辑接管后写不进去。
只用版本号防不住"超时的旧持有者回来继续写"——这是分布式系统的经典问题;只用前两层防不住"老版本代码还在跑"——这是系统迁移期的经典问题。先列出故障模型,再逐个配机制,这个设计顺序值得照抄。
认领待办时还检查写范围:两个待办如果要改同一批文件,不允许同时被认领——冲突在源头排除,而不是事后合并。这条规则在多 agent 场景下是关键防线:两个 agent 各改各的文件可以并行,一旦要碰同一批文件,系统直接不允许同时开工。
理念四:证据必须可验证,而且和预算挂钩
每个回合的执行结果要写回证据(diff、测试结果、产物摘要),关键证据绑定摘要防篡改。对"完成"的判定还有一条硬规则:模型的自我声明不算证据,验证声明才算——待办可以要求附带一份完成验证声明(跑了什么检查、结果摘要),这份声明本身带摘要、可校验。
更进一步,证据、状态更新、配额消耗三件事被绑在一起:一个回合要算作有效进展,必须同时完成"验证通过、状态已写回、预算已记账"。只花钱没写证据,或写了证据没记账,都不算进展。"做了"和"记了"之间没有缝隙,这是后面第三篇结算机制的伏笔。
还有一条容易被忽略的规则:外部证据没落地,配额冻结——比如待办在等 CI 结果,CI 状态没确认之前,should-run 拒绝启动新回合。
这条规则堵的是长任务最浪费的模式:等结果期间 agent 拿不到答案,就开始反复查询、反复尝试,每次都消耗预算。证据不落地就不许花钱,这种无意义消耗在结构上被禁止了。
这套设计怎么支撑长任务
一个完整的例子:并发、崩溃和篡改
接着第一篇的深色模式任务,看状态内核处理四种故障的具体过程。
场景一:并发认领。你同时启动了两个 agent(一个 Claude Code 一个 Codex)。两个都看中了"补测试"这个待办。"同时认领"是怎么保证只有一个成功的?
关键在前面的基础规则:文件锁加版本号,两层配合。两个认领操作都要先拿同一个锁文件的排它锁——操作系统保证同一时刻只有一个进程持有,两个"同时"的请求被强制排成一前一后。第一个进程拿到锁:读租约(版本号 v4)、校验通过、原子写入新租约(版本号变 v5)、释放锁。第二个进程这时才拿到锁:读到的版本号已经是 v5,和自己预期的不符,写入被拒绝。
也就是说:文件锁负责互斥(两个写不会交叉),版本号负责检测(后到的发现状态已变)。这套组合在语义上等价于"分布式锁 + 乐观锁",只是锁由本机操作系统提供——LoopX 是本地优先设计,所有 agent 进程都在同一台机器上,不需要 Redis 这类外部组件。第二个 agent 接着看下一个待办"改样式变量"——写范围检查发现这个待办要改的文件和"补测试"有重叠,也不允许认领,它只能领一个不冲突的待办。整个过程中没有任何仲裁程序参与:锁、版本号、写范围检查把冲突全部在入口挡住。
边界也要说清楚:文件锁只在单机文件系统上可靠。多台机器共享状态、真正跨主机的并发,这套机制管不了——跨主机协调在 LoopX 的路线图里还是未完成项。
场景二:崩溃后恢复的旧持有者。Claude Code 认领了"改组件",做到一半机器断电。租约有有效期,过期后这个待办视为无主。Codex 接手认领,代际加一。之后 Claude Code 恢复运行,拿着旧租约想续写——代际对不上,写入被拒绝,它必须重新走认领流程。旧持有者恢复后不会污染新持有者的工作。如果断电时正好在写状态文件也没关系:原子写保证了文件要么是断电前的完整旧版,要么是完整新版,不存在写了一半的状态。
场景三:篡改暴露。某个回合的完成验证声明写着"测试全部通过",带摘要 a3f2...。后来有人(或一个越界的 agent)改了这个文件里的测试数字。下一个读取这个声明的流程做摘要校验:算出来是 b91c...,对不上——直接报错停下,进入人工处理。不会静默用改过的数据继续跑。
场景四:等待期间预算冻结。"提 PR"这个待办提交后进入等待 CI 的状态,外部证据还没落地。此时心跳照常触发,should-run 检查发现待办在等外部证据,返回"配额冻结"——agent 不会在等待期间反复查询 CI 状态烧预算。CI 结果落地(无论成败)后,配额解冻,下一个回合根据结果继续。
正常运行时这些机制都不介入:单 agent 顺序执行时,锁不竞争、代际不递增、摘要总是一致。它们只在故障发生的那一刻生效——这是好的安全机制的存在方式。
设计亮点
"缺失可重建、损坏必报错"把可用性问题和安全问题分开了。多数容错代码把两者混在一起:任何异常都回退到备份。但"文件丢了"(可以重建)和"文件被人改了"(必须停下让人看)是两种性质完全不同的事件,混在一起处理意味着篡改可以被重建逻辑悄悄掩盖。分开处理,篡改就没有静默通道。
按故障模型配机制,而不是按"加锁"一个词配机制。"防并发"至少包含三种不同的故障(并发修改、超时后旧持有者恢复、旧代码路径写入),一种机制防不了三种;而版本号和代际的分工说明,连"次数"都要按语义拆——内容修改次数和所有权更替次数是两个计数器。先枚举故障再选机制,设计才不会有遗漏——这个方法论在任何并发系统里通用。
事件流只靠两条追加规则就获得了完整的审计能力。不需要数据库、不需要时间戳服务,"同 ID 同摘要是重放、同 ID 异摘要报错"两条规则加上追加写,就保证了历史不可篡改也不可伪造。可迁移的判断:审计系统的最小要件是"不可修改的追加日志 + 内容摘要",而不是复杂的存储。
过期不清理,反而多了一份审计记录。租约过期不做后台清理,过期的租约文件留在原地。少一个常驻进程,而且每个过期租约本身就是一条记录:谁、在什么时候、丢过哪个待办。
代价与局限
没有集中的数据模型文档。全库不用 Pydantic 这类声明式校验,靠分散的 normalize 函数逐字段校验。防御性极强,但读代码的人要对照生成的契约文档和源码才能拼出数据形状——学习成本比集中 schema 高。
两个版本之间存在核对窗口。Markdown 版本和事件流版本之间靠摘要互锁和回填核对,核对完成之前两边可能不一致。设计保证不一致会被发现,但"发现"本身需要人响应——运维上多了一类告警要处理。
每次决策都要重放事件,成本随事件数增长。配额不存计数器、每次从事件流重算,换来了可审计和可修复(错误的事件可以冲销),但事件积累几个月后,每次 should-run 的重放开销会变大。这个代价在当前的设计里没有看到分层或汇总的缓解。
单 agent 场景是过度配置。一个人一个 goal 自己跑,代际、栅栏、写范围互斥大多用不上。正确性没问题,但要付出的理解成本和最复杂的并发场景一样高。