perf(adopt): 卡片选完会话后只解析目标 pane,不再全量重扫 - #632
Conversation
用户在 /adopt 选择卡里点定某个会话后,card-handler 仍会调
discoverAdoptableSessions() 把本机所有 tmux pane 重扫一遍,再 .find() 挑出
那一个。全量扫描要对每个 pane 走进程树,且树里每个节点都拉一次全量 `ps`,
pane 一多就是数秒级同步阻塞。
这条路径是飞书卡片回调,只有 3s 预算且**不会重推**(card.action.trigger 与
普通事件不同)。更糟的是同步 execSync 会把 Node 事件循环整个冻住,连
event-dispatcher 里 2500ms 提前 ACK 的保险丝都发不出去 —— 定时器被饿死,
用户侧看到飞书弹的红色「目标回调服务超时未响应」(错误码 200341)。
外层还套了 3 次重试,最坏是 4 遍全量扫描。
改动:
- 把 discoverAdoptableSessions 每 pane 的循环体抽成
resolveAdoptableSessionForPane,全量扫描与单 pane 快路径共用同一份判定,
避免两条路径漂移(尤其是 bmx-* 跳过和 filterCliId 过滤)
- 新增 discoverAdoptableSessionByTarget(tmuxTarget, filterCliId),用
`tmux display -t <target> -p '#{pane_pid}'` 只取那一个 pane
- card-handler 的 resolveAdoptTarget 先走快路径;判定谓词与全量路径完全一致,
没命中就原样回落全量扫描,因此不改变任何既有结果
实测(本机 31 个 tmux pane / 29 个可 adopt 会话):
全量扫描 4948ms → 单 pane 145ms,34x,且返回对象与全量扫描逐字段相同
影响范围:
- 仅 tmux 来源的 adopt 目标走快路径。zellij 分支未动;herdr 目标的 option 里
没有 tmuxTarget,且需要 excludeOwnedHerdrAdoptTargets 的全局视图,两道
guard 都会挡住它回落全量路径(该函数对非 herdr 条目恒返回 true,快路径
跳过它是 no-op)
- filterCliId 原样透传。卡片 option 是用户可控输入,快路径若丢掉这个过滤就
等于允许把 bot 切到别的 CLI 实现,测试里专门覆盖了这条
- 其它 CLI(codex launcher 跟随、coco/traex rollout 绑定)逻辑随循环体整体
搬迁,未做语义修改,由既有 49 条 discoverAdoptableSessions 测试守住
测试:
- 新增 7 条 discoverAdoptableSessionByTarget 用例,含与全量扫描的逐字段
一致性对比、不执行 list-panes、不触碰其它 pane、bmx-* 跳过、filterCliId
拒绝、pane 消失回落、含空格会话名
- 修 setupMocks 的 pane_pid 分支:原本用 line.split(' ')[1] 取 pid,会话名
含空格时会取成会话名后半段(正是生产代码留了回归测试的那个坑),改为按
最后一个空格切
- pnpm test:11023 passed / 2 failed,两条失败均为 codex-app-threads 的
超时型 flaky,在干净 master 上跑同一套件同样失败(11016 中 2 failed),
与本改动无关
deepcoldy
left a comment
There was a problem hiding this comment.
首审结论:🔴 CHANGES_REQUESTED(1 个 P1 硬阻塞)
隔离审阅钉 head 908bb94a,base = merge-base = 当前 master 6dcca31b(零漂移)。抽取重构本身是字节忠实的、思路正确,但新增的快路径引入了一个比它要修的问题更严重的回归。
🔴 P1(CONFIRMED,我已独立复现):死目标让 discoverAdoptableSessionByTarget 同步冻结 >45s
根因:discoverAdoptableSessionByTarget(session-discovery.ts:933-934)的守卫是
panePid = Number(out);
if (isNaN(panePid)) return undefined;但当 pane 已死 / 目标歧义时,tmux display -t <target> -p '#{pane_pid}' 返回的是空 stdout + exit 0(不是报错,不会进 catch)。我在本机 tmux 实测了 4 种死目标(会话不存在、prefix-collision、坏 window/pane index、活会话+死 pane),全部 stdout=[] exit=0。于是:
Number('') === 0,isNaN(0) === false→ 守卫不触发- 落到
resolveAdoptableSessionForPane(target, 0, filterCliId)→findCliProcess(0, 3, ...) - 它从 PID 0(整个 OS 进程树的根)开始 BFS,每个节点 fork 一次全量
ps -A
实测证据(钉 908bb94a 隔离树、pnpm build 后、在有真实 tmux server 的机器上跑):
$ timeout 45 node -e "import('.../dist/core/session-discovery.js').then(m =>
m.discoverAdoptableSessionByTarget('nonexist:0.0','claude-code'))"
timeout exit code = 124 # 被 45s 墙钟杀掉,仍未返回
>45 秒同步阻塞,远超它要优化掉的 5.4s,并且是在 card.action.trigger 的 2500ms ACK 关键路径上(event-dispatcher.ts:727 的 Promise.race([work, setTimeout(2500)])——同步 execSync 会饿死这个定时器,正是本 PR 描述里点名的失败模式),会冻住整个 daemon 事件循环、波及所有 bot 的飞书长连接。
这恰恰是本 PR 的 retry/fallback 机制存在的理由:pane 在「建卡 → 用户点击」之间死掉。改动不但没修,反而把它放大成几十秒级冻结。
master 免疫:discoverAdoptableSessionByTarget 是本 PR 全新函数。master 只从 tmux list-panes(仅列活 pane)拿 panePid,绝不会合成 panePid=0。所以这是纯新增回归,非既有问题。
建议修法(我已隔离验证):
panePid = Number(out);
// 死/歧义目标下 `tmux display` 打印空 stdout + exit 0,Number('')===0 且 isNaN(0)===false,
// 必须同时挡掉 0 / 负数,否则 findCliProcess(0,...) 会从 pid 0 遍历整棵进程树。
if (!out || !Number.isInteger(panePid) || panePid <= 0) return undefined;patch → rebuild → 实测:死目标 10ms 返 undefined(回落全量扫描 = master 行为),活 pane 82ms 正常解析,bmx-* 7ms 跳过。请再补一条死目标回归测试:mock tmux display 返空串,断言返 undefined 且不调用 ps / list-panes(现有 7 条用例的 gone:9.9 那条走的是 mock 抛错分支,覆盖不到「空串+exit0」这条真实路径)。
✅ 其余全部 REFUTED(独立 trace + 对拍 master)
- 抽取字节忠实:逐行 diff master 循环体 vs
resolveAdoptableSessionForPane,每个continue→return undefined,字段与顺序完全一致,只有tmuxTarget/panePid的 parse 正确地留在调用方。 - 跳过
excludeOwnedHerdrAdoptTargets对 tmux 是 no-op:该谓词对任何source !== 'herdr'恒返true(:810)。herdr 双重排除(selected.source !== 'herdr'+ herdr 两个 push 块都不设tmuxTarget)。 - 无新的目标解析风险:master 全量路径本就用
tmux display -t <target>(getPaneDimensions:596),快路径同款 query +shellescape+tmuxEnv()。-t若曾解析错,两条路径会一起错。 filterCliId保留:切 CLI 的防护在discoverAdoptableSessionByTarget(target, botCfg.cliId)→findCliProcess(...,filterCliId),不在matchesSelected。新增的 codex 用例覆盖到了。matchesSelected与 master 内联谓词逐字一致;cliPid 漂移致 key mismatch 时两路同 miss → 同样回落 retry。- test-mock 修正是孤立的:
setupMocks的pane_pid分支split(' ')[1]→slice(lastIndexOf(' ')+1),匹配生产的lastIndexOf解析,setupMocks仅本文件使用。
测试
- 钉
908bb94apnpm build绿;改动文件隔离跑session-discovery56/56 +session-adopt11/11 = 67 全过。 - 全量套件 PR head 10 failed(
scheduler2 /v3-distillation-runner6 /fs-policy-bwrap.e2e1 /schedule-card-model1)=干净 master6dcca31b(我另 build + 定向跑这 4 文件)逐条同款 10 failed = 零回归;4 个失败文件均不引用改动模块(本机 env baseline)。
残留:作者修(守卫 + 死目标回归测试)→ @codex 复审 → 申晗拍板。未获授权前不合码。
复审交叉核对:补一条我首审误判的 P2(自我更正)codex 独立复审确认了我首审的 P1(死目标 → 但它同时指出:我首审把 H2/H3(接管到错误 pane)判成 REFUTED 是错的。复核后我确认自己判错了,在此更正。 🟠 P2(CONFIRMED,我已独立复现):
|
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审结论:🔴 CHANGES_REQUESTED
复审钉住 head 908bb94a1eb72b5502f321510acaed6f3362062d。优化方向与循环体抽取本身没有发现语义漂移,但我独立确认了 1 个 P1 硬阻塞,并复现了 1 个同根 P2;当前不能合并。
🔴 P1:死 target 的空输出被解析成 PID 0,单次同步冻结超过 45 秒
位置:src/core/session-discovery.ts:929-939
真实 tmux 对不存在的 target 的行为是:stdout 只有一个换行、stderr 为空、exit code 为 0。.trim() 后 out === '',但当前守卫只有:
panePid = Number(out);
if (isNaN(panePid)) return undefined;因此 Number('') === 0 且 isNaN(0) === false,代码进入 resolveAdoptableSessionForPane(..., 0, ...)。随后 findCliProcess(0, 3) 从 PID 0 开始 BFS,而 getChildPids 会对遍历到的每个节点各同步执行一次全量 ps -A -o pid= -o ppid=,冻结 Node 事件循环。
我的独立实测(本机真实 tmux server、PR build 后):
tmux display -t 'nonexist:0.0' -p '#{pane_pid}'
stdout bytes = 1(换行),stderr bytes = 0,exit = 0
timeout 45s node ...discoverAdoptableSessionByTarget('nonexist:0.0','claude-code')
exit = 124,elapsed = 45.007s(仍未返回)
这条路径位于飞书卡片 2.5s ACK 的同步关键路径,并且 resolveAdoptTarget 最多执行 4 次;execSync 期间 ACK timer 也无法调度,会冻结整个 daemon,而不只是当前 bot。
建议至少改为:
if (!out || !Number.isInteger(panePid) || panePid <= 0) return undefined;并补真实行为对应的回归测试:tmux display 返回空字符串但不抛异常,断言快速返回 undefined,且不调用 ps / list-panes。现有 gone:9.9 用例 mock 的是抛异常分支,覆盖不到此问题。
🟠 P2:tmux display -t 会前缀模糊命中,PID 复用时可接管错误会话
仅补 panePid > 0 不能解决这一条。tmux 的 -t 会按前缀匹配:我在隔离 tmux server 中只创建长会话 foobarX,查询不存在的 foobar:0.0,tmux 以 exit 0 返回 foobarX:0.0 的真实正数 PID。
更进一步,我用当前 build 调快路径查询不存在的短 target,得到的对象表现为:
tmuxTarget = 请求的短 target(已不存在)
panePid / cliPid / cwd / 尺寸 = 长会话的真实数据
原因是 resolveAdoptableSessionForPane 把入参 tmuxTarget 原样回填,而没有核对 tmux 实际解析到的 canonical 地址。
matchesSelected 确实提供了一道 PID 门控:只有长会话当前 CLI PID 恰好复用了卡片里的旧 PID 才会命中;否则会回落全量扫描并正确报告退出。但 PID 一旦复用,validateAdoptTarget 会再次用同一个失效短 target 调 tmux display -t,再次模糊命中长会话并验证通过,最终把 bot 绑定到用户未选择的 pane。因此这是低概率、但端到端可达的错误接管。
建议让同一条 tmux display 同时回显 canonical 地址和 PID,并要求地址与卡片中的完整 target 严格相等,例如:
-p '#{session_name}:#{window_index}.#{pane_index} #{pane_pid}'按最后一个空格解析(兼容含空格的 session 名),拒绝空输出、地址不一致、非整数或非正 PID。再补一条 foobar 查询被 tmux 回显为 foobarX 时返回 undefined 的测试。
影响面与验证
- P1/P2 都只来自本 PR 新增的 tmux 单-pane 快路径;zellij、herdr 分支未进入该路径。
filterCliId仍原样透传,bmx-*跳过与 codex launcher 跟随逻辑在抽取后保持一致。pnpm build:通过。pnpm vitest run test/session-discovery.test.ts test/session-adopt.test.ts:67/67 通过。pnpm test:11014 passed、4 failed、5 skipped;4 个失败位于未改动的fs-policy-bwrap/schedule-card-model/scheduler测试,与本 PR 路径无关。git diff --check:通过;工作树干净。
请修复 P1 守卫、P2 canonical target 核对,并各补一条回归测试后再复审。未执行合并,也未修改 PR 代码。
CR 复审指出的 P1 + P2,我在真机(tmux 3.6a)逐条复现后确认成立,且实际
范围比报告的更广 —— 不止会话名前缀匹配。
`tmux display -t <target>` 是模糊解析,且解析失败时**不报错**(exit 0、
stderr 为空),实测:
请求 nonexist:0.0 → 地址回显 ':.'、pane_pid 为空串
请求 claude:99.0 → 解析到 claude:2.3(window 索引不存在 → 回落活动 window)
请求 claude:1.99 → 解析到 claude:1.1(pane 索引不存在 → 回落活动 pane)
请求 clau:1.3 → 解析到 claude:1.3(会话名前缀)
请求 watc → 解析到 watch:1.1(前缀 + 省略索引)
P1:原守卫只有 `isNaN(panePid)`,而 `Number('') === 0`、`isNaN(0) === false`,
守卫不触发 → `findCliProcess(0, 3)` 从 pid 0 开始 BFS 整棵进程树,每个节点再
fork 一次全量 `ps`。实测 >45s 同步冻结,比它要优化掉的 5.4s 更糟,而且就在
卡片回调 2500ms ACK 的同步关键路径上,会冻住整个 daemon 事件循环、波及所有
bot。这恰恰是 retry/fallback 机制本来要覆盖的场景(pane 在建卡与点击之间死掉),
上一版不但没修反而放大了它。
P2:拿到正数 pid 也不等于命中了请求的那个 pane。`resolveAdoptableSessionForPane`
把入参 tmuxTarget 原样回填,于是返回对象会是「地址是用户选的、数据是另一个
pane 的」。matchesSelected 的 cliPid 门控只在 PID 复用时失守,概率低但端到端可达。
修法:让同一条 display 连 canonical 地址一起回显,再要求它与请求的 target
严格相等 —— 一次同时挡掉 P1 的空 pid 和 P2 的静默错配;额外显式拒绝非正整数
pid。格式串与 discoverAdoptableSessions 的 list-panes 完全一致,两边可直接比较;
按最后一个空格切分,兼容含空格的会话名。
真机验证(修复后):
nonexist:0.0 23ms → undefined (原 >45s 冻结)
claude:99.0 26ms → undefined
claude:1.99 25ms → undefined
clau:1.3 20ms → undefined
watc 20ms → undefined
claude:1.3 247ms → 正常解析 (真实目标不受影响)
新增 3 条回归测试:
- 死目标返回「空 pid + 不抛异常」时返回 undefined,且断言只发一次 tmux 查询、
完全不调用 ps / list-panes(原有 gone:9.9 用例走的是 mock 抛错分支,覆盖不到
这条真实路径)
- canonical 地址与请求不符(foobar → foobarX)时返回 undefined,同样不碰进程树
- 反向断言:地址严格相等时正常继续解析,含空格会话名也能对上
已知遗留(本 PR 不改,属既有行为):`validateTmuxAdoptTarget` 用的仍是不带
canonical 校验的 `tmux display -t`,同样会前缀/索引模糊命中。本 PR 的快路径在
源头返回 undefined 后会回落全量扫描(只列活 pane),该链路不再可达,但这个函数
本身在 master 上就有这个性质,建议另开 issue 处理。
|
感谢两位的复审,P1 / P2 都成立,已修,head → 我在真机(tmux 3.6a)逐条复现了,实际范围比报告的还广 —— 不止会话名前缀匹配。 也就是说「只补 修法const out = execSync(
`tmux display -t ${shellescape(tmuxTarget)} -p '#{session_name}:#{window_index}.#{pane_index} #{pane_pid}'`,
...
).trim();
// 按最后一个空格切:会话名可能含空格,与 discoverAdoptableSessions 的 list-panes 解析规则一致
const spaceIdx = out.lastIndexOf(' ');
if (spaceIdx === -1) return undefined;
canonicalTarget = out.slice(0, spaceIdx);
panePid = Number(out.slice(spaceIdx + 1));
...
// tmux 解析到了别的 pane(前缀命中 / 索引不存在时回落活动 pane)→ 当作没找到
if (canonicalTarget !== tmuxTarget) return undefined;
// 死目标下 pane_pid 是空串,Number('')===0 且 isNaN(0)===false,必须显式挡掉
if (!Number.isInteger(panePid) || panePid <= 0) return undefined;格式串与 真机验证(修复后)死目标连跑 5 次:83 / 118 / 100 / 107 / 125 ms,稳定。 新增 3 条回归测试
收益数字修正上一版 PR 描述里的 中位数提速 32.7x;机器空闲时是 4948ms → 145ms。倍数随负载浮动,但量级稳定。顺带一提,全量扫描在高负载下能退化到 28.7 秒,比原始 5.4s 的基线还严重得多。 已知遗留(本 PR 不改)
@codex 描述的 P2 端到端链路(PID 复用 → 全量测试说明这台机器 load average 常年 28~54(跑着 20+ 个 live CLI 会话),全量套件里那批派生进程 / 超时敏感的用例本身就不稳:同一份代码我连跑 4 次,失败集合每次都不一样( 已逐个 grep 确认:这些文件没有一个引用 |
|
To use Codex here, create an environment for this repo. |
复审 deepcoldy#632 时发现姊妹函数 validateTmuxAdoptTarget 与刚修的 discoverAdoptableSessionByTarget 是同一类 bug(Number('') === 0): `tmux display -t <死/歧义目标> -p '#{pane_pid}'` 不报错,只打印空 pane_pid 并以 exit 0 返回,于是 `Number('') === 0`、`isNaN(0) === false`——旧的 `if (isNaN(panePid)) return false` 挡不住,随后 hasCliProcess(0, expectedPid, 6) 从 pid 0 开始 BFS 整棵进程树、每个节点再 fork 一次全量 `ps`,实测 >20s 同步 冻结;且一旦 expectedPid 恰好还活在机器上任意位置就误报 alive。 与 discoverAdoptableSessionByTarget 不同,这条不在卡片回调路径,而是由 daemon 重启时对持久化 adopt 目标的校验触发(session-manager.ts 的 restore),目标 pane 可能已在两次重启之间消失——所以它是 master 就存在的预存隐患(非 deepcoldy#632 引入), 但同根同源,随本 PR 一并收口。 改动: - validateTmuxAdoptTarget 的守卫从 `isNaN(panePid)` 收紧为 `!Number.isInteger(panePid) || panePid <= 0`,与本 PR 另一处守卫同源。 - 补回归测试:死目标返回空 pane_pid + exit 0 时返回 false,且断言全程不触碰 `ps`(即没落到 pid 0 的进程树遍历)。变异测试证明该用例有牙:还原旧守卫后 用例失败(expected true to be false,命中了 ps)。 验证(钉本地 pr632-fixed,stacked on ecd7c8c): - pnpm build 绿 - dist 实测:死目标 validateTmuxAdoptTarget 9ms 返回 false(旧代码 >20s 冻结) - session-discovery + session-adopt 隔离 71/71 Co-Authored-By: Riff <noreply@anthropic.com>
复审 + 代作者收口(申晗授权):两处 blocker 已修并验证,另收口一处同源预存隐患PR head 现为 ✅ 作者的
|
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 最终复审:✅ APPROVED
最终复审钉住 head bb1e7323bc30d37a4a3744785bfbd69d10eba252。此前 P1/P2 均已正确修复,追加的 validator 同源守卫也有效;本轮未发现新的阻塞问题。
1. 提交栈完整、未改写作者提交
对象关系已用 git 独立核对:
6dcca31b → 908bb94a → ecd7c8cf → bb1e7323
ecd7c8cf的 author/committer 均为原作者 Lvjinhong,parent 仍是首版908bb94a。bb1e7323以ecd7c8cf为唯一 parent,是纯 fast-forward 追加,没有 rebase/amend 作者提交。
2. 作者 ecd7c8cf:P1/P2 修复正确
代码现在让 tmux display 同时回显 canonical 地址与 pane PID,按最后一个空格解析,要求 canonical 地址严格等于卡片 target,并拒绝非正整数 PID。含空格 session 名仍能解析。
真实 tmux 独立复现:
死 target 原始回显:hex 3a 2e 20 0a(":. \n"),exit 0,stderr 空
快路径:9ms 返回 undefined(修复前 >45s)
仅长会话 pr632-final-prefix-X-* 存活时:
裸 tmux 查询短 target 仍模糊命中长会话
快路径查询短 target:undefined
快路径查询完整 canonical target:正常解析 claude-code/pid/cwd/尺寸
因此 P1 的 PID 0 遍历和 P2 的模糊错 pane 都在进入进程树前被拒绝;正常目标不受影响。三条回归测试覆盖了空 PID、canonical 不一致和 canonical 相等反向路径。
3. bb1e7323:validator 同源守卫正确,变异测试有牙
validateTmuxAdoptTarget 现在同样拒绝非正整数 pane PID。真实死 target 下独立实测 10ms 返回 false,不再从 PID 0 遍历进程树。
我实际做了变异验证:临时把守卫还原成旧的 isNaN(panePid),单跑新增 validator 回归测试立即失败,失败点明确显示调用记录中出现了 ps;恢复新守卫后同一测试通过。工作树随后恢复干净。
非阻塞残留:validator 仍保留 master 的模糊 target 语义;只有旧 PID 被兄弟 pane 内新 CLI 复用时才可能误判。它不是本 PR 引入,本提交也只承诺收口空 PID 冻结,因此不阻塞本 PR;可后续单独做 canonical 硬化。
4. 验证与影响面
pnpm build:通过。pnpm vitest run test/session-discovery.test.ts test/session-adopt.test.ts:71/71 通过。pnpm test:11018 passed、4 failed、5 skipped;4 个失败与修复前我在同环境跑出的基线逐条相同,位于fs-policy-bwrap/schedule-card-model/scheduler,不引用本 PR 模块。git diff --check:通过;最终工作树干净;测试 tmux session 已清理。- 影响范围:快路径和 validator 是 tmux 共用层,覆盖所有 CLI 的 tmux adopt/card/restore;
filterCliId、bmx 跳过、codex launcher 跟随未变。zellij、herdr 和 sandbox 分支未进入新增逻辑。
复审状态改为 APPROVED。本轮没有合并、没有重启 daemon;是否合并仍由申晗最终确认。
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>
问题
在
/adopt选择卡里点定某个会话后,card-handler的resolveAdoptTarget仍会调discoverAdoptableSessions()把本机所有 tmux pane 重扫一遍,再.find()挑出用户刚选的那一个。卡片 option 里其实已经带着 tmux 地址了。全量扫描要对每个 pane 走进程树,且树里每个节点都拉一次全量
ps,pane 一多就是数秒级同步阻塞。而这条路径是飞书卡片回调 ——
card.action.trigger只有 3s 预算,且与普通事件不同不会重推(见event-dispatcher.ts里CARD_ACTION_ACK_TIMEOUT_MS上方的注释)。更糟的是同步execSync会把 Node 事件循环整个冻住,连 2500ms 提前 ACK 的保险丝都发不出去 ——setTimeout被饿死,用户侧直接看到飞书弹的红色「目标回调服务超时未响应」(错误码 200341)。外层还套了 3 次重试,最坏是 4 遍全量扫描。线上日志的指纹:
handler exceeded 2500ms的告警时间戳比Adopt worker forked晚 9ms,而不是点击后 2500ms —— 定时器直到事件循环被放开才补跑。同一份阻塞大概率也是日志里[ws] reconnecting/no pong within 30s of last ping的来源。改动
discoverAdoptableSessions每 pane 的循环体原样抽成resolveAdoptableSessionForPane,让「扫全部 pane」和「只解析某一个 pane」共用同一份判定 —— 包括bmx-*跳过、filterCliId过滤、codex launcher 跟随。两条路径必须永远同构,否则快路径会绕开 CLI 过滤。discoverAdoptableSessionByTarget(tmuxTarget, filterCliId),只解析目标那一个 pane。resolveAdoptTarget先走快路径。判定谓词与全量路径完全一致,没命中就原样回落全量扫描 —— 只收窄候选集、不改判定逻辑,因此不改变任何既有结果。tmux display -t的模糊解析(CR 复审后加的防护)tmux display -t是模糊解析,且解析失败时不报错(exit 0、stderr 为空)。真机实测(tmux 3.6a):所以既不能靠 exit code 判死活(
Number('') === 0且isNaN(0) === false,会落到findCliProcess(0, ...)从 pid 0 遍历整棵进程树),也不能相信「拿到正数 pid」= 命中了请求的 pane(中间三种都返回真实正数 pid,只是属于别的 pane)。修法是让同一条 display 连 canonical 地址一起回显,再要求它与请求的 target 严格相等 —— 一次同时挡掉两者:
格式串与
discoverAdoptableSessions的list-panes -F完全一致,两边可直接字符串比较;按最后一个空格切分,兼容含空格的会话名。实测
本机 31 个 tmux pane / 29 个可 adopt 会话。这台机器同时跑着 20+ 个 live CLI 会话,负载波动大,所以给 5 轮而非单次采样:
机器空闲时是
4948ms → 145ms。倍数随负载浮动(11x~34x),量级稳定。顺带一提,全量扫描在高负载下能退化到 28.7 秒。死 / 歧义 target(走 canonical 校验直接返回
undefined,回落全量扫描 = master 行为):连跑 5 次死目标:83 / 100 / 107 / 118 / 125 ms,稳定。
影响范围评估
tmuxTarget(JSON.stringify会丢掉 undefined 键),且 herdr 需要excludeOwnedHerdrAdoptTargets的全局视图,selected.source !== 'herdr' && selected.tmuxTarget两道 guard 都会让它回落全量路径。该函数对非 herdr 条目恒返回true,所以快路径跳过它对 tmux 结果是 no-op。filterCliId原样透传。 卡片 option 是用户可控输入,快路径若丢掉这个过滤就等于允许把 bot 切到别的 CLI 实现。新增测试专门覆盖:一个 claude-code bot 解析不出跑 codex 的 pane。readProcessStartTime兜底,全部由既有 49 条discoverAdoptableSessions测试守住。tmux display,无平台相关分支;macOS 的ps/lsof兜底由session-discovery.smoke.test.ts覆盖。已知遗留(本 PR 不改)
validateTmuxAdoptTarget(session-discovery.ts:950)用的仍是不带 canonical 校验的tmux display -t,同样有前缀 / 索引模糊命中的性质 —— 这是 master 既有行为,非本 PR 引入。CR 描述的 P2 端到端链路(PID 复用 →validateAdoptTarget二次模糊命中)现在在源头断了(快路径返回undefined后回落全量扫描,而全量扫描只从list-panes拿活 pane),但该函数本身建议另开 issue 收掉,不在这个 perf PR 里扩大范围。测试
session-discovery49 → 59,session-adopt11,改动文件隔离跑 70/70 稳定全过。新增 10 条
discoverAdoptableSessionByTarget用例:tmux list-panes;不触碰其它 pane 的进程树bmx-*;遵守filterCliIdundefined,由调用方回落undefined,断言只发一次 tmux 查询、完全不调用ps/list-panesfoobar→foobarX 4242,真实正数 pid)→undefined,同样不碰进程树顺手修了
setupMocks的一个潜伏 bug:pane_pid分支原本用line.split(' ')[1]取 pid,会话名含空格时会取成会话名后半段 —— 正是生产代码专门留了回归测试的那个「按第一个空格切」的坑。改为按最后一个空格切。全量套件说明
这台机器 load average 常年 28~54(20+ 个 live CLI 会话),全量套件里那批派生进程 / 超时敏感的用例本身不稳:同一份代码连跑 4 次,失败集合每次都不一样(
codex-app-threads/hook-runner/workflow-c0-isolation/v3-goal-cli/whiteboard-cli/plugin-init/plugin-mcp-gateway/skill-injection-mode/vc-meeting-im-routing/worker-codex-app-missing-dependency轮流出现)。已逐个 grep 确认这些文件没有一个引用session-discovery或card-handler。与两位 reviewer 各自观测到的「同款失败在干净 master 上一样出现」一致。