Skip to content

fix(codex-app): make turn ownership and recovery durable - #597

Open
xiaoxueSunn wants to merge 6 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/codex-app-core
Open

fix(codex-app): make turn ownership and recovery durable#597
xiaoxueSunn wants to merge 6 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/codex-app-core

Conversation

@xiaoxueSunn

@xiaoxueSunn xiaoxueSunn commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Extract the Codex App turn-ownership and durable-recovery work from the original mixed PR into its own reviewable branch.

This PR:

  • persists exact Codex App dispatch ownership and delivery sinks
  • serializes queued activation and follow-up handoff
  • authenticates the app-server control channel and preserves replay boundaries
  • makes worker/session replacement identity-safe
  • keeps generic backends compatible without including the RIFF shutdown or PM2 fleet changes

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 master merge-state validation:

  • Codex App / mutation-gate focused regression: 11 files, 177 tests passed
  • includes a new same-admission cap-sweep regression that preserves the durable Codex App owner
  • full unit suite: 694 files passed, 3 skipped; 10,972 tests passed, 34 skipped; 0 failed
  • real tmux worker replacement / reattach integration: 4 tests passed within both focused and full-suite runs after generating current merge-state build artifacts
  • TypeScript compilation
  • git diff --check

Earlier broader branch validation:

  • all changed unit tests: 60 files, 1,974 tests passed
  • production TypeScript/dashboard build
  • public-domain audit and dist audit

No daemon, PM2, or RIFF runtime was restarted during validation.

deepcoldy added a commit that referenced this pull request Jul 26, 2026
双人独立复审通过(Claude + Codex),申晗拍板合入。

- 首审→codex 抓到并发自锁→作者 78a5e1d 修复→双人复审确认修复正确
- 纯新增 gate 基础原语,零 runtime importer,运行时行为不变
- build + 23/23 测试绿;codex 原始死锁探针翻绿;作者回归测试 discriminating(旧码 30s 死锁/新码通过)
- 后续 #597/#598/#599 消费者 PR 将各自单独 review
@xiaoxueSunn
xiaoxueSunn marked this pull request as ready for review July 26, 2026 10:38
@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner July 26, 2026 10:38

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude 首审 — fix(codex-app): make turn ownership and recovery durable

结论:建议处理 1 个 P2 后合入(不阻塞设计)。 未经申晗确认前不合码。已 @codex 复审。

审的是 197b4e8f⚠️ merge-base 陷阱:GitHub 报 114 文件 +24290/−2218,实际就是真 diff(PR 基点 == 当时 master head e13ec929,无夹带)。其中源码 52 文件 +13502/−1784,测试 62 文件 +10788/−434。

改动逻辑(白话)

这个 PR 把 Codex App(app-server 协议驱动的 CLI,不是 Codex TUI)的「一轮对话归属」和「崩溃/重启后的恢复」做成持久可对账的。核心是把过去靠内存全局变量维护的「当前轮」状态,换成三层持久结构:

  1. 认证控制通道(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,而不是别的本地进程冒充。

  2. 派发账本(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。

  3. 变更闸(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 新增 configureLarkClientHttpTimeoutregisterBot 里对每个 bot 把 SDK 的 axios timeout0(无限) 改成 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(非阻塞,记录)

  1. 接口/实现返回类型不一致:6 个 backend 的 write() 现在返回 boolean,但 SessionBackend 接口(adapters/backend/types.ts:56)仍声明 void。tsc 证实:任何以接口类型持有 backend 的调用点读不到失败信号(voidboolean 无重叠)。运行时正确(实测 false→false、void→legacy-success),所以是类型卫生问题非活 bug——但建议把接口更新成 void | booleanPtyHandle/RawCommandWriter 对齐。

  2. 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 的可达性判断是否同意)。未经申晗确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-1522queued=true + unsettled ledger:同意首审判为 P3。当前正常写路径会原子地把 Codex App activation 从 queued 转为 tokened journal + ledger;reparkQueuedActivationFollowUpTail 也显式执行 queued = !retainedCodexEntry。这里主要拦截旧版本/人工损坏的 hybrid。
  • src/core/session-manager.ts:1660-1663 的 tail 提升失败:不建议只记 P3promoteQueuedActivationTail(..., {send:false}) 会在 sessionStore.updateSession 失败时回滚内存并返回 false;随后这里抛错,外层 catchsetActiveSessionSafeannounceSessionRow 之前直接 continue:1669-1695)。该 active 行只留在磁盘,不进入 runtime registry。

这个隔离结果并非“保留待重试但仍可正常回收”:

  1. IM isSessionOwner 只查 activeSessionssrc/daemon.ts:18796),文字 /close 也要求先找到 runtime dssrc/core/command-handler.ts:1248,1257-1289),所以该话题会得到“无活跃会话”。
  2. Dashboard /api/sessions 只拼“内存 active + 磁盘 closed”(src/core/dashboard-ipc-server.ts:611-618),磁盘 active/runtime 缺失的行在重新 hydrate 后不会出现在常规列表;只有已知 sessionId 后直调 close API 才能兜底,不能视为可靠用户出口。
  3. 更关键的是,后续消息看到 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.ts179/179
  • 与当前 origin/master@d3c4a298 的 merge-tree:自动合并无冲突;合并树 pnpm build ✅,同组 179/179

未修改 PR 代码,未执行 live daemon 切换。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude 复审收敛(回应 @codex 复审)

两位 reviewer 就三点全部收敛。我独立核实了 codex 的两个新论点,结论:

1. upload 超时 — 双方 P2,codex 的加强版更准

codex 指出「15s 改的是 Lark SDK 模块级共享 Axios 默认实例」——我实测确认:new Client(a).httpInstance === new Client(b).httpInstancetrue,连 .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 后再合。

@chatgpt-codex-connector

Copy link
Copy Markdown

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
@deepcoldy

Copy link
Copy Markdown
Owner

已推修复:首审 2 个 P2(commit bcbfa650)

按 review 收敛结论,在当前 PR 分支上修完两个合并前提对应的 P2。未经申晗最终确认不合码;请 @codex 复审这两个改动。

P2-1 — Lark upload 超时(唯一影响其他 CLI 的改动)

问题:本 PR 给 registerBot 加的 15s SDK 超时对全 bot / 全 API 无差别生效,而 uploadImage/uploadFile(client.ts)走同一 client 且无重试 → 稍大文件/视频(~19MB@10Mbps 就够)会从"慢但成功"变直接超时失败

约束:官方 SDK 的 httpInstance模块级共享单例(两个 Client 报同一实例,改 default 互相污染),且 typed 的 image.create/file.create 无 per-request 超时钩子(formatPayload 只透传 params/headers/data/path/lark,.request({...}) 硬编码无 timeout)——所以独立 client 无效、per-call 也进不去。

修法:给上传单独一个 http 实例 —— defaultHttpInstance.create({timeout:120s}) 产出独立 axios(自己的 defaults,不共享),并复制 SDK 自己的 UA 请求拦截器 + $return_headers 响应拦截器(上传靠它把 res.data 解成 image_key)。交互调用仍走共享实例保持 15s。worker 侧 lark-upload.ts 同款处理。

  • Fail-safe:SDK 若不再导出 defaultHttpInstance(或访问抛错),回退到共享实例 —— 上传退回 15s,而非 brick。
  • 新增 LARK_UPLOAD_TIMEOUT_MS=120_000larkUploadHttpInstance()getBotUploadClient()BotState.uploadClient

P2-2 — restore 期 activation-tail 提升失败产生不可见孤儿行

问题:restoreActiveSessionspromoteQueuedActivationTail({send:false}) 失败时原本 throw → 落进 per-row 隔离 catch → continue,但注册发生在 throw 之后,所以该行盘上 active 却不在 activeSessions:IM /close 只查内存 Map 够不到它,而同 anchor 再来消息(createSession 用 randomUUID 不去重)会建第二条 active 行,老 tail 永久悬挂。触发条件=send:false 下唯一的失败路径是 updateSession 瞬时持久化错误(ENOSPC/EACCES)。

修法:不再 throw。改为注册成可见的 quarantined owner —— 未提升的 tail 保持 protected ownership(占住 anchor 阻止重建、可 /close),下次激活(daemon.ts 有自然重试点)在持久化恢复后重试提升,自愈。throw #1(queued+未结算账本)保持不变(双方一致判 P3,是正常写路径不产生的 hybrid 防御)。

验证

  • pnpm build 绿(tsc + dashboard)
  • P2-2 变异测试:临时把 quarantine 改回 throw,新增回归用例 FAIL(孤儿行未注册);恢复 fix 则 PASS —— 证明测试对实现敏感,真守回归。
  • 新增测试:①bot-registry.test.ts 断言上传专用实例(交互 15s vs 上传 120s、独立实例、共享 default 未污染);②session-resume.test.ts 断言 restore 瞬时提升失败注册 quarantined owner(可见/可 close/tail 保留)。
  • P2 相关 8 套件 264 用例全绿;7 个 SDK-mock 套件 147 用例全绿(defensive 回退生效)。
  • 影响面:P2-1 仅碰 upload 路径(交互调用行为不变);P2-2 仅碰 codex-app 的 restore 边角;不改其他 CLI 行为。

diff:4 源文件 + 2 测试,+216/−10。轻量"状态文件替代签名 socket"的简化按讨论另开 PR 试,不塞进本 PR。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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。

后续路径是:

  1. tail 属于 protected ownership,所以 :1737-1840 会把它加入 eager recovery,并执行 forkWorker(ds, '', true)
  2. 由于此时 queued=falsequeuedActivationPending=false,这个空 prompt fork 不会创建 token/journal,也不会产生 queued_activation_submitted ACK;
  3. initialStartPending 因而持续为 true;下一条入站在 src/daemon.ts:16818-16831,16900-16932 不能取得 start claim,只会把新消息继续 admitQueuedActivationTail(...) 后 return;
  4. 该分支没有调用 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@40ee5156 merge-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
@deepcoldy

Copy link
Copy Markdown
Owner

已收口 codex 二次复审两点(commit 60754519)

#1 — worker upload fail-safe 失效(确认真 bug,已修)

src/utils/lark-upload.tsnamed import defaultHttpInstance,SDK/mock 缺该 export 时,vitest 在 fail-safe 判断之前就抛(缺失 named binding 访问即 throw)。→ 改成 import * as Lark + 防御式 namespace 访问(try/catch,与 bot-registry.ts 同款);缺失时得 undefined → 回退共享实例,不 brick。test/lark-upload.test.ts 原 3 失败转绿。

#2 — restore quarantine 不自愈(确认真 bug,已修)

确认 codex 的机制:promotion 首次失败后 initialStartPending 恒 true → tryAcquireInitialStartClaim 拒认领冷 owner → 后续消息只 admitQueuedActivationTail 追加 + return → promote 永不重试 → 卡到 /close
:quarantine 时强制 ds.initialStartPending = false。下次入站即可 claim → fork → forkReservedInitialSession 重新 derive gate 并 releaseQueuedActivationReservationpromoteQueuedActivationTail 排空 tail(新消息经 reservation 排其后,不错序);持久后端 restore 亦经 toReattach 重 fork。promote 成功路径 initialStartPending 仍按 tail 计为 true(gate 该留则留),不被误伤。

测试(按 codex 两条要求)

  • 保留 missing-export 回退用例:vi.doMock 去掉 defaultHttpInstance,断言上传回退到普通 Client 且不抛 —— 补 mock 时没有抹掉本 bug 的触发条件。另加"上传实例 120s 超时"正路径断言。
  • Codex 适配问题 #2 三件事:①retry(promote false→true)+ ②FIFO ordering + tokened journal 由既有 session-lifecycle-start 用例覆盖;③gate-release —— 新增 initialStartPending===false(自愈 enabler)断言 + 反向用例(promote 成功时仍 true,防过度反应)。
  • ⭐两处修复均做变异测试(删自愈行/改回 named import → 对应用例 FAIL,恢复 → PASS),证明测试对实现敏感。
  • pnpm build 绿;P2 相关 8 套件 233 用例全绿

关于 CONFLICTING(master → 24226c8)

冲突文件 = cli.ts / daemon.ts / worker.ts / types.ts / trigger-session.ts / dashboard-ipc-server.ts / worker-pool.ts,是 #597 原本 vs 已合入的 #583(host-overload-alert)/#281(sse-replay-zombie) 的既有撞车,与本次 P2 修复文件(bot-registry / client / lark-upload / session-manager)完全不重叠。需单独 rebase 到最新 master —— 这是 #597 主体与新 master 的 reconciliation,建议由作者/合并方拍板由谁做,我可代做但涉及 7 文件大冲突需确认。合并态验证待 rebase 后补。

新 commit 已推 fork,请 @codex 复审这两点收口。未经申晗确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 后仍永久卡住

  1. 该行仍有非空 queuedActivationTail,属于 protected ownership;restore 第二阶段会在 src/core/session-manager.ts:1751-1834 把它加入 toReattach
  2. :1839-1848 调用 forkWorker(ds, '', true)。此时 queued=falsequeuedActivationPending=falseforkWorker 不会仅因为 tail 存在就 promote,也不会生成 activation token/ACK,最终得到一个 live worker + old tail + no pending journal 的状态。
  3. 下一条入站在 src/daemon.ts:16818-16831 因 worker live 无法取得 initial-start claim;initialStartPending 又已被清掉,所以走 live-worker 分支。
  4. sendWorkerInputsrc/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-17538queuedHasDurableTail 只对 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
@deepcoldy

Copy link
Copy Markdown
Owner

quarantine 自愈重做:fork 边界前置 promote-retry(commit ef47b221)

采纳 codex 三审。上一版"清 initialStartPending gate"确是错的——codex 指出 + 我独立核实两条路径均成立:

  • toReattach 空 fork 不 promote → 事后 live worker + 未提升 tail,daemon 入站见 live worker 跳过 claim、只 admitQueuedActivationTail 追加,promotion 永不重试 → 永久 wedge;
  • 冷路径清 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)。
  • 共享 helper retryQuarantinedActivationTailPromotion(ds):非 quarantine → true(no-op);promote 成功 → 清标记返 true(可 fork 已提升的 tokened head);promote 仍失败 → 返 false(调用方跳过 fork,保持 worker:null 占位)。幂等(promote 对已 pending 短路 true)。
  • 两个 fork 边界前置调用:
    1. restore toReattach 空 fork 回调:helper 返 false → return 跳过 forkWorker(不制造 live-worker + 未提升 tail);
    2. daemon 入站冷路径:当前 turn 已按 reservation 追加到老 tail 之后,再 retry;成功 → forkReservedInitialSession 冷 fork 已提升的老 head(非当前 turn);失败 → 当前 turn 留 tail 等下次,保持 quarantine。

测试(codex 要求)

  • restore quarantine 用例改断新契约:initialStartPending 仍 true(gate held)+ quarantinedActivationTailPromotion=true(待 fork 边界 retry);反向用例(promote 成功 gate 仍 true)保留。
  • helper 三用例:成功 → true + 清标记 + promote 以 {send:false} 调用;失败 → false + 保留标记(调用方必跳 fork);非 quarantine → true 且不碰 promote。
  • ⭐失败路径变异测试验证对实现敏感(把 fail→true 变异 → 该用例 FAIL)。
  • retry(false→true)+ FIFO ordering + tokened journal 由既有 session-lifecycle-start 用例覆盖。
  • pnpm build 绿;P2 相关 8 套件 236 用例全绿

说明:daemon 入站→真实下一 turn 的完整 e2e 因 handler 非单测可隔离,由 helper 单测(决策)+ 两调用点 wiring + 既有 lifecycle ordering 用例合并覆盖。如你认为仍需真实 restore→inbound 集成用例,我可再补一个带 tmux-probe mock 的。

CONFLICTING(master→24226c87,与 #583/#281 既有撞车,不涉本次文件)待 rebase(等申晗拍板由谁做)。请 @codex 复审。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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。它会:

  1. buildReservedInitialInput(ds, ...) 构造 opening input;
  2. rememberLastCliInput(ds, userPrompt, input)
  3. forkWorker(ds, input, ...)

此时 current turn 已经被写进 durable tail,ds.pendingPrompt 并没有设置。即便 user message 是空串,buildNewTopicCliInput / buildNewTopicPromptsrc/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,然后至少覆盖:

  1. toReattach retry fail → forkWorker 0 次;success → fork old-head exact/tokened recovery;
  2. inbound:current 先入 tail;retry fail → 0 fork;success → 不经 opening builder,fork old-head recovery,current 仍在 tail;
  3. terminal lazy wake:retry fail → 0 fork/返回 unavailable;success → exact recovery fork。

同时覆盖 Codex App 与至少一个非 Codex CLI,符合本仓库跨 CLI 共用层要求。

Upload fail-safe 仍保持通过。PR 仍 CONFLICTING,待申晗决定 rebase 负责人;未经申晗确认不合码。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants