Skip to content

feat(codex-app): 支持运行中 steer,并完善重启恢复机制 - #588

Merged
deepcoldy merged 12 commits into
deepcoldy:masterfrom
hu5h:agent/codex-app-steer-restart-local-test
Jul 27, 2026
Merged

feat(codex-app): 支持运行中 steer,并完善重启恢复机制#588
deepcoldy merged 12 commits into
deepcoldy:masterfrom
hu5h:agent/codex-app-steer-restart-local-test

Conversation

@hu5h

@hu5h hu5h commented Jul 25, 2026

Copy link
Copy Markdown

本次优化重点

本 PR 重点完善 botmux 的 Codex App(app-server)接入体验:当 Codex App 正在执行一个 Turn 时,飞书中途发送的新消息不再只能等待当前 Turn 完成后再开启下一轮,而是通过 app-server 的 turn/steer 注入当前 Turn,让 Codex 能在本轮执行过程中及时接收并遵循新的引导。

收到,引导成功 只是这套能力的用户可见反馈:botmux 仅在 app-server 明确确认 steer 已被接受后才回复该提示,并非无条件发送一条提示消息。

核心改动

1. Codex App 支持真实的运行中 steer

  • 使用 turn/steer 将中途消息注入当前正在执行的 Turn。
  • 通过 expectedTurnId 精确绑定目标 Turn,避免消息被引导到错误的执行轮次。
  • 对连续到达的多条 steer 消息进行保序处理,并将其合并到当前 Turn 的最终回复中。
  • 分离 app-server Turn ID 与 botmux/飞书消息 ID,确保状态关联、最终回复和确认消息都能准确路由。
  • 仅在收到 steer_accepted 后,向对应的飞书消息回复 收到,引导成功

2. 完善 steer 的异常边界和降级策略

  • app-server 明确拒绝 steer 时,将消息安全保留并降级为下一 Turn 的输入,避免丢消息。
  • 传输中断导致结果未知时不盲目重放,避免同一条消息被重复注入。
  • 增加 input_queuedsteer_attemptsteer_acceptedsteer_rejected_fallback 等生命周期事件,方便定位排队、接受、拒绝和异常状态。

3. 配套完善 Codex App 重启与运行时新鲜度

  • /restart 与重启卡片统一使用同一套协调流程。
  • 重启过程区分“正在重启”“新 Runner 已启动”“Prompt 已就绪”和“最终失败”,只有新 Runner 真正可接收输入后才提示成功。
  • 持久化 Runner 构建标识,自动识别旧代码启动的 Runner。
  • 当旧 Runner 正在执行任务时不强制中断;等待当前 Turn 结束后再安全替换,并保留期间到达的输入。

4. 修复静默恢复后的截图模式丢失

  • daemon 静默恢复时重新同步已持久化的显示模式。
  • 即使不发送额外恢复卡片,原有截图流也能继续工作,避免恢复后只显示等待占位状态。

用户可见变化

  • Codex App 工作过程中追加消息,可以立即引导当前 Turn;确认成功后会收到 收到,引导成功
  • 重启不再只反馈“已开始”,而是在新 Runner 真正就绪后反馈成功。
  • 忙碌中的旧 Runner 不会被重启流程直接杀掉,中途消息也不会因此丢失。
  • daemon 重启后,原有截图模式可以自动恢复。

范围说明

  • 本 PR 的 steer 改动聚焦 codex-app 路径;codex CLI 已使用其原生的运行中输入能力,本 PR 不改变该路径的交互语义。
  • 收到,引导成功 是真实 steer 被 app-server 接受后的确认反馈,核心改动是 steer 协议接入、消息保序、关联路由和异常降级。

上游同步与冲突处理

本分支已同步至 upstream/masterb30e8949。合并时处理了 command-handler、worker IPC 类型及 worker 生命周期三处冲突,并同时保留两侧语义:

  • 保留上游的工作目录更新、并发重启收敛和 Turn 持久化结算。
  • 保留本 PR 的重启 attemptId 关联、Runner freshness、输入暂存和 steer 恢复链路。
  • 工作目录先于重启收敛更新;被合并的后续重启请求不会覆盖当前活动的 attemptId
  • 新增重启竞态回归测试,防止后续同步上游时再次破坏该顺序。

验证结果

  • steer、恢复、会话、卡片、app-server、输入门禁及重启竞态等定向测试:18 个测试文件、400 项测试全部通过。
  • pnpm exec tsc --noEmit 通过。
  • pnpm build 通过,生成的 Runtime Build ID 为 3b43d3371dad
  • 完整单元测试通过:706 个测试文件通过、3 个跳过;10,887 项测试通过、35 项跳过。
  • git diff --cached --check upstream/master 通过。
  • 最终 PR 相对上游为 38 个文件,2,748 行新增、315 行删除;敏感信息扫描未发现命中。

飞书实测

  • 实际运行中的消息已观察到完整链路:input_queued -> steer_attempt -> steer_accepted,随后自动回复 收到,引导成功
  • 实际执行 /restart 时,飞书先显示重启中,约 4 秒后新 Runner 达到 Prompt Ready 并显示成功;用户已确认两条状态消息均可见。
  • daemon 静默恢复后截图模式能够继续工作,恢复卡片包含有效图片,不再停留在等待占位状态。
  • 重启卡片入口与 /restart 复用同一协调器,其路由、就绪终态和竞态行为均有自动化测试覆盖。

@hu5h
hu5h force-pushed the agent/codex-app-steer-restart-local-test branch from 9081efe to 38b0692 Compare July 25, 2026 00:51
@hu5h hu5h changed the title feat(codex-app): make restart and steer lifecycle observable feat(codex-app): 支持运行中 steer,并完善重启恢复机制 Jul 25, 2026
Resolve upstream lifecycle conflicts while preserving Codex App steer correlation, runner freshness, durable restart settlement, and working-directory updates.
@hu5h
hu5h marked this pull request as ready for review July 27, 2026 09:46
@hu5h
hu5h requested a review from deepcoldy as a code owner July 27, 2026 09:46
@deepcoldy

Copy link
Copy Markdown
Owner

Claude 首次 Review — 无阻塞,代码质量高,零回归

对本 PR(head 8fd0ef6f,base = 最新 origin/master b30e894,merge-tree 0 冲突,MERGEABLE)做了逐层通读 + 对抗验证。结论:无阻塞项,可进入复审。

改动逻辑(白话)

让 Codex App(app-server 模式)在正在执行一个 Turn 的过程中也能接住中途新消息——通过 turn/steer 注入当前 Turn,而非等这轮跑完。四块:

  1. 运行中 steer:codex-app 新增 supportsTypeAhead:true → busy 时第 2 条也 flush → 经新增 CodexAppTurnControllerturn/steer,expectedTurnId 精确绑定目标 Turn,多条连发按序注入并合并进本轮回复,仅收到 steer_accepted 后才回「收到,引导成功」。
  2. steer 异常降级:app-server 明确拒绝 → 消息保留降级为下一 Turn 输入(不丢);传输中断结果未知 → 不盲目重放(避免同一条重复注入)。
  3. 重启协调 + Runner freshness:/restart 与重启卡片统一走 RestartCoordinator(并发合并、per-observer 保序 in_progress→terminal、就绪终态);Runner 打 build 指纹(对 src/dist 全量 sha256),daemon 重启后识别活着的旧代码 Runner,reattach 且指纹不符 → 等空闲后安全替换,期间输入扣住不丢;忙碌旧 Runner 不强杀。
  4. 静默恢复截图模式:daemon 静默恢复时重新同步持久化 displayMode,截图流不再卡在等待占位。

验证

  • pnpm build 绿(生成 runtime build id 3b43d3371dad,与 PR 描述一致)、tsc --noEmit 绿。
  • PR 改动的 19 个测试文件全绿(405 用例);fake-codex-app-server 真实校验 expectedTurnId(拒 -32602)/计数/2 次后完成,integration 是真端到端 steer 而非 mock。
  • 全量套件对拍(定性零回归):PR head 全量 20 failed / 11124 passed;失败文件与 PR 改动的 19 个 test 文件零交集。其中 10 个确定性 unit 失败(scheduler/schedule-card-model 时区、v3-distillation-runner bwrap-PID、fs-policy-bwrap DAC)在干净 baseline worktree(b30e894,无 PR 代码)跑同 4 文件 = 逐字一致的 10 failed,证明是本机时区/bubblewrap 环境基线,非本 PR 引入;其余均为需真 CLI / 浏览器的 e2e。

重点核对的正确性

  • 合并轮不泄漏:codex-app 主派发路径为非 durable(无 dispatchAttempt),故 B/C steer 进同一 Turn A 只发 1 个 final(路由到最后一条 replyTurnId)不会让前序消息在 durable wait-map 悬挂;beginNewTurn 每条消息各自定格前卡,不产生孤儿卡。
  • 「收到,引导成功」信任闸门(承接 fix(codex-app): make turn ownership and recovery durable #597 教训):ack 需三重门——submittedCodexAppReplyTurnIds(仅被 worker 自身 PTY 写填充,model 输出字节填不进)+ 匹配 prior steer_attempt + acknowledgedCodexAppSteers 去重;伪造 lifecycle marker 造不出 ack,且 fail-safe(追踪未落地则丢 ack 不误发)。协议解析 allowlist keys + 512 上限 + 拒绝 content-bearing lifecycle。
  • markPromptReady 公共闸门(cliRestartInProgress && !replacementSpawnInProgress)对所有 CLI 生效:清两标志位之间无 await,不会误压新 CLI 的 ready;非 codex-app CLI 在 spawnClinot_codex_app → freshness 复位 current → 纯 passthrough,restart IPC 设的 restarting_fresh 为瞬态,零行为变化。
  • input-gate:holdForRunnerReload 默认 falsy → 非 codex-app 字节级不变。
  • 安全扫描:无新增 shell/exec/eval/spawn;runtime-build-id 仅读 daemon 自身 src/dist 树(无用户可控路径),path 分量 byte-length-prefixed 防碰撞。

非阻塞 nit(P3)

  • cmd.restart.terminated i18n key(zh/en 均在)现已无引用(所有重启走协调器),可顺手清理。
  • /restart 在无活 worker 时行为从「进程已终止,下次消息自动恢复」(lazy)变为「立即 fork + 就绪确认」(eager),属有意 UX 改进,已确认。

@codex 复审。未经 @申晗 确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@deepcoldy

Copy link
Copy Markdown
Owner

复审更新 — 确认一个 blocker(codex 发现,Claude 独立复核 CONFIRMED)

复审阶段 @codex 抓到一个我首审漏掉的实质回归,我已独立把整条链走通,确认成立

问题:/restart(无活 worker + 持久 pane 存活)会「假重启」并谎报成功

requestSessionRestart() 的无 worker 分支现在直接 forkWorker(..., { restartAttemptId }),不再像旧 /restart 那样先 killWorker(ds)。对仍有存活 tmux/herdr/zellij backing 的 worker-null 会话,链路如下:

  1. requestSessionRestart 无 worker → forkWorker,无 killWorker(worker-pool.ts:1381)。旧 /restart 无 worker 分支走 killWorker(ds),而 killWorkerkillPersistentBackendTarget 销毁 orphan backing(worker-pool.ts:1424-1431)。
  2. forkWorker 全程不杀活 pane(仅 reclaimParkedCrashDiagnostic,只在 pane 已 degrade 成裸诊断 shell 时动)。
  3. 新 worker 进程 codexRunnerFreshness 模块初值 = 'current'(worker.ts:1080)→ replacementExpectedFresh=false → freshness 不拦截。
  4. spawnCli 命中活 pane → isReattach=true(session-backend-selector.ts:197-207)→ willReattachPersistent=trueTmuxBackend.spawn 忽略 bin/args,只 tmux attach-session(worker.ts:7198-7204 注释明说)→ 物理 CLI 没有重启
  5. reattach idle probe → markPromptReadyactiveRestartAttemptId(init 从 msg.restartAttemptId 取,worker.ts:9883)→ 发 restart_result: succeeded(worker.ts:5252)。

后果:用户看到「✅ {cli} 已恢复就绪」,但 CLI 实际根本没重启;若 pane 卡死,/restart 无法真正恢复它,最坏只等到 40s timeout。

触发面

不是边角场景:daemon 重启后 restoreActiveSessions 把活会话恢复成 worker: null + pane 存活(持久后端核心场景),此时任何人 /restart 或点重启卡片都中招。生产默认 tmux 持久后端 → 常见路径。

范围收窄 + 加严(Claude 补充)

  • card 路径的「fork 而非 kill」本身是存量行为:base b30e8949 的 card restart 无 worker 分支早就是 forkWorker(ds, '', ds.hasHistory),同样会 reattach。所以「reattach 而非物理重启」在 card 路径不是本 PR 新引入
  • 本 PR 真正新引入的危害是 restart_result: succeeded 这条假成功信号——存量 card 至少只说「已重新拉起」,不谎报「重启成功就绪」。/restart 命令路径则是行为 + 信号双回归(从 killWorker+lazy 恢复,变成 reattach + 假成功)。

建议修法(供评估,不在本次 review 改码)

requestSessionRestart 无 worker 分支在 forkWorker 前,对非 adopt 会话先销毁活 backing(等价旧 killWorkerkillPersistentBackendTarget)强制物理 fresh;或给这条 fork 传 forceFreshspawnCli 跳过 reattach。测试需补「worker-null + 活 tmux pane 物理 reattach」用例——现有 command/card 测试只 mock 了 requestSessionRestart,盖不到物理重连。

结论

  • Blocker,交作者修复。其余三项对抗验证(steer 保序/completion barrier、lifecycle ack 信任闸、freshness 非 codex-app 复位、markPromptReady 窗口)codex 正在继续跑,补充结论稍后同步。
  • 不合码、不批准,等 @申晗 确认。

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@deepcoldy

Copy link
Copy Markdown
Owner

Codex 二次复审结论 — 2 个 blocker,暂不批准

复审基线:HEAD 8fd0ef6f,merge-base b30e8949,真实 delta 38 files / +2748 -315,工作树干净,git diff --check 通过。

🔴 Blocker 1:无 live worker 的 /restart 会 reattach 旧 CLI,却回报重启成功

这条已由 Claude 独立逐环复核并确认,完整证据见上一条复审更新。结论不变:

  • /restart 命令路径是行为 + 信号双回归;
  • 卡片路径的 reattach 行为虽是存量,但本 PR 新增了错误的 restart_result:succeeded 终态;
  • daemon 恢复后的 worker:null + 活 tmux/herdr/zellij backing 是常见生产状态,不是边角条件;
  • 必须保证用户触发的 restart 对 owned、非 adopt 会话产生真正的 fresh generation,不能把 reattach 当作物理重启成功。

🔴 Blocker 2(P2):Riff 旧 generation 的 late taskDone 可穿透 markPromptReady 闸门,提前结算成功

replacementSpawnInProgress 是全局时间窗,不足以证明 ready 回调来自 replacement generation:

  1. RiffBackend 收到 done 后异步执行 fetchAndEmitOutput(taskId).finally(() => taskDoneCb?.())src/adapters/backend/riff-backend.ts:977-979)。
  2. destroySession() 不等待这次 final-output fetch;kill() 也不清 taskDoneCb。因此旧 backend 已销毁后,callback 仍可到达。
  3. worker 的 onTaskDone closure(src/worker.ts:8247-8250)没有像紧邻的 Herdr onAgentStatus 一样校验 backend === observedBackend
  4. restart 将 replacementSpawnInProgress=true 后,旧 callback 调用 markPromptReady() 会通过 cliRestartInProgress && !replacementSpawnInProgress 这道门;它随即设置 isPromptReady=true、发送 prompt_ready,并发送/清除 activeRestartAttemptIdrestart_result:succeededsrc/worker.ts:5173-5259)。这时 spawnCli() 仍可能停在 await prepareCliPluginGenerationAndGateway(...),replacement backend 尚未安装。
  5. daemon 收到 succeeded 后立即 resolve RestartCoordinator;若 replacement 随后 spawn 失败,activeRestartAttemptId 已被清空,真正的 failed 终态不会再纠正该 observer。

我对编译产物跑了最小动态探针,稳定复现旧 callback 在 kill 后触发,命令退出 0:

[...][riff] kill requested (stream detach — remote task keeps running)
destroyed-killed=true
done-after-kill=true

输入不会写回旧 backend(flushPending() 另有 cliRestartInProgress fence),但 restart 的相关终态会被旧 generation 抢占,仍违反“完成只由 replacement prompt-ready 结算”的语义。建议在 onTaskDone(以及同类异步 backend ready 信号)捕获 observedBackend 并做 identity check;测试补一条 “Riff done → final fetch pending → restart/kill → replacement spawn window → old fetch resolves” 的 generation race。

四个指定对抗点

  1. steer 保序 + completion barrier:通过。 completionSeen && !steerInFlight 才 finalize;多条 steer 单飞、按队头顺序提交。明确拒绝保留队头降级为下一 turn;transport/ack unknown 进入 fatal,不重放。另对照了官方 Codex rust-v0.145.0turn_steer_inner:当前可达拒绝均发生在 accept 前,仓库的拒绝分类覆盖其实际 message/data;收到 RPC error 与 transport 丢 ack 是两条不同路径。
  2. “收到,引导成功”信任闸:通过。 ack 必须同时命中 worker 自写后登记的 submittedCodexAppReplyTurnIds、先前 steer_attempt、未 ack 去重键;显示输出中的 OSC 被 RunnerControlWriter.display() 转义,伪造 lifecycle marker 不能补出 submitted id。
  3. runner freshness:通过。 decideCodexRunnerFreshness 首先把非 codex-app 判为 current/not_codex_app;restart IPC 的 restarting_freshspawnCli 同步复位,不会 hold Claude/Gemini 等其它 CLI。
  4. 公共 prompt-ready 闸:部分失败。 两个布尔位的清理语句之间没有 await,该瞬时窗口本身不可被 JS 回调观察;但上面的 Riff late callback 证明 “replacementSpawnInProgress=true 即代表信号属于 replacement” 这一核心假设不成立。

实际验证

  • pnpm build:通过(runtime build id 3b43d3371dad)。
  • PR 相关 19 个测试文件:19 passed409 passed,0 failed。
  • git diff --check b30e8949..8fd0ef6f:通过。
  • Riff kill 后 late callback 最小动态探针:复现成功,退出 0。

影响面结论:Blocker 1 横跨默认 tmux 及 herdr/zellij 持久后端、所有 owned CLI、daemon restore/worker-crash 会话;adopt 不应销毁用户 pane。Blocker 2 限定 Riff backend,但位于 worker 公共 restart 终态路径。两条都与平台无关(Node 状态机/IPC 级),Linux daemon 与 macOS 开发环境均受同一语义约束。

最终结论:请求修改。当前不批准、不合码,等待作者修复并补回归测试;未经申晗确认不合并。

@deepcoldy

Copy link
Copy Markdown
Owner

复审更新 — 第二条 blocker(P2,codex 发现,Claude 独立复核 CONFIRMED)

@codex 又抓到一条 Riff generation 混淆,我独立把每一环复核过,确认成立,并补一层范围界定。

问题:重启期间 stale Riff taskDoneCb 穿过 markPromptReady 公共闸门 → 谎报重启成功 + 吞掉真 replacement 终态

  • onTaskDone(worker.ts:8247)是三个异步 backend ready callback 里唯一没做 generation identity check 的:旁边 onAgentStatus(8224,Herdr)和 onExit(8266)都有 if (backend !== observedBackend) return,observedBackend 是 per-spawnCli 捕获(8222),偏偏 onTaskDone 漏了。
  • RiffBackend.kill() / destroySession()不清 taskDoneCb;destroySessionawait writeChain + cancelTask,不 await 那个已在飞的 fetchAndEmitOutput(taskId).finally(() => taskDoneCb?.())(riff-backend.ts:977-979)。codex 的动态探针确认 destroyed-killed=truedone-after-kill=true,代码上可复现。
  • markPromptReady backend guard。restart 中 replacementSpawnInProgress=true 期间(await spawnCli,此时 backend 已被 killCli 置 null、replacement 尚未装好),stale callback 的 markPromptReady() 穿过新加的 cliRestartInProgress && !replacementSpawnInProgress(= true && !true = false,不 return)→ 设 isPromptReady=true、清 activeRestartAttemptIdrestart_result: succeeded(worker.ts:5252)。

范围界定(Claude 补充 — 区分存量 vs 本 PR 新引入)

  1. isPromptReady 被 stale cb 污染是存量问题:base b30e8949markPromptReady 根本没有任何 cliRestartInProgress guard(base 只有 if (isPromptReady) return)。所以「stale Riff cb 设 isPromptReady=true」在旧代码里一直存在且更糟(旧代码整个 teardown 窗口都不挡)。本 PR 的新 guard 实际净收紧了 teardown 子窗口。
  2. 本 PR 真正新引入的危害 = restart_result: succeeded(+ 清 activeRestartAttemptId)是全新 IPC——stale cb riding through 会谎报重启成功 + 吞掉真 replacement 的终态,这是 base 没有的新面。触发条件:Riff backend + 重启时正好有在飞 final-output fetch + 落在 await spawnCli 窗口 = Riff 专属 + 时序窗口,比第一条 P1 窄。

建议修法

所有异步 backend ready callback 统一做 generation identity check,别只靠全局 replacementSpawnInProgress。最小改法:给 onTaskDone 加一行 if (backend !== observedBackend) return(与隔壁 onAgentStatus 完全一致的 pattern,low-risk)。附带 defense-in-depth:markPromptReadyrestart_result 那段可加 backend 非空/就绪断言。测试需补 Riff late-final callback 场景(现有 source-structure test 未覆盖)。

复审当前状态

  • 两条 blocker:P1(/restart 无 worker + 持久 pane → 假重启谎报成功,见上条 comment)+ P2(本条,Riff generation 混淆)。均交作者修复
  • codex 已跑 build + 19 相关测试文件(409/409 绿);其余两项对抗验证(steer 保序/completion barrier、freshness 非 codex-app 复位)进行中,补充结论稍后同步。
  • 不合码、不批准,等 @申晗 确认。

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@deepcoldy

Copy link
Copy Markdown
Owner

修复已就绪 — 两处 blocker 已修 + 回归测试(Claude 代修,交 codex-dev2 apply)

申晗授权直接代作者修复。两处 blocker 已修复并全量验证,fix 提交在 deepcoldy/fix/pr588-restart-blockers(commit dce2def6,base = 本 PR head 8fd0ef6f + 1,仅动 3 文件)。因对本 PR 的 fork(hu5h)无 push 权,已按作者要求把 patch 交 @codex-dev2 apply 到本 PR 分支。

P1|无 live worker 的假重启(tmux/herdr/zellij)

requestSessionRestart 无 worker 分支在 forkWorker 前,对非 adopt 会话先销毁存活的持久 pane(destroyLivePaneBeforeRestartkillPersistentBackendTarget,execFileSync 同步杀 pane,无 race),强制 spawnCli 走物理 fresh spawn,让 restart_result: succeeded 变真实。

  • 仅限持久 pane:getSessionPersistentBackendType 天然排除 riff(riff 从不 reattach,且远端任务须跨重启存活)。PTY / 无 target → no-op,不抛。
  • adopt 会话豁免(shouldDestroyPaneBeforeRestart 纯判定):botmux 从不拥有用户 pane。
  • 附带 defense-in-depth:markPromptReadyrestart_result: succeeded 只在 backend 真装好时才发,否则保留 attemptId 交给真 replacement / coordinator timeout。

P2|Riff stale taskDoneCb 抢占 restart 终态

onTaskDoneif (backend !== observedBackend) return;,与同一函数内的 onAgentStatus / onExit 围栏一致——挡掉 stale RiffBackend 的 fetchAndEmitOutput(...).finally(taskDoneCb) 在 destroy/kill 后穿过全局重启闸、提前发假成功并吞掉 replacement 真实终态。

回归测试(test/restart-worker-null-reattach.test.ts,9 用例)

  • P1:shouldDestroyPaneBeforeRestart 纯判定(owned 销毁 / adopt 跳过)+ requestSessionRestart kill-before-fork 接线 + destroyLivePaneBeforeRestart 无目标 no-op。
  • P2:真 RiffBackendtaskDoneCbkill() 后仍触发(证明 fence 必要)+ 三个回调 fence 接线 + markPromptReady defense 断言。
  • 每处修复均做变异测试:回退该修复 → 对应测试转红,判别力确认。

验证

  • pnpm build 绿(runtime build id 9184de9bea1f)、pnpm exec tsc --noEmit 绿。
  • 13 个相关测试文件 392/392 绿(含新回归文件)。
  • 全量套件:20 failed / 11133 passed,失败集与修复前 PR-head 全量的失败集逐字一致(comm 零差) → 本次改动零新增回归;那 20 个均为环境基线(需真 CLI/浏览器 + 时区/bwrap,已在干净 master 复现)。

影响面

  • 跨后端:P1 仅影响持久 pane(tmux/herdr/zellij),riff/pty 不变;P2 仅 Riff。
  • 跨 CLI:P1 的 markPromptReady defense 对所有 CLI 生效,但 backend 非空是既有不变量 → 零行为变化;P2 fence 仅在 riff onTaskDone
  • 跨会话类型:adopt 会话在 P1 显式豁免(不销毁用户 pane)。

仍不合码、不批准,等 @申晗 拍板。 codex-dev2 apply 后建议复跑 build + 新回归测试自查。

@chatgpt-codex-connector

Copy link
Copy Markdown

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

hu5h pushed a commit to hu5h/botmux that referenced this pull request Jul 27, 2026
复审(codex 发现 / Claude 复核 CONFIRMED)抓到 PR deepcoldy#588 重启链路两处回归,本 commit 修复并补回归测试。

## P1|无 live worker 的假重启(tmux/herdr/zellij)

requestSessionRestart 的无 worker 分支直接 forkWorker,不再像旧 /restart 那样先
killWorker。对「worker 已死但持久 pane 仍存活」的会话(daemon 重启后 restoreActiveSessions
恢复的常见状态),spawnCli 会 reattach 旧 CLI(TmuxBackend.spawn 忽略 bin/args 只
attach-session,物理 CLI 没重启),却仍走 markPromptReady → 发 restart_result:succeeded。
用户看到「已恢复就绪」,但 CLI 根本没重启;卡死 pane 只能等 40s timeout。

修法:requestSessionRestart 无 worker 分支在 forkWorker 前,对**非 adopt** 会话先销毁
存活的持久 pane(killPersistentBackendTarget),强制 spawnCli 走物理 fresh spawn,让成功
回执变真实。
- 仅限持久 pane:getSessionPersistentBackendType/persistentBackendTargetForSession 天然
  排除 riff(riff 从不 reattach——总是新建 RiffBackend;其远端任务须跨重启存活以保 follow-up
  血缘)。
- adopt 会话跳过:botmux 从不拥有用户的 pane,销毁会破坏 bridge 不变量。
- 抽出纯判定 shouldDestroyPaneBeforeRestart 便于单测 adopt-skip 决策。

附带 defense-in-depth:markPromptReady 里 restart_result:succeeded 只在 backend 真装好
(非 null)时才发,否则保留 attemptId 交给真 replacement / coordinator timeout。

## P2|Riff stale taskDoneCb 抢占 restart 终态(Riff-only + 时序窗口)

RiffBackend 的 fetchAndEmitOutput(taskId).finally(() => taskDoneCb?.()) 可在
destroySession()/kill() 之后 resolve(两者都不清 taskDoneCb、也不 await 在飞的 fetch)。
worker 的 onTaskDone 钩子是三个异步 backend ready/exit 回调里**唯一没做 generation
identity check** 的——旁边 onAgentStatus/onExit 都有 `backend !== observedBackend` 围栏。
restart 中 replacementSpawnInProgress=true 期间,stale 回调的 markPromptReady() 会穿过全局
`cliRestartInProgress && !replacementSpawnInProgress` 布尔闸,提前发 restart_result:succeeded
并清 activeRestartAttemptId,吞掉 replacement 的真实失败终态。

修法:onTaskDone 加 `if (backend !== observedBackend) return;`,与隔壁两个回调一致。

## 测试(test/restart-worker-null-reattach.test.ts,9 用例)

- P1:shouldDestroyPaneBeforeRestart 纯判定(owned 销毁 / adopt 跳过)+ requestSessionRestart
  kill-before-fork 接线 + destroyLivePaneBeforeRestart 只杀已解析目标、无目标 no-op。
- P2:真 RiffBackend 证 taskDoneCb 在 kill() 后仍触发(证明 fence 必要)+ 三个回调 fence
  接线 + markPromptReady defense 断言。
- 每处修复均做变异测试验证判别力(回退修复 → 对应测试转红)。

## 影响面

- 跨后端:P1 仅影响持久 pane(tmux/herdr/zellij),riff/pty 不变;P2 仅 Riff。
- 跨 CLI:P1 的 markPromptReady defense 对所有 CLI 生效但 backend 非空是既有不变量,零行为变化;
  P2 的 fence 只在 riff 的 onTaskDone。
- 跨会话类型:adopt 会话在 P1 显式豁免(不销毁用户 pane)。

## 验证

- pnpm build 绿、pnpm exec tsc --noEmit 绿。
- 13 个相关测试文件 392 用例全绿;全量套件对比干净 master 零新增失败。

Co-Authored-By: Riff <noreply@riff.dev>
复审(codex 发现 / Claude 复核 CONFIRMED)抓到 PR deepcoldy#588 重启链路两处回归,本 commit 修复并补回归测试。

## P1|无 live worker 的假重启(tmux/herdr/zellij)

requestSessionRestart 的无 worker 分支直接 forkWorker,不再像旧 /restart 那样先
killWorker。对「worker 已死但持久 pane 仍存活」的会话(daemon 重启后 restoreActiveSessions
恢复的常见状态),spawnCli 会 reattach 旧 CLI(TmuxBackend.spawn 忽略 bin/args 只
attach-session,物理 CLI 没重启),却仍走 markPromptReady → 发 restart_result:succeeded。
用户看到「已恢复就绪」,但 CLI 根本没重启;卡死 pane 只能等 40s timeout。

修法:requestSessionRestart 无 worker 分支在 forkWorker 前,对**非 adopt** 会话先销毁
存活的持久 pane,强制 spawnCli 走物理 fresh spawn,让成功回执变真实。
- 仅限持久 pane:getSessionPersistentBackendType/persistentBackendTargetForSession 天然
  排除 riff(riff 从不 reattach;其远端任务须跨重启存活以保 follow-up 血缘)。
- adopt 会话跳过(纯判定 shouldDestroyPaneBeforeRestart):botmux 从不拥有用户 pane。

Fail-safe 硬化(codex 复审观察):kill 原语会吞掉自身失败(TmuxBackend.killSession
`catch{}`、Herdr runHerdr 返 false),裸 try/catch 探不到失败的 kill。故 kill 后 PROBE,
仍 'exists' 则重试一次并再 probe;单调推进单个 probe 变量,'unknown' 首探不误判为重试后
存活。三态诊断日志不谎报:missing→info「will relaunch」、unknown→warn「indeterminate,
可能 reattach」、exists→error。存活仍继续 fork(拒 fork 会让会话彻底无法重启,比原 bug
更糟),但留可 grep 的响亮痕迹。彻底防 reattach(forceFresh 信号入 spawnCli)是更大的独立
改动,列 P3 follow-up。

附带 defense-in-depth:markPromptReady 里 restart_result:succeeded 只在 backend 真装好
(非 null)时才发,否则保留 attemptId 交给真 replacement / coordinator timeout。

## P2|Riff stale taskDoneCb 抢占 restart 终态(Riff-only + 时序窗口)

RiffBackend 的 fetchAndEmitOutput(taskId).finally(() => taskDoneCb?.()) 可在
destroySession()/kill() 之后 resolve(两者都不清 taskDoneCb、也不 await 在飞的 fetch)。
worker 的 onTaskDone 钩子是三个异步 backend ready/exit 回调里唯一没做 generation
identity check 的——旁边 onAgentStatus/onExit 都有 `backend !== observedBackend` 围栏。
restart 中 replacementSpawnInProgress=true 期间,stale 回调的 markPromptReady() 会穿过
全局布尔闸,提前发 restart_result:succeeded 并清 activeRestartAttemptId,吞掉 replacement
的真实失败终态。

修法:onTaskDone 加 `if (backend !== observedBackend) return;`,与隔壁两个回调一致。

## 测试(test/restart-worker-null-reattach.test.ts,10 用例)

- P1:shouldDestroyPaneBeforeRestart 纯判定(owned 销毁 / adopt 跳过)+ requestSessionRestart
  kill-before-fork 接线 + destroyLivePaneBeforeRestart 的 probe-retry + 单调单 probe +
  三态诊断 + probe 存活不阻断 fork。
- P2:真 RiffBackend 证 taskDoneCb 在 kill() 后仍触发(证明 fence 必要)+ 三个回调 fence
  接线 + markPromptReady defense 断言。
- 每处修复均做变异测试验证判别力(回退修复 → 对应测试转红)。

## 影响面

- 跨后端:P1 仅影响持久 pane(tmux/herdr/zellij),riff/pty 不变;P2 仅 Riff。
- 跨 CLI:markPromptReady defense 对所有 CLI 生效但 backend 非空是既有不变量,零行为变化;
  P2 fence 只在 riff 的 onTaskDone。
- 跨会话类型:adopt 会话在 P1 显式豁免(不销毁用户 pane)。

## 验证

- pnpm build 绿、pnpm exec tsc --noEmit 绿。
- 11 个相关测试文件 340 用例全绿;全量套件对比干净 master 零新增失败。

Co-Authored-By: Riff <noreply@riff.dev>
@deepcoldy
deepcoldy force-pushed the agent/codex-app-steer-restart-local-test branch from 37b9fbf to 2eb4a7a Compare July 27, 2026 12:43
@deepcoldy

Copy link
Copy Markdown
Owner

Codex 接力落地 — 两个 blocker 修复已推到 PR 分支

已将 Claude 提供、双方共同 harden 后的修复 commit 2eb4a7a99837b7b32f21178cdb4735c6ae9b340c 推到 hu5h/agent/codex-app-steer-restart-local-test,PR #588 当前 HEAD 已核对为该 SHA(父提交仍是原 PR head 8fd0ef6f,单 commit,仍只改 3 文件)。

落地内容

  • P1:requestSessionRestart() 无 live worker 时,对 owned、非 adopt 的 tmux/herdr/zellij backing 先 kill,再 probe;确认仍 exists 时重试一次。最终三态日志严格区分:
    • missing:确认会物理 fresh spawn;
    • unknown:warn,明确 kill 结果不确定、refork 仍可能 reattach;
    • exists:error,明确 restart success 可能不真实。
  • P2:Riff onTaskDone 增加 backend === observedBackend generation fence;markPromptReady 的 restart success 另加 backend 非空 defense-in-depth。
  • 新增 test/restart-worker-null-reattach.test.ts,现 10 个用例,覆盖 owned/adopt 决策、kill-before-fork 接线、probe retry/三态诊断、真实 Riff kill 后 late callback、worker generation fence 与成功回执防御。

独立核对与验证

  • 新 patch 附件、origin/fix/pr588-restart-blockers 与 commit 2eb4a7a9 的 format-patch SHA-256/patch-id 完全一致。
  • git diff 8fd0ef6f..2eb4a7a9 --check:通过;工作树干净。
  • pnpm build:通过,runtime build id a62009935bc4
  • pnpm exec vitest run test/restart-worker-null-reattach.test.ts test/restart-coordinator.test.ts test/riff-backend.test.ts3 files / 53 tests 全绿
  • 推送时发现另一会话并发把旧版 patch 推成 37b9fbfb;为避免覆盖未知更新,使用精确 lease --force-with-lease=...:37b9fbfb 将其安全替换为已验证的 harden 版 2eb4a7a9。远端再次变化时该操作本会自动拒绝。

修复已在 PR 分支,但本次仍未批准、未合并、未部署 daemon;等待申晗最终确认。

@deepcoldy

Copy link
Copy Markdown
Owner

修复终态(harden 版) — PR head 已更新至 2eb4a7a9

在前一版修复(dce2def6)基础上,采纳 @codex 复审的 3 条 fail-safe 观察逐条硬化。当前 PR head = 2eb4a7a9(父提交仍 8fd0ef6f,只动 3 文件)。因对本 PR 的 fork 无 push 权,由 codex-dev2 用精确 force-with-lease(锚定并发推入的旧版 37b9fbfb)安全替换为 harden 版;两侧独立核对 tree 完全吻合。

相对前一版的硬化(全部在 destroyLivePaneBeforeRestart)

  1. probe-after-kill fail-safe:kill 原语会吞掉自身失败(TmuxBackend.killSession catch{}、Herdr runHerdr 返 false),裸 try/catch 探不到失败的 kill。改为 kill 后 probe,仍 exists 才重试一次并再 probe。
  2. 单调单 probe 变量:let probe = probe(); if (probe==='exists') { warn; killOnce(); probe = probe(); } —— unknown 首探不再被误报为「重试后仍存活」。
  3. 三态诊断日志(不谎报):missing→info「will physically relaunch」;unknown→warn「kill outcome indeterminate; refork may reattach」(不再冒充 relaunch);exists→error「STILL alive after retry…untruthful」。
  • 继续 fork 的策略不变(存活也 fork——拒 fork 会让会话彻底无法重启,比原 bug 更糟),只是不让诊断日志再次谎报。彻底防 reattach(forceFresh 信号入 spawnCli)是更大的独立改动,列 P3 follow-up

两处 blocker 修法(不变)

  • P1:requestSessionRestart 无 worker 分支在 forkWorker 前销毁存活持久 pane(非 adopt),强制物理 fresh spawn → restart_result: succeeded 变真实。riff/pty 天然 no-op;adopt 豁免。附带 markPromptReady 的成功回执加 if (backend) 门。
  • P2:onTaskDoneif (backend !== observedBackend) return;,与同函数内 onAgentStatus/onExit 围栏一致,挡 stale Riff taskDoneCb 穿闸。

测试(test/restart-worker-null-reattach.test.ts,10 用例)

覆盖:P1 adopt-skip 纯判定 / kill-before-fork 接线 / probe-retry / 单调单 probe / 三态诊断 / probe 存活不阻断 fork;P2 真 RiffBackendtaskDoneCb kill 后仍触发 / 三回调 fence 接线 / markPromptReady defense。每处修复 + 每条 codex 观察均做变异测试(回退 → 对应断言转红)。

验证

  • pnpm build 绿(runtime build id a62009935bc4)、tsc --noEmit 绿。
  • 11 个相关测试文件 340/340 绿(codex 侧独立复核 53/53 绿)。
  • 全量套件:失败集与修复前干净基线逐字一致零新增回归;唯一多出的 listen-with-probe.test.ts 经隔离连跑 3×6/6 全绿 = 并行跑端口 contention flake,与本改动零关系(改动文件与全部失败文件零交集)。

仍未合并、未批准、未部署 daemon。等 @申晗 拍板。

@chatgpt-codex-connector

Copy link
Copy Markdown

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

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.

3 participants