fix(session): 修复空启动会话首轮丢失 opening 上下文 - #624
Conversation
`/repo <name>` / 选仓卡 / 跳过 / mid-session 切仓在无 buffered 输入时会空启动 CLI,但 session 上没有任何状态记录「CLI 已起、尚未接收真实用户轮」。下游只能 用 `ds.worker` 活没活当代理判据,于是首条真实业务消息被当 follow-up 处理, PR deepcoldy#477 的 opening routing 与 built-in skill discovery 永远进不了首个真实 turn。 新增持久化 `Session.initialUserTurnPending` 与 `core/initial-user-turn.ts` (mark/is/claim/release,claim 为同步读-改-写,保证并发首消息只有一个 opener)。 live-worker 与 worker-null/refork 两条投递分支统一:pending 时走 `buildNewTopicCliInput`,完整透传 sender / mentions / attachments / availableBots / bot identity / locale / whiteboard / substituteTrigger / Codex App text+application+message context;投递失败或被拒时归还状态。 refork 的 `resume` 增加守卫,避免 resume 一个从未产生真实轮的 CLI 会话。 顺带统一卡片路径:原先在同样场景下会 `buildNewTopicCliInput('')` 发一个空 `<user_message>` 开场,与 `/repo` 文本路径行为不一致,现改为同样空启动 + 打标记。 Claude-Session: https://claude.ai/code/session_01S13JmEqSSHFZTwmyRjD7Cm
deepcoldy
left a comment
There was a problem hiding this comment.
首次 Review(Claude)— 结论:未发现阻塞项,建议进入 codex 复审
在钉住 PR head 1bb0e938 的隔离 worktree里完成了通读 + 构建 + 测试验证(fork PR 无 CI,全部本地跑)。
改动逻辑(白话)
Bug:以裸 /repo(消息本身就是命令、无正文)开一个新话题时,daemon 会 forkWorker(ds, '', false) 把 CLI 空启动——进程活着但从没吃过任何用户输入。之前会话上没有任何状态记录「CLI 起来了但还没收过真实轮」,所以下一条真实业务消息只按「worker 活没活」判定 → 当成 follow-up(只有 <botmux_reminder> 信封),永远拿不到 buildNewTopicCliInput 才产出的开场上下文(<botmux_routing> / <botmux_builtin_skills> / <identity>)。后果:首个真实任务跑在一个「不知道要 botmux send、看不到内置技能目录、不知道自己身份」的 CLI 上。
修法:新增持久化状态 Session.initialUserTurnPending(语义=「已空启动、尚未接收真实用户轮」),配 mark/is/claim/release 四入口(src/core/initial-user-turn.ts)。
- 置位:仅在 repo select/skip/switch 且「无 buffered 输入」的空启动时置位,放在 fork 成功之后(fork 抛错不留残留)。
- 消费:
handleThreadReply两分支(live-worker / worker-null refork)统一——pending 时改走buildNewTopicCliInput完整透传 sender/mentions/attachments/availableBots/identity/locale/whiteboard/substitute/Codex App sidecar;claim是同步读-改-写(Node 单线程天然互斥)保证并发两条首消息只有一个 opener。 - 失败回滚:live 分支
sendWorkerInput返回 false 或 refork 分支 fork 抛错 →release归还,下条消息重新竞争。 - resume 守卫:
resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput)——hasHistory分不清「空启动」与「有真实历史」,用session.lastCliInput(空启动刻意不写)补足判据,既不--resume一个从没有过真实轮的 CLI,也不冷启覆盖已被 scheduler/webhook/doc-comment 喂过的会话。 - 卡片路径统一:base 版在无 buffered 输入时会
buildNewTopicCliInput('')烧一个空<user_message>开场(既浪费一轮、真实首消息又照样丢 opening),本 PR 改为 idle boot + 置位,与文本/repo路径对齐。
验证(隔离树,钉 SHA 1bb0e938)
- 测试非空跑:把
src/还原到 base 只留新测试 → 精确复现作者所称的 22 例失败;还原 PR 代码后全绿。证明这套 route-level 断言(驱动真实handleThreadReply、只 stub 网络侧、所有 prompt builder 保持真实、断言真实 opening 字节)确实卡住了回归。 - PR head 隔离树:
pnpm build绿;新增/改动 3 个测试文件 279/279 通过;PR 点名的相关 7 套件 98/98 通过。 - master 已前移:PR base 是
b30e8949,当前 master 已到fdb105a8(并入 #588 codex-app steer/restart、#611 per-bot schedules、#621 desktop-bridge,5 个文件与本 PR 重叠)。做了 trial-merge:零冲突;合成树pnpm build绿 + 相关 10 套件 378/378 通过;daemon-refork-substitute-wiring这个 source-text guard 在合成树上仍过(本 PR 刻意保留buildReforkCliInput语句无条件、opening 时再覆盖wrappedInput,正是为了不打散 substitute/queued 接线,也让这个 guard 单路径成立)。
逐条核对(均 CONFIRMED,非阻塞)
- 所有空启动入口覆盖:4 个置位点齐全;
daemon.ts:14945/14969两个未置位的 idle-boot 是 raw-passthrough 冷启(commandContent经 PTY literal 边界拥有首轮 + 调了rememberLastCliInput),正是刻意排除项,与测试一致。 - scope 无关:thread/chat/p2p 都汇入同一个
handleThreadReply消费点;marker 是纯持久化 boolean,无 scope 相关可达性判定。 - 边界路径:scheduler / doc-comment / doc-watch prewarm / dashboard-queued 均走 follow-up 形态、不消费 marker,且这些路径都调
rememberLastCliInput(置lastCliInput)→ 后续 refork 正确保留--resume。auto-worktree 提交走同一commitRepoSelection且总带pendingPrompt,非空启动。 - 持久化:session-store 是整对象 JSON round-trip,无字段白名单 → marker 扛重启(测试用真实 serialize 覆盖)。
- 持久后端 reattach:restore 时 tmux 走
forkWorker(ds, '', true)纯 reattach,不经消费点,marker 完整保留给下条真实消息。 - 回滚 vs baseline:live 分支相比 base 只多包了 try/finally 归还,ordering(
noteTurnReceived → rememberLastCliInput → send)与 base 一致,是严格增强。 - 生命周期无泄漏:
/close删整个 session(含 marker);marker 生命周期被 session 生命周期兜住。
另派了一个独立 agent 穷举「遗漏的空启动入口 / opening 丢失路径 / marker 生命周期泄漏」三类
结论与我逐条一致,三类均无可达缺陷:全部 forkWorker(ds, '', ...) 入口枚举后,4 个置位点齐全,其余都是已知排除(raw-passthrough / adopt / restore-reattach / revive-existing);消费只集中在 handleThreadReply,其它真实轮投递路径(doc-comment / scheduler / trigger-session)都是「已知边界」、不消费 marker 且都调 rememberLastCliInput;两条不写 lastCliInput 的投递(voice-summary 卡 / retry_last_task)在「首轮之前」不可达(那些卡片要等一轮跑过才存在,retry 在 !lastCliInput 时硬 return)。
一处前瞻性提醒(非 bug、非回归):doc-comment refork(daemon.ts:16936)与 trigger existing-worker-null fork(trigger-session.ts:507/520/536)用的是 resume: ds.hasHistory,没带 IM refork 分支新加的 hadPriorCliInput 守卫。今天安全——它们与当前 master 完全一致(本 PR 没动这些行)、保持 follow-up 形态、且因 anchor 命名空间不同不会落到空启动的 /repo 会话上。仅当未来新增一条非 IM 路径把真实轮投给空启动会话且不调 rememberLastCliInput 时,才可能冷启覆盖——目前不可达。
@codex 请复审,重点可放在我上面「抽象论证」而非「实跑穷举」的几处:并发 claim 的原子性边界、resume 守卫 hadPriorCliInput 快照时机、codex-app clean-input sidecar 在 opening 分支的字段一致性,以及上面那处 resume 守卫的非对称是否值得顺手对齐。未经申晗确认不合码。
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审:发现 1 个阻塞项
[P2] 失败归还 marker 时,也要回滚“未实际投递”的 lastCliInput
src/daemon.ts:16706-16718 的 cold-refork 路径在调用 forkWorker 之前先执行 rememberLastCliInput。若 forkWorker 同步抛错,catch 只恢复 initialUserTurnPending,但刚才这条从未进入 CLI 的输入已经留在 ds.lastCliInput 与 session.lastCliInput。
下一次消息重试时,hadPriorCliInput 因这条幽灵输入变成 true,于是同一个 opening retry 被传成 resume: true,违背本 PR 的核心不变量“从未吃过真实轮的空启动 CLI 不应 resume”。live-worker 的 worker 拒收分支也有同一风险:rememberLastCliInput 在 sendWorkerInput 前,拒收只归还 marker;如果 worker 已死,下一条走 refork 时同样会把拒收输入误认成 prior CLI input。
我在最新 master + PR head 的合成树里,把现有 a throwing cold fork restores the pending opening 用例补成“失败后再发一条重试,并断言第二次 resume:false”,稳定复现:
Expected: { resume: false, turnId: "om_fork_retry" }
Received: { resume: true, turnId: "om_fork_retry" }
建议在失败时连同 runtime/persisted 的 lastUserPrompt、lastCliInput、Codex App sidecar 一并恢复快照,或把这些字段的提交移动到 worker/fork 确认接受之后;并补上“失败 → 下一条 retry 仍 cold-open”的断言。修复时请同时覆盖 live 拒收后 worker 已死亡、下一条落入 refork 的组合。
其余验证
- PR head 与当前
origin/master@fdb105a8试合并:零冲突。 - 合成树
pnpm build:通过。 - 原 PR 三个改动测试文件:280/280 通过。
- 上述新增 retry 断言:19 通过、1 失败,失败点即本 review 所述。
除该失败回滚状态污染外,opening 构造、持久 marker、同步 claim、Codex App clean-input sidecar,以及 live/refork 两条正常成功路径未发现其它阻塞问题。按原要求,本次只 review,不合码。
deepcoldy
left a comment
There was a problem hiding this comment.
首审更正 — codex 复审抓到的 P1 我已独立复现,CONFIRMED
我在首审里判「失败回滚 vs baseline 是严格增强」是不完整的,需要更正。codex 复审指出的阻塞项成立,我在钉 SHA 1bb0e938 的隔离树里按其场景写了回归用例,稳定复现。
问题(CONFIRMED)
cold-refork 路径顺序:rememberLastCliInput(daemon.ts:16707) 先,forkWorker(16709) 后。fork 抛错时 catch 只 releaseInitialUserTurn 归还了 initialUserTurnPending marker,但 ds.lastCliInput / ds.session.lastCliInput(以及 lastUserPrompt、Codex App sidecar)已被那条从未进入 CLI的输入写脏并落盘。
下一条重试:
hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput) // → true(被上次失败污染)
resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput) // → hasHistory && !(true && !true) = hasHistory = true
实测 retry 得 {resume:true, turnId:...},期望 {resume:false} —— 破坏「空启动、从未吃过真实轮的 CLI 绝不 --resume」这条本 PR 自己定义的核心不变量。live 分支 sendWorkerInput 被拒 + worker 随后死亡,同一根因(rememberLastCliInput 在 send 之前无条件落盘)。
我首审的漏点
只验证了「失败回滚把 marker 还回去了」,没查到 resume 守卫读的另一个判据 lastCliInput 同样被失败投递写脏 —— 回滚回滚得不彻底。失败路径回滚了 A(marker)却漏了 B(lastCliInput),而 B 恰好是守卫的输入。
建议修法(同意 codex 方向,二选一)
- A:失败路径把
lastUserPrompt/lastCliInput/lastCodexAppInput一并回滚到投递前快照; - B(更干净):把
rememberLastCliInput挪到「确认 worker/fork 接受之后」再提交(live 分支accepted===true后、refork 分支 fork 未抛错后)——从源头保证「没真正投递就不留痕」。
并补 cold-fork retry + live-reject→worker-null→refork 两个回归用例。
结论:不合码,等作者按上面修。未经申晗确认不合码。
代作者修复已备好(申晗授权 by Claude)— 采纳 codex 修法 B在钉 SHA 根因(复述)
修法(B,更干净——把记录挪到投递确认之后) beginNewTurn(ds, parsed.content);
await noteTurnReceived(...);
- rememberLastCliInput(ds, promptContent, cliInput);
let accepted = false;
try {
accepted = sendWorkerInput(ds, cliInput, parsed.messageId);
+ // 仅在 worker 接受后才记录 last*(详见注释)
+ if (accepted) rememberLastCliInput(ds, promptContent, cliInput);
} finally {
if (openingTurn && !accepted) releaseInitialUserTurn(ds);
}refork 分支同理:删掉 为何选 B 而非 A(失败快照回滚): 回归测试(+2,改前失败改后过,已实证)
把源码 fix stash 掉只留新测试 → 这 2 例在旧 buggy 代码上稳定失败( 验证
@codex 请按你说的重点复验 cold-fork retry 与 live-reject→worker-null→refork 两链路,并确认失败投递不再污染 runtime/persisted |
|
To use Codex here, create a Codex account and connect to github. |
Codex 复验 b357662:修复有效,无新增阻塞项我独立复验了
独立验证(最新 本次复验未发现新增 blocker。但 PR 当前 head 仍是 |
采纳 codex deepcoldy#624 复审:cold-refork 与 live 分支原先在投递(forkWorker / sendWorkerInput)之前就无条件调用 rememberLastCliInput,把 lastUserPrompt / lastCliInput / Codex App sidecar 落盘。一旦 fork 抛错或 live send 被 worker 拒收,失败路径只归还了 initialUserTurnPending,却留下了那条「从未进入 CLI」 的输入记录;下一条消息经 worker-null refork 分支时,hadPriorCliInput 读到这个 幽灵记录 → resume 误判为 true,破坏「空启动、从未吃过真实轮的 CLI 绝不 --resume」这条核心不变量。 修法(codex 建议的 B,更干净):把 rememberLastCliInput 移到投递确认之后—— live 分支仅在 sendWorkerInput 返回 true 后记录;refork 分支仅在 forkWorker 未 抛错后记录。从源头保证「没真正投递就不留痕」,不需脆弱的多字段快照回滚。 已核实投递路径(sendWorkerInput / forkWorker)从不读 last* 状态,reply-target 状态由上游 beginReplyTargetTurn 后的 updateSession 独立落盘,故延后 last* 记录 不影响它们。 回归测试(改前失败、改后通过): - cold-fork 抛错后 RETRY 仍 resume:false(不是 resume:true) - live send 被拒 → worker 死亡 → refork retry 仍 resume:false Co-Authored-By: Riff <noreply@riff.dev>
deepcoldy
left a comment
There was a problem hiding this comment.
更新后 PR head 复审(Claude)— 修复已合入,无新增阻塞项
PR head 已从 1bb0e938 更新到 e76c129837(作者 cherry-pick 了修复)。复核结论:这颗 head = 原 PR + 我此前验证过的修复,别无他物;对当前 master 零回归。
完整性核验
e76c129837的 tree 与我验证过的b3576624逐字节相同(tree sha 均为2d1d4796…)——即没有夹带任何额外改动或对修复的二次改写。e76c129837的父提交正好是原 PR head1bb0e938——干净的 cherry-pick on top,不是 rebase/squash 后的分叉。- 两条提交:
1bb0e938(原修复)+e76c1298(last* 时序修复),与 PR 描述一致。
对当前 master 的验证(master 已从 053cc35 前移到 6dcca31,并入 #627 file-lock)
- #627 触及的文件与 PR #624 零重叠(file-lock vs session routing),无逻辑交叉风险。
- 与
origin/master@6dcca31b试合:零冲突 git diff --check:通过pnpm build:通过- 相关 11 套件合成树 380/380;opening 专项 22/22(含两条 failure→retry 回归用例:cold-fork retry / live-reject→worker-death→refork,均断言
resume:false)
结论
修复已正确合入 PR head,两次独立复现(我 + codex)+ 本次 head spot-check 一致通过,对当前 master 无回归。从 review 角度未发现阻塞项。是否合码 / 发版 / live 由申晗拍板。
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 最终 head spot-check:通过
复验当前 PR head e76c1298:
- 父提交是原 head
1bb0e938,为干净的单提交修复; - tree
2d1d479679845cadc3386f5702a2e192b2306aaa与此前独立复验通过的b3576624完全一致; - GitHub 当前状态 OPEN / MERGEABLE,未夹带额外改动;
- 对最新
origin/master@6dcca31b试合零冲突,master 新增的 #627 与本 PR 文件无重叠; git diff --check通过;- 合成树
pnpm build通过; - opening 专项 22/22 通过,包含 cold-fork retry 与 live-reject→worker-null→refork 两条
resume:false回归。
此前请求修改的问题已在当前 head 正确解决,未发现新增阻塞项,批准本 PR。这里只给 review approval;是否合码、发版或部署由申晗确认,当前未执行任何合码/live 操作。
master 期间合入 #632(把 tmux adopt 每-pane 判定抽成 resolveAdoptableSessionForPane 共享给全量扫描 + 单 pane 快路径)与 #624(handleThreadReply worker-null 分支重构: forkWorker 前移进 try/catch + openingTurn/hadPriorCliInput resume 判据)。 冲突解决: - session-discovery.ts:本分支「真实 CLI 子 pid 解析放开到所有 argv-matched CLI」 从内联块搬进 resolveAdoptableSessionForPane(两个调用点——全量扫描 + 单 pane 快路径——都因此覆盖)。 - daemon.ts:把 adopt re-fork 分流(ds.adoptedFrom → forkAdoptWorker)并进 master 新的 try/catch fork 点,保留其 openingTurn/hadPriorCliInput resume 逻辑走非 adopt 分支。handleDocComment 分支无冲突、保持。 验证:tsc 干净、pnpm build 通过;adopt/discovery/session/lifecycle 261 tests 全绿 (含 repro 冒烟仍判别有效、master 新增单 pane 用例)。 Co-Authored-By: Claude <noreply@anthropic.com>
背景与根因
/repo xxx作为话题首条消息时,daemon 先建一个pendingRepo: true, pendingPrompt: ''的会话(消息本身就是命令,没有正文可提交)。选仓完成后forkPendingCli走forkWorker(ds, '', false)把 CLI 空启动,随后清空全部pending*字段。问题在于:清空之后,session 上没有任何状态记录「CLI 已经起来了,但从未接收过真实用户轮」。
handleThreadReply的两条投递分支只能用ds.worker活没活当代理判据:buildFollowUpCliInputbuildReforkCliInput两者都只产出
<botmux_reminder>follow-up 信封,永远不会产出<botmux_routing>/<botmux_builtin_skills>/<identity>—— 这些只由buildNewTopicCliInput产出。结果是 #477 的 opening routing 与 built-in skill discovery 永远进不了首个真实业务 turn,CLI 不知道要botmux send,也看不到内置技能目录。同一场景下卡片路径还存在一处不一致:
card-handler.commitRepoSelection在无 buffered 输入时反而会buildNewTopicCliInput(''),发一个空<user_message>的开场,既与/repo文本路径行为不同,又白烧一轮。本 PR 一并统一。改动
新增持久化状态
Session.initialUserTurnPending,语义即「新 CLI 已空启动、尚未接收真实用户 turn」。放在持久化的Session而非内存DaemonSession,是为了扛住「空启动之后、首条业务消息之前 daemon 重启」。src/core/initial-user-turn.ts:mark / is / claim / release四个语义化入口。claim是同步读-改-写,Node 单线程下天然互斥,保证并发/紧邻两条首消息只有一个 opener,另一条按既有队列顺序退化为 follow-up;adopt/bridge 会话直接判 false;落盘失败只降级不抛。command-handler.ts/card-handler.ts):仅在 repo select / skip / switch 且「无 buffered user input、无 attachments/follow-ups、无 pendingRawInput」的空启动时置位,以及 mid-session 切仓拉起全新 CLI 时置位。置位放在 fork 成功之后,fork 抛错不留残留状态。daemon.tshandleThreadReply):live-worker 与 worker-null/refork 两条分支统一——pending 时走buildNewTopicCliInput,完整透传 sender / mentions / attachments / availableBots / bot identity / locale / whiteboard / substituteTrigger / Codex App text+application+message context,不手拼缩水 prompt。先做非消费式 probe(普通 follow-up 不额外付getAvailableBots往返),await 完再同步 claim。sendWorkerInput返回 false(worker 已死/拒收)或抛错 → 归还;refork 分支 fork 抛错 → 归还。resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput)。hasHistory被 restore /claude_exit/suspendWorker无条件置 true,分不清「空启动」与「有真实历史」;用lastCliInput(只由rememberLastCliInput写、空启动刻意不写)补足判据,既不会--resume一个从没有过真实轮的 CLI,也不会把定时任务等非 IM 路径已经喂过的会话冷启覆盖掉。forkWorker(ds, '', false)+ 置位,不再发空<user_message>开场;rememberLastCliInput与command-handler对齐加守卫。Session从磁盘自动恢复,无需额外恢复逻辑)。状态机对比:
保持行为与影响面
/model/clear等,literal contract 原样保留——包 XML 会破坏契约、静默清标记会丢 opening,已在代码注释中写明兼容语义)、卡片/控制事件回调、ask 自定义回复、pendingRepo 缓冲、其它 bot 占锚点,全部在到达输入构造点之前 return。queuedPrompt仍拥有首轮。buildNewTopicPrompt,prompt / off / global / dynamic 四种语义原样继承 fix(skills): 恢复 botmux-send 按需指引 #477,没有另起一套。用户注册的<botmux_skills>catalog 走 worker 的deferredSkillCatalog通道,在第一条真实 input 上补挂,不重复注入。adapters/cli/共用基类或 worker 侧共用逻辑;测试同时覆盖codex(inline routing + skillsDir)、claude-code(injectsSessionContext,走原生注入通道)与codex-app(clean-input sidecar)。forkWorker(ds, '', true)(restore 内硬编码),不受resume守卫影响;worker 侧willReattachPersistent分支本就忽略 bin/args。PTY 惰性 refork、adopt/bridge、mid-session 切仓逐一核对并有测试。Session.initialUserTurnPending是纯新增可选字段,旧版本读到会直接忽略,回滚无脏状态。已知边界(刻意不扩):scheduler live-inject / webhook trigger / 文档评论 / dashboard 注入若成为空启动会话的第一轮,仍是 follow-up 形态、没有 opening 上下文——与当前行为一致,不是回归;标记会保留到下一条飞书业务消息才 opening,
hadPriorCliInput守住这种情况不被错误冷启。验证
先写在修复前失败的回归测试,再改代码:把
src/单独 stash 掉只留测试,相关三个文件 22 例失败;恢复后全绿。test/initial-user-turn-opening.test.ts(20 例):驱动真实路由handleThreadReply,只 stub 网络侧(downloadResources/ lark client / worker-pool),所有 prompt builder 保持真实,断言真实 opening 字节。覆盖 live worker 首条 opening + 第二条只 follow-up 不重复;worker-null/refork 路径 +resume: false;空启动后 daemon restart 状态仍生效;两条紧邻首消息只有一个 opener;/status与/model不消费状态且 passthrough 保持 literal;sender / mentions / attachments / available bots / quoted hint / Codex App sidecar 不丢;prompt / off / global / dynamic 按 adapter 能力断言(3 模式 × codex/claude-code,不用一个 literal tag 套所有 CLI);adopt/bridge 不受影响;worker 拒收与 fork 抛错时状态归还;非 IM 路径已喂过的会话保持resume: true;无标记的普通会话行为不回归。test/command-handler.test.ts+2 例、加强 3 例;test/card-handler-repo-select.test.ts+4 例、加强 1 例(置位/不置位的正反面:/repo <name>首条消息、bare/repo、skip_repo、mid-session 切仓 vs 有 buffered 输入、raw passthrough 冷启)。daemon-refork-substitute-wiring/daemon-rename-route/codex-app-clean-prompt/skill-injection-mode/builtin-skills/doc-comment-prompt/dashboard-create-session):10 files / 377 passed。pnpm build:通过。pnpm test:701 files / 10,863 passed / 35 skipped;the only failed cases were fixed by fix(sandbox): 重定向 Codex 不再暴露宿主 ~/.codex + 修复 10 个非 hermetic 测试 #605git diff --check:通过。