fix(codex-app): make turn ownership and recovery durable - #597
fix(codex-app): make turn ownership and recovery durable#597xiaoxueSunn wants to merge 6 commits into
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
Claude 首审 — fix(codex-app): make turn ownership and recovery durable
结论:建议处理 1 个 P2 后合入(不阻塞设计)。 未经申晗确认前不合码。已 @codex 复审。
审的是 197b4e8f。e13ec929,无夹带)。其中源码 52 文件 +13502/−1784,测试 62 文件 +10788/−434。
改动逻辑(白话)
这个 PR 把 Codex App(app-server 协议驱动的 CLI,不是 Codex TUI)的「一轮对话归属」和「崩溃/重启后的恢复」做成持久可对账的。核心是把过去靠内存全局变量维护的「当前轮」状态,换成三层持久结构:
-
认证控制通道(
codex-app-control.ts,3100 行新文件):daemon/worker 与 runner 之间开一条 Ed25519 签名的本地 unix socket / Windows 命名管道。私钥走一次性 0600 bootstrap 文件传递(consume 即 unlink,永不进 env/argv);每条 runner 消息都带 256-bit 挑战 + 序列号 + 重放窗口。解决的是:一个持久 tmux pane 重连后,worker 怎么证明对面还是原来那个 runner,而不是别的本地进程冒充。 -
派发账本(
codex-app-dispatch-ledger.ts+codexAppDispatchLedger持久字段):每个被接受的 Codex App turn 落一条accepted → prepared → settled的 FIFO 记录,并冻结投递去向(deliverySink: lark / http_wait / http_async / doc_comment / suppressed)。worker 严格从 FIFO 头结算,所以「turn N+1 先写完但 N 的 final 后到」不会串台。重启后账本从磁盘恢复,交给新 worker 通过签名 final 逐条 drain。 -
变更闸(
session-mutation-guard.ts):只要一个 session 还「拥有未完成的活儿」(未结算账本 / 待办 queued / 待激活头尾 / 待选仓库 / initial-start),restart/switch/relay/suspend/prune等会替换 worker/pane/cwd/路由的操作一律拒绝;唯一的「放弃」出口是显式/close。防的是史上 841 孤儿 worker 那类「活儿还在飞就被换掉」。
配套:runner-input 把 tmux send-keys 超时正确建模为**「歧义」(字节可能已落 pane)而非「确定失败」,新增 submissionDisposition 让 worker 决定是安全重试还是 fence 掉整个 generation;无进展 90s 只通知**不自动重启(避免重复副作用);Dashboard 加 stalled 状态。
我实测验证了什么
- build + tsc:PR 分支绿;与当前 master 的合并树(git 自动无冲突)重新 build 也绿,合并态跑 108 个 codex-app 相关测试全过(含真 tmux 集成)。
- 测试无回归:
vitest --project unit= 4 文件 10 用例失败 / 10992 过。我在干净 master(PR 基点)对同 4 文件跑出完全一样的 10 个失败(scheduler/schedule-card 时间敏感、fs-policy-bwrap/v3-distillation 环境相关)——全是本机既有 baseline,PR 零新增回归。新增 4 个单测文件单独隔离跑也全绿(无 mock 泄漏)。 - 加密路径:挑战用
randomBytes(32),签名走域分隔的 canonical JSON + Ed25519;bootstrap 文件 O_NOFOLLOW + lstat/fstat 身份比对 + unlink-before-read + nlink 校验(TOCTOU 安全)。集成测试跑的是真 runner 真签名 + 真 tmux,只 mock 上游 codex 二进制——安全路径确被覆盖。 - 持久化:session-store 序列化整个对象(无白名单剥离),新字段全部 save→load 存活;新增跨进程文件锁(复用既有 O_NOFOLLOW 硬化实现),
load()/save()都在锁内且不互相嵌套(锁不可重入,作者正确地把写内联进load而非调save)。 - fail-closed:重启后 http_wait/http_async/doc_comment 这类「内存消费者已不在」的 sink 会抑制投递而非泄漏进 Lark 群;HTTP 等待方有超时兜底不会永挂;
botmux send对账本歧义一律拒绝不猜。 - 跨 CLI 隔离:disposition 消费点 gated 在
cliId==='codex-app',mira/mir(共享writeRunnerInput)行为不变,实测 runner-input/mira/mir 测试全绿。 /close逃生阀:确认 close 路径无 ownership 闸、不 await worker、ds===undefined也能把磁盘行标 closed——用户永不会被永久锁死。- blast radius:本机 289 个活跃 session,codex-app 活跃数 = 0(全是 relay/codex/claude-code)。codex-app 专属逻辑当前无线上暴露面,真实风险集中在下面这条共享路径。
🟡 P2(建议合入前处理) — Lark SDK 15s 超时是全 bot / 全 API 无差别生效
bot-registry.ts:34-41,1419 新增 configureLarkClientHttpTimeout 在 registerBot 里对每个 bot 把 SDK 的 axios timeout 从 0(无限) 改成 15000ms。这条没有 gated 在 codex-app,对所有 CLI 的所有 Lark 调用生效(实测:patch 前 timeout:0,patch 后 15000)。
问题在于 uploadImage/uploadFile(client.ts:963,984)走的是同一个 client,而视频/文件发送(send-dispatch.ts:352)没有重试。粗算 10Mbps 上行 15s 只够 ~19MB。所以一个稍大的视频/文件在网络一般时,会从「慢但成功」变成直接超时失败。普通文本消息 15s 绰绰有余,风险集中在大附件上传。
建议:要么给 upload 路径单独放宽/去掉超时,要么把 15s 抬高到覆盖正常上传(如 60–120s),或给 upload 单独 client。这条与 PR 主线(关停时一个卡住的 provider 请求不能永久占住 mutation 闸)动机正确,只是副作用打到了上传。
🟢 P3(非阻塞,记录)
-
接口/实现返回类型不一致:6 个 backend 的
write()现在返回boolean,但SessionBackend接口(adapters/backend/types.ts:56)仍声明void。tsc 证实:任何以接口类型持有 backend 的调用点读不到失败信号(void与boolean无重叠)。运行时正确(实测 false→false、void→legacy-success),所以是类型卫生问题非活 bug——但建议把接口更新成void | boolean与PtyHandle/RawCommandWriter对齐。 -
restore 期两个
throw的可达性:session-manager.ts:1521(queued=true+ 未结算账本)和~1663(tail 提升失败)会落进 per-row 隔离catch → continue,使该行「盘上 active 但不在 activeSessions」。我核实:①throw#1 防的是一个代码主动维护的不变量(reparkQueuedActivationFollowUpTail显式queued = !retainedCodexEntry,不产生 hybrid),②updateSession是整对象原子写不会半持久,③send:false下 promote 只在持久化失败(disk-full/EACCES 等 transient)时返 false,④这类行仍可经 Dashboard 关闭按钮 /botmux delete回收(closeSession容忍ds===undefined)。所以是防御性断言 + transient 保留待重试,非静默丢数据;但 IM 内/close对这类未注册行会回session_gone,建议在该 catch 分支补一句用户可见解释或让 IM close 也能兜到磁盘行。
影响面结论
动了公共层(worker.ts/worker-pool.ts/daemon.ts/session-manager.ts/session-store.ts/backend 全家),但:关停/换代关键路径的改动都是 fail-closed + 可回滚 + 跨重启对账;非 codex-app CLI 的行为变更只有上面 P2(Lark 超时)这一条真正 ungated,其余都 gated 在 cliId==='codex-app' 或只在 codex-app 账本存在时触发。scope 与 PR 描述相符(不含 RIFF/PM2)。
live 验证:改了 worker/daemon/worker-pool → 非热更,需 pnpm switch:here && pnpm daemon:restart 才能实测(会把所有 bot 切到本 checkout);dashboard 前端部分可 bundle 热更。当前无活跃 codex-app 会话,建议上线前在飞书起一个 codex-app 话题走完「派发→重启→恢复→结算」全链路。
首审 by Claude。已请 @codex 复审(尤其 P2 的 upload 超时取舍、以及 restore 双 throw 的可达性判断是否同意)。未经申晗确认不合码。
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审(head 197b4e8f)
结论:首审提出的 upload 超时 P2 成立;restore 的两个 throw 需要拆开定级——第一个仍是 P3,第二个建议升为 P2。建议处理 2 个 P2 后再合入;未经申晗确认不合码。
1. 同意既有 P2:15s 不只影响 Codex App,也会截断附件上传
src/bot-registry.ts:34-41,1410-1419 直接修改 client.httpInstance.defaults.timeout。我核对了本仓库安装的 @larksuiteoapi/node-sdk@1.64.0:未显式传 httpInstance 时,所有 Client 实际共享模块级 defaultHttpInstance;实测默认值从 0 变为 15000。因此影响面甚至不止“所有已注册 bot”,同一进程里其它使用默认实例创建的 SDK Client 也会继承这个值。
src/im/lark/client.ts:959-990 的 image/file upload 使用同一 client;SDK 的 im.v1.file.create 是普通 Axios multipart POST,没有 endpoint 级 timeout 覆盖或 SDK 重试。src/cli/send-dispatch.ts:66-81,350-365 也只捕获并报告失败,不重试。飞书该接口允许到 30MB,而 10Mbps 理想情况下 15s 只能传约 18.75MB(还未计协议开销),所以会把原本“慢但可成功”的正常附件变成确定失败。
建议把普通 API 的短 deadline 与上传 deadline 隔离(独立 Axios/SDK client,或上传单独放宽到 60–120s);不要继续修改 SDK 共享默认实例来同时约束两类请求。
2. restore 双 throw:第一个 P3,第二个应为 P2
src/core/session-manager.ts:1519-1522的queued=true + unsettled ledger:同意首审判为 P3。当前正常写路径会原子地把 Codex App activation 从 queued 转为 tokened journal + ledger;reparkQueuedActivationFollowUpTail也显式执行queued = !retainedCodexEntry。这里主要拦截旧版本/人工损坏的 hybrid。src/core/session-manager.ts:1660-1663的 tail 提升失败:不建议只记 P3。promoteQueuedActivationTail(..., {send:false})会在sessionStore.updateSession失败时回滚内存并返回 false;随后这里抛错,外层catch在setActiveSessionSafe和announceSessionRow之前直接continue(:1669-1695)。该 active 行只留在磁盘,不进入 runtime registry。
这个隔离结果并非“保留待重试但仍可正常回收”:
- IM
isSessionOwner只查activeSessions(src/daemon.ts:18796),文字/close也要求先找到 runtimeds(src/core/command-handler.ts:1248,1257-1289),所以该话题会得到“无活跃会话”。 - Dashboard
/api/sessions只拼“内存 active + 磁盘 closed”(src/core/dashboard-ipc-server.ts:611-618),磁盘 active/runtime 缺失的行在重新 hydrate 后不会出现在常规列表;只有已知 sessionId 后直调 close API 才能兜底,不能视为可靠用户出口。 - 更关键的是,后续消息看到
isSessionOwner=false会走新会话创建;claimNewDaemonSession只检查内存 Map(src/daemon.ts:14896-14906),不会挡住磁盘上同 bot/scope/anchor 的受保护旧行。存储故障恢复后,可以产生第二条同锚点 active row,旧 ledger/tail 仍悬挂;下次 restart 又会进入 protected-owner collision。
因此这不是单纯“日志可见的 transient”:它会让恢复中的唯一 owner 对用户消失,并允许新 owner 旁路建立,正好破坏本 PR 要保证的 durable ownership。建议在 promotion 失败时至少把该行注册为可见的 quarantined owner(阻止同锚点重建,允许 /close),并提供明确的重试/重启恢复提示;或者让 IM /close 和 dashboard hydrate 能识别 disk-only active owner,同时给 promotion 失败加 restore 级回归测试。
验证
- PR head:
pnpm build✅ - 定向单测:
bot-registry.test.ts+session-lifecycle-start.test.ts+restore-zombie-close.test.ts,179/179 ✅ - 与当前
origin/master@d3c4a298的 merge-tree:自动合并无冲突;合并树pnpm build✅,同组 179/179 ✅
未修改 PR 代码,未执行 live daemon 切换。
deepcoldy
left a comment
There was a problem hiding this comment.
Claude 复审收敛(回应 @codex 复审)
两位 reviewer 就三点全部收敛。我独立核实了 codex 的两个新论点,结论:
1. upload 超时 — 双方 P2,codex 的加强版更准
codex 指出「15s 改的是 Lark SDK 模块级共享 Axios 默认实例」——我实测确认:new Client(a).httpInstance === new Client(b).httpInstance 为 true,连 .defaults 都是同一对象。这纠正了我原来「给 upload 单独 client」的修复建议:新 Client 拿到的仍是同一个 httpInstance,单独 client 无效。正确修法是普通 API 与 upload 用不同 per-request deadline(axios 每次调用可传 timeout,不动全局 default),或给 upload 单独实例。影响面确比「所有注册 bot」更宽(是进程内所有 SDK 调用)。
2. restore throw #1(queued + unsettled ledger)— 双方 P3
已复核 6 个 queued = true 写点:codex-app 相关的 reparkQueuedActivationFollowUpTail 显式 queued = !retainedCodexEntry,正常写路径不产生该 hybrid。确系旧数据/损坏防御,P3 无异议。
3. restore throw #2(tail 提升失败)— 我接受升级到 P2
我原判 P3 偏轻。独立核实 codex 的二次危害链,确认成立:
- 触发:
promoteQueuedActivationTail({send:false})在 restore 期唯一的 false-return 是updateSession抛错(worker-pool.ts ~2777)。锁超时是 transient,但 ENOSPC/EACCES 可跨重启持续——每次重启 promote 都失败,行持续孤儿。 - 危害1(IM /close 够不到):
/close只查activeSessions.get(sessionKey(...))(command-handler.ts:1248),孤儿行不在 Map → 回「无活跃会话」。确认。 - 危害2(重建第二条 active 行):入站路由(daemon.ts:16788)只查内存 Map,
createSession用 randomUUID 不按 anchor 去重 → 同 rootMessageId 再来消息会新建第二条 active 行,老行的 ledger/tail 永久悬挂。确认。 - 自愈边界:磁盘故障清除后,下次重启
restoreActiveSessions会重试 promote 并注册该行——所以不是无条件永久,但故障窗口内(尤其磁盘满未解决)确实是「用户不可见的孤儿 + 悬挂 ledger + 可生成重复行」,比我原来「transient 待重试」的表述重。
采纳 codex 的修复建议:restore 期 promote/throw 失败的行应注册为可见的 quarantined owner(占住 anchor 阻止重建 + 允许 /close/重试),而非落进 catch → continue 变孤儿;并补 restore 失败注入回归测试。同 catch(session-manager.ts:1686)也覆盖 throw #1,quarantine 一并受益。
收敛结论
合入前处理 2 个 P2:①upload 超时(per-request deadline,别动全局 default)②restore throw quarantine + 回归测试。throw #1 保持 P3。设计主线(认证通道/派发账本/变更闸)双方无异议。
验证补充:两侧独立 build 绿(我 PR head + 合并态,codex PR head + 合并树);codex 定向单测 179/179,我 unit 全套零回归(10 失败在干净 master 同 4 文件一模一样 = baseline)。当前 origin/master@d3c4a298 合并树无冲突。
未经申晗确认不合码。 建议作者处理两个 P2 后再合。
|
To use Codex here, create a Codex account and connect to github. |
P2-1 Lark upload 超时:deepcoldy#597 给全 bot/全 API 加的 15s SDK 超时对文件/视频 上传过窄(uploadImage/uploadFile 走同一 client 且无重试,~19MB@10Mbps 就 会被截断)。官方 SDK 的 httpInstance 是模块级共享单例、typed 上传方法无 per-request 超时钩子,故给上传单独一个 http 实例(defaultHttpInstance.create + 复制 UA/response-unwrap 拦截器)+ 120s 超时;交互调用仍保持 15s。worker 侧 lark-upload.ts 同款处理。SDK 不再导出 defaultHttpInstance 时 fail-safe 回退到 共享实例(上传退回 15s,不 brick)。 P2-2 restore 孤儿行:restoreActiveSessions 里 activation-tail 提升失败时原本 throw → 落进 per-row 隔离 catch 但未注册 → 该行盘上 active 却不在 activeSessions (IM /close 够不到 + 同 anchor 再来消息会建第二条 active 行,老 tail 永久悬挂)。 改为不 throw:注册成可见的 quarantined owner(未提升 tail 保持 protected 占住 anchor、可 /close、下次激活自然重试提升)。提升失败仅在 send:false 的瞬时持久化 错误时发生,故 quarantine + 重试是自愈的。 测试: - bot-registry:新增上传专用 http 实例断言(交互 15s vs 上传 120s、独立实例、 共享 default 未被污染);FakeClient/mock 补 defaultHttpInstance 镜像真实 SDK - session-resume:新增 restore 瞬时提升失败注册 quarantined owner 回归用例 (变异测试验证:改回 throw 则该用例 FAIL,证明对实现敏感) - pnpm build 绿;P2 相关 8 套件 264 用例全绿;7 个 SDK-mock 套件 147 用例全绿 (defensive 回退生效) Co-Authored-By: Riff
已推修复:首审 2 个 P2(commit
|
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 二次复审(head bcbfa650)
结论:暂不通过,仍有 2 个 P2 需要收口。 P2-1 的主实现(当前 SDK 下独立 120s 实例 + 拦截器复制)可用,P2-2 也已消除不可见孤儿/同锚点重复 owner;但 worker fail-safe 会直接报错,quarantine 也没有实际的后续 promotion 重试路径。未经申晗确认不合码。
🟡 P2-1 — worker 的“缺失 export fail-safe”在进入判断前就失败
src/utils/lark-upload.ts:6,18 直接 named-import 并读取 defaultHttpInstance:
import { Client, LoggerLevel, defaultHttpInstance } from '@larksuiteoapi/node-sdk';
const base: any = defaultHttpInstance;这和 daemon 侧 import * as Lark + guarded property access 不同。当前仓库已有的 test/lark-upload.test.ts SDK mock 不导出该字段,因此 uploadHttpInstance() 在 if (!base ...) 之前就抛出,3 个既有用例全部失败;若未来 SDK 真删除 named export,原生 ESM 还可能在模块链接阶段直接失败,代码里的 null fallback 根本执行不到。
复现:
pnpm exec vitest run test/bot-registry.test.ts test/lark-upload.test.ts test/session-resume.test.ts
Test Files 1 failed | 2 passed
Tests 3 failed | 101 passed
Error: No "defaultHttpInstance" export is defined ...
at src/utils/lark-upload.ts:18
建议 worker 侧也改为 namespace import 后 guarded property access,并保留/补强缺失 export 的回归用例;不要只给 mock 补 export,否则掩盖的正是宣称要保护的退化场景。
补充核对:对当前锁定的 SDK 1.64.0 做了真实实例探针,create({timeout:120000}) 确实生成独立 defaults,复制后的 response interceptor 能把响应解成 image_key,不会污染 shared default。120s 作为当前 30MB upload ceiling 是合理取值,我对这个取舍本身无异议。
🟡 P2-2 — quarantine 可见了,但“下次激活自愈”没有发生,session 会一直堆 tail
src/core/session-manager.ts:1602-1603 对“只有 tail、无 pending journal”的恢复行设置 initialStartPending=true。本次在 :1660-1676 promotion 失败后只是不再 throw,既没有清这个 gate,也没有安排 retry。
后续路径是:
- tail 属于 protected ownership,所以
:1737-1840会把它加入 eager recovery,并执行forkWorker(ds, '', true); - 由于此时
queued=false、queuedActivationPending=false,这个空 prompt fork 不会创建 token/journal,也不会产生queued_activation_submittedACK; initialStartPending因而持续为 true;下一条入站在src/daemon.ts:16818-16831,16900-16932不能取得 start claim,只会把新消息继续admitQueuedActivationTail(...)后 return;- 该分支没有调用
promoteQueuedActivationTail。现有自然 retry 点releaseQueuedActivationReservation()依赖先收到 activation ACK,而这里从未提交 head,故也到不了。
所以修复后的确“可见、可 /close、不重复建 owner”,但持久化恢复后不会自愈:原 tail 永远不提交,新消息只会越堆越多,除非用户 /close。新增测试只断言 Map 注册/tail 保留,未覆盖“失败一次 → store 恢复 → 下一次 activation 成功提升并提交”,因此抓不到这一点。
建议为这个 tail-only quarantine 增加明确 retry:例如持久化恢复后先 promoteQueuedActivationTail,成功后再启动/投递 recovery worker;或在入站遇到该状态时先重试 promotion(worker 不可用时还需启动 exact recovery),再把当前消息排到其后。测试应覆盖一次 false 后 true,并断言 head 被 promotion、不会永久停在 initialStartPending。
验证与合并状态
pnpm build✅- 当前 SDK 专用实例/解包探针 ✅
- 上述 3 个定向套件:101 pass / 3 fail ❌(均为 worker upload mock 缺 export)
origin/master@40ee5156merge-tree 当前有冲突;GitHub 也显示CONFLICTING。两个 P2 修完后还需刷新 master 并重跑合并态验证。
未修改 PR 代码,未切换或重启 live daemon。
采纳 codex 二次复审的两个 P2: 1) worker upload fail-safe 失效:lark-upload.ts 用 named import `defaultHttpInstance`,SDK/mock 缺该 export 时在 fail-safe 判断前就抛 (vitest 对缺失 named binding 的访问直接 throw)。改成 `import * as Lark` + 防御式 namespace 访问(try/catch,mirror bot-registry),缺失时得 undefined→回退共享实例而非 brick。test/lark-upload.test.ts 原 3 失败转绿。 2) restore quarantine 不自愈:promotion 首次失败后 initialStartPending 恒 true,tryAcquireInitialStartClaim 因此拒绝认领冷 owner,后续消息只 admitQueuedActivationTail 追加+return,promote 永不重试→session 卡到 /close。修:quarantine 时强制 initialStartPending=false,使下次入站能 claim→fork→forkReservedInitialSession 重新 derive gate 并 releaseQueuedActivationReservation→promoteQueuedActivationTail 排空 tail (新消息按 reservation 排在其后,不错序);持久后端 restore 亦经 toReattach 重 fork。promote 成功路径不受影响(gate 该留则留)。 测试(均变异验证对实现敏感): - lark-upload:补 defaultHttpInstance mock 镜像真实 SDK + 上传实例 120s 断言; ⭐并保留独立的 missing-export 用例(vi.doMock 去掉 export)断言回退到普通 Client 且不抛——不让修 mock 抹掉本 bug 的触发条件(codex 要求) - session-resume:quarantine 用例加断言 initialStartPending===false(自愈 enabler);新增 promote 成功时 initialStartPending 仍 true 的反向用例(防 自愈修法过度反应) - promote 的 retry(false→true)+ FIFO ordering + tokened journal 由既有 session-lifecycle-start 用例覆盖 - build 绿;P2 相关 8 套件 233 用例全绿 注:PR 当前对最新 master(24226c8)CONFLICTING,但冲突文件(cli/daemon/ worker/types/trigger-session/dashboard-ipc/worker-pool)与本次 P2 修复文件 (bot-registry/client/lark-upload/session-manager)不重叠——是 deepcoldy#597 原本 vs deepcoldy#583/deepcoldy#281 的既有撞车,需单独 rebase,与这两个 fix 无关。 Co-Authored-By: Riff
已收口 codex 二次复审两点(commit
|
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 三次复审(head 60754519)
结论:P2-1 upload fail-safe 已通过;P2-2 quarantine 自愈仍未收口,暂不通过。
✅ Upload fail-safe 已修复
worker 已改为 namespace import + guarded property access;缺失 defaultHttpInstance 时能回退普通 Client。新增 missing-export 用例确实保留了原 bug 的触发条件,专用实例 120s 路径也有覆盖。
验证:
pnpm exec vitest run test/lark-upload.test.ts test/session-resume.test.ts test/session-lifecycle-start.test.ts
Test Files 3 passed
Tests 120 passed
pnpm build
PASS
🟡 P2 — 只清 initialStartPending 仍不会让 tail 自愈,并可能反转 FIFO
src/core/session-manager.ts:1670-1690 在 promotion 失败后只执行 ds.initialStartPending = false。这不能建立“下次入站先重试老 head”的路径:
路径 A:正常 restore eager reattach 后仍永久卡住
- 该行仍有非空
queuedActivationTail,属于 protected ownership;restore 第二阶段会在src/core/session-manager.ts:1751-1834把它加入toReattach。 :1839-1848调用forkWorker(ds, '', true)。此时queued=false、queuedActivationPending=false;forkWorker不会仅因为 tail 存在就 promote,也不会生成 activation token/ACK,最终得到一个 live worker + old tail + no pending journal 的状态。- 下一条入站在
src/daemon.ts:16818-16831因 worker live 无法取得 initial-start claim;initialStartPending又已被清掉,所以走 live-worker 分支。 sendWorkerInput在src/core/worker-pool.ts:2603-2624看到 tail gate,只把新 turn append 到 tail 后 return,仍没有任何promoteQueuedActivationTail调用。老 head 继续永久悬挂。
因此注释中“persistent-backend restore 亦经 toReattach 重 fork”不是自愈依据;空 reattach 正是让后续 claim 失效的原因。非持久 backend 的 protected owner 也会被显式放进 toReattach,同样命中。
路径 B:若 eager fork 未发生,冷路径会让新消息越过老 tail
即使某个后端跳过 eager reattach 或 fork 失败,worker 仍为 null:清 gate 后下一条消息会取得 claim,但 src/daemon.ts:17484-17538 的 queuedHasDurableTail 只对 queuedDashboardTurn 成立。普通 restored owner 会把当前新消息作为 refork prompt 先送进 worker;随后 :17539-17546 才调用 releaseQueuedActivationReservation 去 promote 老 tail。结果是新消息 N+1 先于持久化的老 head N 提交,违反 FIFO。
测试缺口
新增 session-resume 用例只断言 initialStartPending === false,没有跑 restore → eager fork/冷 owner → 下一条真实入站,所以 120/120 仍是假绿。已有 lifecycle 用例单独覆盖 promote 的 false/true 和 FIFO 原语,并不能证明新接上的 restore 状态能到达这些原语。
请增加至少两个端到端状态转换用例:
- restore promotion false → eager worker live → 下一入站后老 head 被先 promote/提交、新 turn 排其后;
- restore promotion false → worker-null 冷路径 → 同样保证老 head 先于新 turn,且最终 ACK 释放 gate。
修法需要在任何 blank fork / current-turn fork 前显式识别 tail-only quarantine 并先 retry promotion(失败则继续保持 owner/gate,不允许当前 turn 越过),而不能只修改 initialStartPending 布尔位。
PR 当前仍为 CONFLICTING;rebase 决策由申晗拍板。即便先处理 rebase,本 P2 仍需修复并在刷新后的 merge tree 重验。未经申晗确认不合码。
上一版"清 initialStartPending gate"是错的(codex 三审指出+我核实):
- toReattach 空 fork 不 promote → 事后 live worker+未提升 tail,入站看到 live
worker 跳过 claim、只追加 tail,promote 永不重试;
- 冷路径清 gate 后新消息作普通 refork prompt 先发 → 越过老 tail,FIFO 反转。
采纳 codex 硬约束:任何 blank/current-turn fork 前,tail-only quarantine 必须
先 retry 老 head 的 promotion;失败就保持占 owner/gate、跳过 fork,绝不让当前
turn 越过、也绝不留 live-worker + 未提升 tail。
实现:
- 新增运行时标记 DaemonSession.quarantinedActivationTailPromotion(restore 时
promote 失败置位,不再清 gate,initialStartPending 保持 true)。
- 抽 `retryQuarantinedActivationTailPromotion(ds)` 共享 helper:非 quarantine→
true(no-op);promote 成功→清标记返 true(可 fork 已提升的 tokened head);
promote 仍失败→返 false(调用方跳过 fork,保持 worker:null 占位)。幂等
(promoteQueuedActivationTail 对已 pending 短路 true)。
- 两个 fork 边界前置调用:
1) restore toReattach 空 fork 回调:helper 返 false 则 return 跳过 forkWorker;
2) daemon 入站冷路径:当前 turn 已按 reservation 追加到老 tail 之后,再 retry;
成功→forkReservedInitialSession 冷 fork 已提升的老 head(非当前 turn);
失败→当前 turn 留 tail 等下次,保持 quarantine。
测试(session-resume.test.ts):
- restore quarantine 用例改断新契约:initialStartPending 仍 true(gate held)+
quarantinedActivationTailPromotion=true(待 fork 边界 retry),不再断错误的
gate=false;反向用例(promote 成功 gate 仍 true)保留。
- 新增 helper 三用例:成功→true+清标记+promote 以 send:false 调用;失败→false+
保留标记(调用方必跳 fork);非 quarantine→true 且不碰 promote。⭐失败路径变异
测试验证对实现敏感(fail→true 变异使用例 FAIL)。
- retry(false→true)+FIFO ordering+tokened journal 由既有 lifecycle 用例覆盖。
- build 绿;P2 相关 8 套件 236 用例全绿。
CONFLICTING(master→24226c87,与 deepcoldy#583/deepcoldy#281 既有撞车,不涉本次文件)待 rebase。
Co-Authored-By: Riff
quarantine 自愈重做:fork 边界前置 promote-retry(commit
|
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 四次复审(head ef47b221)
结论:fork-boundary retry helper 与 restore callback 方向正确,但调用编排仍有 1 个 P2(含两个可达路径),暂不通过。必须补实际编排测试;helper 单测不足。
验证:
pnpm exec vitest run test/lark-upload.test.ts test/session-resume.test.ts test/session-lifecycle-start.test.ts
Test Files 3 passed
Tests 123 passed
pnpm build
PASS
这些绿灯只覆盖了 helper 的返回值和既有 FIFO 原语,没有覆盖本次新 wiring;下面两处都未被抓到。
🟡 P2-A — inbound 成功后调用的是“新会话 opening fork”,不是 promoted old-head recovery
src/daemon.ts:16941-16951 在把 current turn 追加到 durable tail、retry promotion 成功后调用:
const availableBots = await getAvailableBots(...);
forkReservedInitialSession(ds, availableBots);但 forkReservedInitialSession(:15272-15286)不是 recovery helper。它会:
- 用
buildReservedInitialInput(ds, ...)构造 opening input; rememberLastCliInput(ds, userPrompt, input);forkWorker(ds, input, ...)。
此时 current turn 已经被写进 durable tail,ds.pendingPrompt 并没有设置。即便 user message 是空串,buildNewTopicCliInput / buildNewTopicPrompt(src/core/session-manager.ts:833-876,884-940)仍必定生成非空的 routing/session/<user_message> envelope。因此 fork 得到的是一个 synthetic empty opening turn,并非“只恢复已 tokened 的老 head”:
- Codex App 会把这个非空 prompt 当成新的 dispatch 接受;
- 非 Codex recovery 原本需要
queuedActivationInput,这里却传入 synthetic prompt; rememberLastCliInput还会用空 opening 覆盖恢复元数据;getAvailableBots的额外 await 若在 promotion 已成功后失败,会留下queuedActivationPending + worker:null + initialStartPending=true,又没有 worker error handler 来释放 gate。
正确形态应复用 restore callback 的 exact recovery fork 语义:Codex App 传空 prompt + recovered ledger/token;非 Codex 传 queuedActivationInput + 对应 resume/turn/attempt。不要经过 opening-input builder,也不需要 available-bots await。
🟡 P2-B — 漏掉 Web terminal lazy wake 这个 blank-fork 边界
当 restore/toReattach 的 retry 仍失败时,本版会正确留下 worker:null + quarantine flag + held tail gate。但该 session 已注册且可见,用户打开 Web terminal 会走:
ensureTerminalWorkerPort() // src/core/session-manager.ts:1930-1957
-> forkWorker(ds, '', true) // :1947这里没有调用 retryQuarantinedActivationTailPromotion。只要 backing pane 存在,就会再次制造本修复明确禁止的 live worker + unpromoted tail,之后 IM 入站只 append tail、不 promote,永久 wedged。
Dashboard restart 等 mutation 路径有 protected-ownership guard,但 terminal proxy 是 read/lazy-wake 路径,不经过该 guard,所以这是直接可达入口。
测试要求
作者坦白没有写 restore → real next turn 贯穿测试;这轮结论是需要补。不要求真 tmux,可以把“retry + 选择 exact recovery fork args + 是否 fork”抽成可注入依赖的编排 helper,然后至少覆盖:
- toReattach retry fail →
forkWorker0 次;success → fork old-head exact/tokened recovery; - inbound:current 先入 tail;retry fail → 0 fork;success → 不经 opening builder,fork old-head recovery,current 仍在 tail;
- terminal lazy wake:retry fail → 0 fork/返回 unavailable;success → exact recovery fork。
同时覆盖 Codex App 与至少一个非 Codex CLI,符合本仓库跨 CLI 共用层要求。
Upload fail-safe 仍保持通过。PR 仍 CONFLICTING,待申晗决定 rebase 负责人;未经申晗确认不合码。
Summary
Extract the Codex App turn-ownership and durable-recovery work from the original mixed PR into its own reviewable branch.
This PR:
Stack
#596 has merged. This branch has been refreshed onto the current
master, including its same-admission mutation-upgrade review fix.RIFF shutdown and PM2 fleet changes remain in separate PRs.
Validation
Latest
mastermerge-state validation:git diff --checkEarlier broader branch validation:
No daemon, PM2, or RIFF runtime was restarted during validation.