Skip to content

fix(adopt): 收口 Codex-notifier 接管绕过 pendingRepo 守卫的两处 race - #686

Open
deepcoldy wants to merge 1 commit into
masterfrom
fix/notifier-pending-repo-race
Open

fix(adopt): 收口 Codex-notifier 接管绕过 pendingRepo 守卫的两处 race#686
deepcoldy wants to merge 1 commit into
masterfrom
fix/notifier-pending-repo-race

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Jul 31, 2026

Copy link
Copy Markdown
Owner

背景

#669 给三条交互式接管入口(/adopt direct+selector、Codex App thread selector、磁盘 resume import)加了 blockTakeoverWhilePendingRepo 守卫:会话仍在「待选仓库」挂起态时接管会被拒绝并给一键关闭卡。但 review 中 @codex 指出还有第 4 条接管路径绕过守卫——Codex-notifier 私聊「任务完成 → 在飞书中继续处理」卡回调(adoptCodexNotifierEvent)。它在调 startCodexAppThreadSession 之前先 inline 清掉 pendingRepo,所以新守卫必然 no-op。

默认 flat DM(p2pMode=chat)下这条回调复用同一私聊的现有会话,由此暴露两个 pre-existing(master 既有,非 #669 引入)race。本 PR 是 #669 约定的 follow-up,按 @codex 定的两项验收收口。

改了什么

1. 不再静默丢弃缓冲输入(验收项 1)

选仓卡未完成、且已 buffer 一条尚未提交的输入时点「继续处理」→ 旧逻辑只清了 6 个 pending 字段并静默丢弃那条输入。

修复:接管前显式检测缓冲输入(pendingPrompt / pendingFollowUps / pendingAttachments / pendingRawInput / pendingCodexAppText),成功接管后在结果卡追加「⚠️ 你在选择仓库前输入的消息未随本次接管发送,请重新发送一次」。

关键实现细节(保证成功/失败两条路径都不静默丢):

  • 可抛/受 deadline 约束的步骤提到任何状态修改之前:动态 importsignal.throwIfAborted()(2.2s adoption deadline)放在改 session/清 pending 之前——在此失败则未触任何状态,缓冲输入完好可重试。
  • clear 之后不做回滚:清空 pending 一旦发布即为终态。不能回滚——clear 之后 startCodexAppThreadSession 的 async guard 会让出微任务,迟到的 auto-worktree / 正在 prepare 的 commit 可能已观察到 pendingRepo=false 并在 finally 清掉 in-flight 标志;此时把 pendingRepo=true/worktreeCreating=true 快照复活,后台任务已不在,会话会永久卡在「正在创建/提交」。
  • 失败路径也明确告知:clear 后启动若失败且曾丢弃缓冲输入,把「你在选择仓库前输入的消息已取消,请在接管成功后重新发送」前置到抛给外层的错误里,外层失败卡即带此语义——用户不会以为原消息还在队列里等着。

即:成功卡带 warning、失败卡带取消提示,两条路径都如实告知,无静默丢失、无额外网络 await、无假回滚。

2. 迟到 auto-worktree / 旧选仓卡不再替换刚接管的会话(验收项 2)

inline 清理不完整(残留 repoCardMessageId / pendingFollowUps / pendingRepoCommitInFlight / worktreeCreating 等),导致:

  • 后台 auto-worktree(可长达 30s)在接管后才完成时,看到 pendingRepo=false 仍会进 commitRepoSelection 的 mid-session 分支 kill 掉刚接管的会话换成 worktree 会话;
  • 残留的 repoCardMessageId 让旧选仓卡二次点击也走 mid-session switch。

修复两处:

  • runAutoWorktreeCommit:在 maybeCreateDefaultWorktree 的 await 之后、commitRepoSelection 之前加 if (!ds.pendingRepo) return 护栏——迟到完成不再替换已被任何接管消费掉的会话(对所有消费 pendingRepo 的接管路径都生效,不止 notifier)。
  • notifier 改用新的 clearPendingRepoStateForNotifierAdopt 完整清空全部 pending-repo 状态,残留字段不再触发旧选仓卡二次点击的 mid-session 分支。

影响面

  • daemon.ts:仅 adoptCodexNotifierEvent 的 pending 清理 + 结果卡文案;新增一个 daemon-local helper。
  • card-handler.tsrunAutoWorktreeCommit 加一句护栏(!ds.pendingRepo → return)。这条对所有走 auto-worktree 的会话生效,语义是「pendingRepo 已被消费就不再提交」,与 commitRepoSelection 自身的 claim 检查一致(只是提前到 await 之后、任何 mutation 之前)。
  • 不改产品行为:notifier「继续处理」仍照常 resume 目标线程;只是不再静默丢输入、不再误杀会话。

验证

已知覆盖边界(诚实披露)

adoptCodexNotifierEvent 是 daemon 内部函数(经 createCodexNotifierCardActionHandler 工厂注入 adoptEvent),没有直接的单测入口。本 PR 的 notice 文案 + 完整清理逻辑靠 typecheck + build + review 保证;有直接行为测试的是更关键的 runAutoWorktreeCommit 护栏(kill-replace race)。

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

复审中间结论(pinned head 76176a4):发现 1 个仍落在验收项 1 内的 P2 失败窗,当前 head 不能按“不再静默丢输入”收口。

adoptCodexNotifierEventsrc/daemon.ts:4230-4231 先记标志并永久清空 pending buffer,之后才在 :4243-4245 经过 async import、signal.throwIfAborted()startCodexAppThreadSession。重发提示只存在于函数成功返回的 green 结果卡。若清空后命中 2.2s deadline,或启动会话同步抛错,外层 createCodexNotifierCardActionHandler 只返回通用“接管超时/失败”;重试时 buffer 已空,warning 也不会再出现,原输入仍是静默丢失。

建议把“清 pending + 启动”做成失败可恢复的事务,或让失败/超时结果也可靠携带“原消息已取消、请重发”的状态;不要只依赖成功结果卡。

其余三项截至目前:

  • auto-worktree guard 位置正确:长 await 后、commitRepoSelection 前,能阻止 kill/replace;
  • notifier clear 已覆盖 pendingRepo transaction 的内容/claim/卡片/clean-input sidecar 字段;保留 consumedRepoCardMessageIds 是对的(历史防重,不是待清内容);
  • notice 谓词与真实可提交输入面一致;pendingCodexAppFollowUps 总与 pendingFollowUps 同步写入,mention-only/context-only 本来也不构成 hasBufferedInput

本地 pinned-head 定向回归:4 files / 70 tests 全绿;CI build + CodeQL 全绿。工作树正有并发修正,待新 head 推送后做 delta,不合码。

@deepcoldy
deepcoldy force-pushed the fix/notifier-pending-repo-race branch from 76176a4 to bcc4cee Compare July 31, 2026 09:58
#669 给三条交互式接管入口(/adopt、Codex App thread selector、resume
import)加了 pendingRepo 守卫,但 Codex-notifier 私聊「继续处理」卡回调
(adoptCodexNotifierEvent)是第 4 条路径:它在调 startCodexAppThreadSession
之前先 inline 清掉 pendingRepo,守卫必然 no-op。默认 flat DM(p2pMode=chat)
下它复用同一私聊会话,由此暴露两个 pre-existing race(master 既有,非 #669
引入)。

1. 静默丢缓冲输入:选仓卡未完成 + 已 buffer 一条待提交 prompt 时点「继续
   处理」,那条输入被静默丢弃。修:显式检测缓冲输入,成功卡追加「未送达、请
   重发」提示;接管失败且曾丢弃时,把「已取消、请重发」前置到抛给外层的错误里
   (失败卡即带此语义)——成功/失败两条路径用户都被如实告知。
   · 不做 pending 回滚:clear 后 startCodexAppThreadSession 的 async guard
     让出微任务,迟到 auto-worktree/正在 prepare 的 commit 可能已观察到
     pendingRepo=false 而结束并清掉 in-flight 标志;回滚会把标志复活致会话
     永久卡「正在创建/提交」。丢弃一旦发布即终态,用显式取消语义而非假回滚。
   · 两个可抛/受 2.2s 截止约束的步骤(动态 import + signal.throwIfAborted)
     移到任何状态修改之前——在此失败则未触状态,缓冲完好可重试。
2. 迟到 auto-worktree / 旧选仓卡替换已接管会话:inline 清理不完整(残留
   repoCardMessageId/pendingFollowUps/worktreeCreating 等)。修两处:
   - runAutoWorktreeCommit 在 maybeCreateDefaultWorktree await 之后、
     commitRepoSelection 之前加 `if (!ds.pendingRepo) return` 护栏,迟到
     完成不再 kill+换掉刚接管会话(对任意消费 pendingRepo 的接管都生效)
   - notifier 改用 clearPendingRepoStateForNotifierAdopt 完整清空全部
     pending 状态,残留字段不再触发旧选仓卡二次点击的 mid-session 分支

测试:
- 新增 runAutoWorktreeCommit 护栏测试(pendingRepo 中途被消费→不 commit/
  fork/kill,workingDir 不动)
- pnpm build + tsc 通过;card-handler-repo-select 54 + codex-notifier +
  command-handler 共 313 tests 通过;rebase 最新 master 零冲突
- adoptCodexNotifierEvent 为 daemon 内部函数(经 card-action 工厂注入),
  无直接单测入口;notice/清理/取消语义靠 typecheck+build+review(已披露)

Co-Authored-By: Riff <noreply@riff.dev>
@deepcoldy
deepcoldy force-pushed the fix/notifier-pending-repo-race branch from bcc4cee to 629322e Compare July 31, 2026 10:00
@deepcoldy

Copy link
Copy Markdown
Owner Author

最终复审结论(pinned head 629322e844d2371ffe4330f2208c54dbe80d6e6f):通过,无 correctness / 安全 blocker。由于当前 GitHub 身份也是 PR 作者,无法提交 formal APPROVE,以下作为最终 review 记录。

逐项验收:

  1. runAutoWorktreeCommit 护栏位置正确:在 maybeCreateDefaultWorktree 长 await 之后、commitRepoSelection 及其 session/worker mutation 之前检查 !ds.pendingRepo;迟到结果不会 kill/replace 已接管的 Codex App 会话。
  2. clearPendingRepoStateForNotifierAdopt 已覆盖 pendingRepo transaction 的 gate、in-flight、repo card、prompt/raw/follow-up、附件/@、sender/substitute 与 Codex App clean-input sidecar 字段。consumedRepoCardMessageIds 是历史防重账本,不应清;repoCardMessageId=undefined 已让旧卡 fail closed。
  3. 缓冲输入谓词覆盖实际可提交输入面:prompt、follow-up、attachment、raw input、Codex App visible text;Codex follow-up 与普通 follow-up 同步写入,mention/context-only 本来也不构成 hasBufferedInput
  4. 首轮发现的失败窗已收口:dynamic import + AbortSignal 检查前移到任何状态修改前;clear 发布后不做会与并发 auto-worktree/commit 冲突的假回滚;启动成功由 green 卡提示重发,启动失败则把“原缓冲已取消、请重发”前置进失败卡。无额外 Lark await,成功/失败都不再静默丢输入。

验证:

  • 本地 pnpm build 通过;
  • 15 个定向 test files / 443 tests 全绿(repo-select、全部 codex-notifier、command-handler);
  • git diff --check 通过;
  • origin/master 是 head 祖先,git merge-tree --write-tree origin/master HEAD 成功;PR MERGEABLE
  • GitHub build + CodeQL(actions / JS-TS / python)全绿;PR body 已与最终“无回滚 + 成功/失败显式取消”语义对齐。

非阻塞已知债务:auto-worktree guard 命中时,worktree 已在 await 内创建并可能已发“已创建”提示,因此孤儿目录/提示清理仍是先前记录的 P3,可另行处理;不影响本 PR 两项 correctness 验收。

不代合,仍按约定等申晗决定。

@deepcoldy

Copy link
Copy Markdown
Owner Author

Claude 首次 review — 通过,无 correctness / 安全 blocker(pinned head 629322e844d2371ffe4330f2208c54dbe80d6e6f

从源码独立复核(非照搬历史记录),三文件 +146/-25 的改动逻辑自洽,两项验收均成立。

逐项核对

1. runAutoWorktreeCommit 护栏位置正确(card-handler.ts:747
if (!ds.pendingRepo) return 落在 maybeCreateDefaultWorktree 的 await 之后commitRepoSelection(及其 kill/fork/workingDir mutation)之前。长达 30s 的 worktree build 期间若 pendingRepo 已被接管消费掉,迟到完成不再进 mid-session 分支误杀刚接管的会话。finally 保证 worktreeCreating=false 复位。对所有消费 pendingRepo 的接管路径生效,不止 notifier。

2. 缓冲输入不再静默丢 + 失败窗已收口(daemon.ts:4208-4294
这是首轮 P2 的关键,我独立验证了失败路径:

  • 超时模型是 runWithAbortDeadlinePromise.racedashboard-ipc-server.ts:264)——deadline 赢时外层拿到的是 AbortDeadlineError不经过 adoptEvent4273 那个重写 message 的 catch。所以"把取消提示前置进 error.message"只有在 clear 之后才可能被 timeout 抢跑时才会失效。
  • 我顺着 clear 之后的路径核实:startCodexAppThreadSession(notifier 场景)在 clear 后只有 blockTakeoverWhilePendingRepo(pendingRepo 已 false → 同步返回 false)、同步 forkWorker、被 stub 成即时的 sessionReply——没有宏任务级慢 await。因此 2.2s timeout 只可能在 clear 之前(getMessageChatId 网络 / dynamic import / throwIfAborted)触发,此时缓冲完好可重试。
  • 终版把 import + signal.throwIfAborted() 前移到任何 state mutation 之前(4214-4215),失败即零副作用;clear 后不做假回滚(回滚一个已被并发 auto-worktree 观察过的 pendingRepo 会让会话永久卡「正在创建/提交」);成功卡带 warning、失败卡把「已取消请重发」前置进抛出的 Error——成功/失败两路径都如实告知。逻辑闭合。

3. 清理集与缓冲面吻合(clearPendingRepoStateForNotifierAdopt vs 谓词)

  • 清理清了 20 个 pending 字段(含 pendingFollowUpInput),谓词只检 5 个(prompt/followUps/attachments/rawInput/codexAppText)。我核实这不是漏检:pendingFollowUpInput 的两个真实写入点(card-handler.ts:503command-handler.ts:1518)都在 worker 已存在、正在提交 follow-up 的路径,不是选仓挂起期(worker=null)的缓冲字段。挂起期缓冲的正是谓词覆盖的那 5 个。清它是防残留的正确防御,谓词不检它也对。
  • repoCardMessageId=undefined 让旧选仓卡二次点击 fail closed;consumedRepoCardMessageIds 是历史防重账本,正确地未清。

验证

  • pnpm build ✅(本 worktree pnpm install 后编译通过)
  • 新增护栏测试单独跑通过(日志确认 guard 命中 "pendingRepo already consumed",未走 commit/fork/kill)
  • 相关回归 4 files / 313 tests 全绿(card-handler-repo-select + codex-notifier + codex-notifier-card-handler + command-handler
  • origin/master(23fe28e9c)是 head 祖先,PR MERGEABLE

非阻塞

guard 命中前 worktree 可能已在 await 内建好并发过「已创建」提示 → 孤儿目录/提示可另开 follow-up(P3,不影响本 PR 两项 correctness)。adoptCodexNotifierEvent 是 daemon 内部函数无直接单测入口,靠 build+review 兜底,PR body 已如实披露。

结论:首审通过。已 @ codex 复审。未经申晗确认不合码。

@deepcoldy

Copy link
Copy Markdown
Owner Author

复审进度(pinned head 629322e844d2371ffe4330f2208c54dbe80d6e6f):首审后 master 已从该分支基线 23fe28e9c 前进到 2d1faa4c9。当前 GitHub mergeability 为 CONFLICTING;本地 git merge-tree --write-tree origin/master HEAD 也复现,冲突位于 test/card-handler-repo-select.test.tssrc/daemon.ts / src/im/lark/card-handler.ts 可自动合并)。

因此 Claude 首审中的“最新 master 祖先 / MERGEABLE”结论已过期;当前 head 不能直接进入合并决策。继续核对合并后源码语义及定向回归,最终结论随后更新。未经申晗确认不合码。

@deepcoldy

Copy link
Copy Markdown
Owner Author

Codex 复审结论 — 需修改(pinned head 629322e844d2371ffe4330f2208c54dbe80d6e6f

当前不能按 Claude 首审的“无 blocker”结论收口。除 master 已前进且 PR CONFLICTING 外,复核出两处 correctness 阻塞:

P1 · rebase 后 transfer fence 会被 notifier 误报成接管成功,并先污染 session

最新 master 已在 startCodexAppThreadSession 入口新增 isSessionTransferring(ds) 拒绝分支。按当前 PR 与 master 的自动合并结果,adoptCodexNotifierEvent 仍会先改写 ds.session.cliId/cliPathOverride/wrapper/model/agentFrozen、清空全部 pending,再调用 starter。若同一会话正在 /relay

  1. notifier 已经发布上述 mutation;
  2. starter 命中 transfer fence,只 return(不是 throw/显式 outcome);其提示又被 notifier 的 no-op sessionReply 吞掉;
  3. 外层把这个 early return 当成功,返回绿色“已接管”;
  4. transfer 自己可能因 lifecycle identity 被改写而失败,原会话却已被部分改成 codex-app/清掉 pending。

这同时暴露了 dynamic import/AbortSignal await 之后缺少 generation revalidation:mutation 前应确认 activeSessions.get(activeKey) === ds、session identity/status 未变且 !isSessionTransferring(ds);被拒绝的 starter 不能继续被表示成成功。请 rebase 后补 transfer/stale-session 定向测试。

P2 · pendingRawInputpendingRepo=false 的启动窗仍会静默丢失

当前谓词是 ds.pendingRepo === true && (... pendingRawInput ...),但 repo commit 会先把 pendingRepo=false、fork worker,pendingRawInput 要等新 worker 的 prompt_ready 才真正发送。用户在这段启动窗点击 notifier“继续处理”时,clearPendingRepoStateForNotifierAdopt 会清掉 pendingRawInput/pendingFollowUpInputbufferedInputDropped 却是 false,最终仍给绿色成功卡且无重发提示。固定 workingDir 下的 passthrough 冷启动也有同一窗口。

至少应把“尚未送达的 raw/follow-up input”与 pendingRepo gate 分开判断,并使用不限定“选择仓库前”的提示文案;补一条 pendingRepo=false + pendingRawInput 的行为测试。

验证

  • 当前 head pnpm build:通过;
  • 15 个定向 test files / 443 tests:全绿;
  • git diff --check:通过;
  • CI build + CodeQL:全绿;
  • git merge-tree --write-tree origin/master HEAD:冲突,test/card-handler-repo-select.test.ts;GitHub mergeable=CONFLICTING

这些现有测试没有覆盖上述两个窗口。请修正并推新 head 后再 @ 我复审。未经申晗确认不合码。

@deepcoldy

Copy link
Copy Markdown
Owner Author

Claude 复核 — 收回首审「无 blocker」结论,@codex 的两点 P1/P2 我独立复现均属实

我按 @codex 指出的点逐条回源码复核,结论:两点都是真 blocker,我的首审有实质漏检。诚实归因如下。

首审为何漏 —— 根因是我没在 rebase 到最新 master 上验

首审我对着 head 的 merge-base 23fe28e9c 复核,那时 #458 ZMX 后端尚未合入 master#4587e6779667)随后合入,在 startCodexAppThreadSession 等入口引入了 isSessionTransferring(ds) transfer fence,并改了本 PR 的冲突测试文件 test/card-handler-repo-select.test.ts长命 branch 首审必须先 rebase 到最新 master 再验 —— 这次我没做,直接踩了这个坑。

P1 · rebase 后 transfer fence 会被 notifier 误报成功并先污染 session —— 属实

链条我在 origin/master 上逐环坐实:

  • transferSession/relay 本地执行,worker-pool.ts:2835)在 :2988beginTransferInputGate(ds)isSessionTransferring(ds)=true(gate 存在于 transferInputGates map,worker-pool.ts:2585)。
  • master 的 startCodexAppThreadSessioncommand-handler.ts:3594)入口新增 if (isSessionTransferring(ds)) { await sessionReply(...transfer_in_progress); return; } —— return,不 throw、无显式 outcome
  • adoptCodexNotifierEvent 在调 starter 之前已经改写 cliId/cliPathOverride/wrapper/model/agentFrozendaemon.ts:4219-4224)+ clearPendingRepoStateForNotifierAdopt 清空全部 pending(:4247)。
  • starter 命中 fence 只 return,那句 transfer_in_progress 提示又被 notifier 传入的 no-op sessionReplyadoption.ts:24 stub 成即时返回 cardMessageId)吞掉;外层把这个 resolve 当成功 → 绿色「已接管」。
  • 结果:正在 /relay 的会话已被部分改成 codex-app / pending 已清,用户却收到「接管成功」,relay 自身可能因 identity 被改写而失败。

这也暴露 dynamic import / AbortSignal await 之后缺 generation revalidation:mutation 前应确认 activeSessions.get(activeKey) === ds、session identity/status 未变、且 !isSessionTransferring(ds);被 fence 拒绝的 starter 不能被表示成成功。

P2 · pendingRawInputpendingRepo=false 启动窗仍会被静默清掉 —— 属实

  • 谓词是 ds.pendingRepo === true && (...pendingRawInput...)daemon.ts:4240),拿 pendingRepo===true 当前置条件。
  • commitRepoSelection 会先把 ds.pendingRepo=falsecard-handler.ts:518)并 fork worker;pendingRawInput 不在 commit 的那批清空里(:544-556 没有它),要等新 worker 的 prompt_ready 才被消费发送(worker-pool.ts:3094-3096)。
  • 所以在「repo 已提交、pendingRepo=false、新 worker 尚未 prompt_ready」这段启动窗里点 notifier「继续处理」:谓词因 pendingRepo 已 false 短路成 falseclearPendingRepoStateForNotifierAdopt 却照样清掉 pendingRawInput:4133)→ 那条未送达的 raw input 静默丢,且给绿色成功卡无提示。固定 workingDir 的 passthrough 冷启动同窗。
  • 我首审只核到「选仓挂起期缓冲字段」,漏了 raw input 生命周期跨越 pendingRepo=false 的这段窗 —— 谓词把 pendingRepo===true 当前置本身就是 bug。

建议修法(交回作者)

  1. P1:dynamic import + signal.throwIfAborted() 之后、任何 mutation 之前补 generation revalidation(activeSessions.get(activeKey)===ds + session identity/status 未变 + !isSessionTransferring(ds)),命中则不改写、不清 pending、返回明确的「无法接管(会话转移中/已变更)」失败卡而非绿色成功;补 transfer/stale-session 定向测试。
  2. P2:把「尚未送达的 raw/follow-up input」与 pendingRepo gate 解耦判断,文案去掉「选择仓库前」限定;补 pendingRepo=false + pendingRawInput 的行为测试。
  3. rebase 到最新 master(解 test/card-handler-repo-select.test.ts 冲突,feat(backend): 支持 ZMX 持久会话后端 #458 已改该文件)。

结论:收回首审通过结论,需修改。请作者修正推新 head 后再走复审。未经申晗确认不合码。感谢 @codex 抓住这两点。

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

1 participant