Skip to content

perf(adopt): 卡片选完会话后只解析目标 pane,不再全量重扫 - #632

Merged
deepcoldy merged 3 commits into
deepcoldy:masterfrom
Lvjinhong:perf/adopt-single-pane-resolve
Jul 28, 2026
Merged

perf(adopt): 卡片选完会话后只解析目标 pane,不再全量重扫#632
deepcoldy merged 3 commits into
deepcoldy:masterfrom
Lvjinhong:perf/adopt-single-pane-resolve

Conversation

@Lvjinhong

@Lvjinhong Lvjinhong commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

更新:已按 CR 复审修掉 P1(死 target 冻结 >45s)和 P2(模糊命中错误 pane),head → ecd7c8cf,详见本条回复。下方数字已同步修正。

问题

/adopt 选择卡里点定某个会话后,card-handlerresolveAdoptTarget 仍会调 discoverAdoptableSessions() 把本机所有 tmux pane 重扫一遍,再 .find() 挑出用户刚选的那一个。卡片 option 里其实已经带着 tmux 地址了。

全量扫描要对每个 pane 走进程树,且树里每个节点都拉一次全量 ps,pane 一多就是数秒级同步阻塞。

而这条路径是飞书卡片回调 —— card.action.trigger 只有 3s 预算,且与普通事件不同不会重推(见 event-dispatcher.tsCARD_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 的来源。

改动

  1. discoverAdoptableSessions 每 pane 的循环体原样抽成 resolveAdoptableSessionForPane,让「扫全部 pane」和「只解析某一个 pane」共用同一份判定 —— 包括 bmx-* 跳过、filterCliId 过滤、codex launcher 跟随。两条路径必须永远同构,否则快路径会绕开 CLI 过滤。
  2. 新增 discoverAdoptableSessionByTarget(tmuxTarget, filterCliId),只解析目标那一个 pane。
  3. resolveAdoptTarget 先走快路径。判定谓词与全量路径完全一致,没命中就原样回落全量扫描 —— 只收窄候选集、不改判定逻辑,因此不改变任何既有结果。

tmux display -t 的模糊解析(CR 复审后加的防护)

tmux display -t 是模糊解析,且解析失败时不报错(exit 0、stderr 为空)。真机实测(tmux 3.6a):

请求 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    ← 前缀 + 省略索引

所以既不能靠 exit code 判死活(Number('') === 0isNaN(0) === false,会落到 findCliProcess(0, ...) 从 pid 0 遍历整棵进程树),也不能相信「拿到正数 pid」= 命中了请求的 pane(中间三种都返回真实正数 pid,只是属于别的 pane)。

修法是让同一条 display 连 canonical 地址一起回显,再要求它与请求的 target 严格相等 —— 一次同时挡掉两者:

`tmux display -t ${shellescape(tmuxTarget)} -p '#{session_name}:#{window_index}.#{pane_index} #{pane_pid}'`
...
if (canonicalTarget !== tmuxTarget) return undefined;
if (!Number.isInteger(panePid) || panePid <= 0) return undefined;

格式串与 discoverAdoptableSessionslist-panes -F 完全一致,两边可直接字符串比较;按最后一个空格切分,兼容含空格的会话名。

实测

本机 31 个 tmux pane / 29 个可 adopt 会话。这台机器同时跑着 20+ 个 live CLI 会话,负载波动大,所以给 5 轮而非单次采样:

全量扫描 5 次: 25028 / 11809 / 15030 / 17185 / 28692 ms   中位数 17185
单 pane  5 次:   259 /   623 /   525 /   273 /  1027 ms   中位数   525
中位数提速: 32.7x

机器空闲时是 4948ms → 145ms。倍数随负载浮动(11x~34x),量级稳定。顺带一提,全量扫描在高负载下能退化到 28.7 秒

死 / 歧义 target(走 canonical 校验直接返回 undefined,回落全量扫描 = master 行为):

   23ms  nonexist:0.0    → undefined      (加防护前 >45s 冻结)
   26ms  claude:99.0     → undefined
   25ms  claude:1.99     → undefined
   20ms  clau:1.3        → undefined
   16ms  bmx-fake:0.0    → undefined
  247ms  claude:1.3      → claude:1.3 / pid 45575   ← 真实目标不受影响

连跑 5 次死目标:83 / 100 / 107 / 118 / 125 ms,稳定。

影响范围评估

  • 只有 tmux 来源的 adopt 目标走快路径。 zellij 分支完全未动。herdr 目标的 option 里没有 tmuxTargetJSON.stringify 会丢掉 undefined 键),且 herdr 需要 excludeOwnedHerdrAdoptTargets 的全局视图,selected.source !== 'herdr' && selected.tmuxTarget 两道 guard 都会让它回落全量路径。该函数对非 herdr 条目恒返回 true,所以快路径跳过它对 tmux 结果是 no-op。
  • filterCliId 原样透传。 卡片 option 是用户可控输入,快路径若丢掉这个过滤就等于允许把 bot 切到别的 CLI 实现。新增测试专门覆盖:一个 claude-code bot 解析不出跑 codex 的 pane。
  • 其它 CLI 的逻辑随循环体整体搬迁,未做语义修改 —— codex launcher 跟随、coco/traex rollout 绑定、claude session meta、readProcessStartTime 兜底,全部由既有 49 条 discoverAdoptableSessions 测试守住。
  • 跨平台:新增代码只多一条 tmux display,无平台相关分支;macOS 的 ps/lsof 兜底由 session-discovery.smoke.test.ts 覆盖。
  • 未改任何卡片渲染,无 UI 变化,故无截图。

已知遗留(本 PR 不改)

validateTmuxAdoptTargetsession-discovery.ts:950)用的仍是不带 canonical 校验的 tmux display -t,同样有前缀 / 索引模糊命中的性质 —— 这是 master 既有行为,非本 PR 引入。CR 描述的 P2 端到端链路(PID 复用 → validateAdoptTarget 二次模糊命中)现在在源头断了(快路径返回 undefined 后回落全量扫描,而全量扫描只从 list-panes 拿活 pane),但该函数本身建议另开 issue 收掉,不在这个 perf PR 里扩大范围。

测试

session-discovery 49 → 59,session-adopt 11,改动文件隔离跑 70/70 稳定全过

新增 10 条 discoverAdoptableSessionByTarget 用例:

  • 与全量扫描中同一 pane 的条目逐字段一致(快路径的核心契约)
  • 不执行 tmux list-panes;不触碰其它 pane 的进程树
  • 与全量扫描一样跳过 bmx-*;遵守 filterCliId
  • pane 消失时返回 undefined,由调用方回落
  • 会话名含空格时同样能解析
  • 死目标「空 pid + 不抛异常」→ undefined,断言只发一次 tmux 查询、完全不调用 ps / list-panes
  • canonical 不符(foobarfoobarX 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-discoverycard-handler。与两位 reviewer 各自观测到的「同款失败在干净 master 上一样出现」一致。

用户在 /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),
  与本改动无关
@Lvjinhong
Lvjinhong requested a review from deepcoldy as a code owner July 28, 2026 05:29

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

首审结论:🔴 CHANGES_REQUESTED(1 个 P1 硬阻塞)

隔离审阅钉 head 908bb94a,base = merge-base = 当前 master 6dcca31b(零漂移)。抽取重构本身是字节忠实的、思路正确,但新增的快路径引入了一个比它要修的问题更严重的回归

🔴 P1(CONFIRMED,我已独立复现):死目标让 discoverAdoptableSessionByTarget 同步冻结 >45s

根因discoverAdoptableSessionByTargetsession-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('') === 0isNaN(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:727Promise.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 → 实测:死目标 10msundefined(回落全量扫描 = 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,每个 continuereturn 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 修正是孤立的setupMockspane_pid 分支 split(' ')[1]slice(lastIndexOf(' ')+1),匹配生产的 lastIndexOf 解析,setupMocks 仅本文件使用。

测试

  • 908bb94a pnpm build 绿;改动文件隔离跑 session-discovery 56/56 + session-adopt 11/11 = 67 全过
  • 全量套件 PR head 10 failed(scheduler2 / v3-distillation-runner6 / fs-policy-bwrap.e2e1 / schedule-card-model1)=干净 master 6dcca31b(我另 build + 定向跑这 4 文件)逐条同款 10 failed = 零回归;4 个失败文件均不引用改动模块(本机 env baseline)。

残留:作者修(守卫 + 死目标回归测试)→ @codex 复审 → 申晗拍板。未获授权前不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

复审交叉核对:补一条我首审误判的 P2(自我更正)

codex 独立复审确认了我首审的 P1(死目标 → findCliProcess(0,…) 从 pid 0 遍历全机 → 同步冻结,codex 实测 132.5s/次 × 4 次 retry、1822 节点,与我 >45s 的量级一致)。

但它同时指出:我首审把 H2/H3(接管到错误 pane)判成 REFUTED 是错的。复核后我确认自己判错了,在此更正。

🟠 P2(CONFIRMED,我已独立复现):tmux display -t 前缀模糊匹配 + verbatim 回填 → 可能接管错误会话

我首审的错误推理:我说「master 全量路径本就用 tmux display -t <target>getPaneDimensions),所以快路径没引入新的解析风险」。这个论证是错的 —— master 喂给 getPaneDimensions 的 target 来自 tmux list-panes(已经是 canonical + 活 pane 的地址),master 从不把「用户选的、可能已失效的原始 target」直接喂给 tmux display -t。而快路径正是这么做的。

问题tmux display -t前缀模糊匹配。我在本机 tmux 3.3a 实测:

# 只有更长的兄弟会话 foobarX 存活,用户选的 foobar 已死
$ tmux display -t 'foobar:0.0' -p '#{session_name}:… #{pane_pid}'
resolved=foobarX:0.0 pid=3151714   exit=0

于是 discoverAdoptableSessionByTarget('foobar:0.0') 拿到的是 foobarX 的 pid,但返回对象里 tmuxTarget原样回填成失效的 'foobar:0.0'resolveAdoptableSessionForPane 直接透传入参)。matchesSelectedadoptTargetKey = tmux:foobar:0.0:<cliPid>:只要新解析出的 cliPid 撞上卡片里的旧 cliPid(PID 复用),就 MATCH → 接管了 foobarX,却贴着 foobar 的标签

master 免疫:master 走 list-panes(canonical 名),死掉的 foobar 根本不在列表里,foobarX 的 key 是 tmux:foobarX:0.0:… ≠ 卡片的 tmux:foobar:0.0:… → 正确回落「目标已退出」。

关键:P1 的 panePid <= 0 守卫修不了这条 —— 模糊匹配返回的是正数 pid(foobarX 真实存在),守卫放行。这是同根异症的两个缺陷。

建议 H2/H3 硬化(与 P1 同一 patch):让 tmux display 顺带回显解析后的地址,与请求的 target 比对,不一致就 return undefined 回落全量:

const out = execSync(
  `tmux display -t ${shellescape(tmuxTarget)} -p '#{session_name}:#{window_index}.#{pane_index} #{pane_pid}'`, 
).trim();
const [resolvedAddr, pidStr] = [out.slice(0, out.lastIndexOf(' ')), out.slice(out.lastIndexOf(' ')+1)];
const panePid = Number(pidStr);
if (!out || resolvedAddr !== tmuxTarget || !Number.isInteger(panePid) || panePid <= 0) return undefined;

这样死目标(空串)、模糊替换(地址不符)、非正 pid 三种都挡掉,回落到用 canonical 名的全量扫描。

严重度

  • P1(H6)= 硬阻塞:分钟级 daemon 全局冻结、触发条件普通(卡片放一会儿目标就死)、master 免疫。
  • P2(H2/H3)= 建议同 patch 修:会接管错误会话,但受 PID 复用门控(概率低、命中时影响高)。同根因,不单独构成合码阻塞,但既然要动这个函数就该一起收。

更正后的首审结论仍是 🔴 CHANGES_REQUESTED

抽取字节忠实(H1)、filterCliId 保留(H4)、herdr 去重跳过对 tmux 是 no-op(H5)三条维持 REFUTED(codex 独立复核一致)。零回归结论不变。残留:作者修(P1 守卫 + P2 地址核对 + 死目标/模糊目标两条回归测试)→ 申晗拍板。

@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 复审结论:🔴 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('') === 0isNaN(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 处理。
@Lvjinhong

Copy link
Copy Markdown
Contributor Author

感谢两位的复审,P1 / P2 都成立,已修,head → ecd7c8cf

我在真机(tmux 3.6a)逐条复现了,实际范围比报告的还广 —— 不止会话名前缀匹配。tmux display -t 在索引不存在时也会静默回落到活动 window / 活动 pane:

请求 nonexist:0.0  → exit 0,地址回显 ':.'、pane_pid 为空串
请求 claude:99.0   → exit 0,解析到 claude:2.3   ← window 索引不存在,回落活动 window
请求 claude:1.99   → exit 0,解析到 claude:1.1   ← pane 索引不存在,回落活动 pane
请求 clau:1.3      → exit 0,解析到 claude:1.3   ← 会话名前缀
请求 watc          → exit 0,解析到 watch:1.1    ← 前缀 + 省略索引

也就是说「只补 panePid > 0」确实不够 —— 中间三种情况都会返回真实正数 pid,只是属于别的 pane。所以我按 @codex 的建议走了 canonical 回显方案,它能一次同时盖掉 P1 和 P2:.nonexist:0.0claude:2.3claude:99.0,一个相等性判断全挡掉。

修法

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;

格式串与 discoverAdoptableSessionslist-panes -F 完全一致,所以 canonical 地址和卡片里存的 tmuxTarget 可以直接字符串比较,不需要额外归一化。

真机验证(修复后)

   23ms  nonexist:0.0    → undefined      (原 >45s 冻结)
   26ms  claude:99.0     → undefined
   25ms  claude:1.99     → undefined
   20ms  clau:1.3        → undefined
   20ms  watc            → undefined
   16ms  bmx-fake:0.0    → undefined
  247ms  claude:1.3      → claude:1.3 / pid 45575   ← 真实目标不受影响

死目标连跑 5 次:83 / 118 / 100 / 107 / 125 ms,稳定。

新增 3 条回归测试

  • 死目标「空 pid + 不抛异常」:断言返回 undefined,且只发一次 tmux 查询、完全不调用 ps / list-panes。正如 review 指出的,原有 gone:9.9 用例 mock 的是抛异常分支,覆盖不到这条真实路径。
  • canonical 不符:mock tmux displayfoobar:0.0foobarX:0.0 4242(真实正数 pid),断言返回 undefined 且不碰进程树。
  • 反向断言:地址严格相等时正常继续解析,含空格会话名(AD 智投星核心指标:0.0)也能对上 —— 防止修成「无脑返回 undefined」。

session-discovery 56 → 59,session-adopt 11,隔离跑 70/70 全过

收益数字修正

上一版 PR 描述里的 4948ms → 145ms / 34x 是单次采样,不够诚实。重测了 5 轮(这台机器同时跑着 20+ 个 CLI 会话,负载波动很大):

全量扫描 5 次: 25028 / 11809 / 15030 / 17185 / 28692 ms   中位数 17185
单 pane  5 次:   259 /   623 /   525 /   273 /  1027 ms   中位数   525

中位数提速 32.7x;机器空闲时是 4948ms → 145ms。倍数随负载浮动,但量级稳定。顺带一提,全量扫描在高负载下能退化到 28.7 秒,比原始 5.4s 的基线还严重得多。

已知遗留(本 PR 不改)

validateTmuxAdoptTargetsession-discovery.ts:950)用的仍是不带 canonical 校验的 tmux display -t,同样有前缀 / 索引模糊命中的性质 —— 这是 master 上的既有行为,不是本 PR 引入。

@codex 描述的 P2 端到端链路(PID 复用 → validateAdoptTarget 二次模糊命中并验证通过)现在在源头就断了:快路径返回 undefined 后回落全量扫描,而全量扫描只从 list-panes 拿活 pane,不会合成不存在的地址。但 validateTmuxAdoptTarget 本身作为公开函数仍有这个性质,是否单开一个 issue 收掉,听你们的 —— 我倾向于不在这个 perf PR 里扩大范围。

全量测试说明

这台机器 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-discoverycard-handler。改动文件隔离跑 70/70 稳定全过。这与两位 reviewer 各自观测到的「同款失败在干净 master 上一样出现」是一致的。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@Lvjinhong
Lvjinhong requested a review from deepcoldy July 28, 2026 08:52
复审 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>
@deepcoldy

Copy link
Copy Markdown
Owner

复审 + 代作者收口(申晗授权):两处 blocker 已修并验证,另收口一处同源预存隐患

PR head 现为 bb1e7323(作者修复 ecd7c8cf + 我追加的同源预存修复 bb1e7323,后者直接 stack 在作者提交上、fast-forward,未改写作者任何提交)。

✅ 作者的 ecd7c8cf 已正确修掉我首审 + codex 复审提的两条

改法与我们建议一致:tmux display 连 canonical 地址一起回显,session_name:window.pane 与请求 target 严格相等,再叠加 panePid <= 0 守卫。我钉 ecd7c8cf build 后在有真实 tmux server 的机器上实测:

  • P1 死目标冻结 → 已修:discoverAdoptableSessionByTarget('nonexist:0.0','claude-code') 12ms 返回 undefined(原 >45s 同步冻结)。
  • P2 前缀模糊命中错误 pane → 已修:我构造真实前缀冲突(pr632probe<pid>X 存活、短目标 pr632probe<pid> 已死),裸 tmux 确实把短目标模糊解析到长会话的 pid,但快路径因 canonical !== requested 正确返回 undefined
  • 新增 3 条回归测试(死目标空串 exit0 / 模糊命中拒绝 / canonical 相等反向断言)覆盖到位,且断言不触碰 ps/list-panes

🟠 追加修复(bb1e7323):姊妹函数 validateTmuxAdoptTarget 的同源预存冻结

复审时发现同一 bug 类还残留在 validateTmuxAdoptTarget(session-discovery.ts)——它是这个 PR 之前 master 就有的(与 master 逐字节相同,非 #632 引入),但同根同源,经申晗确认随本 PR 一并收口:

  • 根因:同样 Number('') === 0 / isNaN(0) === false,旧守卫挡不住 → hasCliProcess(0, expectedPid, 6) 从 pid 0 BFS 整棵进程树、每节点 fork 全量 ps。实测 dead target >20s 同步冻结;且一旦 expectedPid 恰好还活在机器任意位置就误报 alive
  • 触发路径:不在卡片回调,而是 daemon 重启时对持久化 adopt 目标的校验session-manager.ts 的 restore,1250 行)—— 目标 pane 可能已在两次重启间消失。
  • 改法:守卫从 isNaN(panePid) 收紧为 !Number.isInteger(panePid) || panePid <= 0,与 discoverAdoptableSessionByTarget 同源。这里按 pid 校验、无需 canonical-echo(模糊命中的错误 pane 自然不含 expectedPid,除非 pid 复用又恰好 reparent 到兄弟会话——比 P2 更窄的 pid-gated 边角,已在注释里记录为可接受)。
  • 验证:dist 实测 dead target 9ms 返回 false(原 >20s);补回归测试「死目标返 false 且全程不碰 ps」;变异测试证明该用例有牙——还原旧守卫后用例失败(expected true to be false,命中了 ps)。

测试

  • pnpm build 绿。
  • 改动文件隔离:session-discovery + session-adopt 71/71(作者 3 条 + 我 1 条回归全过)。
  • 全量套件:4 个失败文件(scheduler2 / v3-distillation-runner6 / fs-policy-bwrap.e2e1 / schedule-card-model1)= 干净 master 6dcca31b 逐条同款 10 failed = 零回归,均为本机 env-baseline,不引用改动模块。

未合码。交 @codex 独立复审(复核 ecd7c8cf 两修 + bb1e7323 同源修 + 变异测试有牙)。@申晗 拍板后再合。

@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 最终复审:✅ APPROVED

最终复审钉住 head bb1e7323bc30d37a4a3744785bfbd69d10eba252。此前 P1/P2 均已正确修复,追加的 validator 同源守卫也有效;本轮未发现新的阻塞问题。

1. 提交栈完整、未改写作者提交

对象关系已用 git 独立核对:

6dcca31b → 908bb94a → ecd7c8cf → bb1e7323
  • ecd7c8cf 的 author/committer 均为原作者 Lvjinhong,parent 仍是首版 908bb94a
  • bb1e7323ecd7c8cf 为唯一 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;是否合并仍由申晗最终确认。

@deepcoldy
deepcoldy merged commit 47dce8b into deepcoldy:master Jul 28, 2026
deepcoldy added a commit that referenced this pull request Jul 28, 2026
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>
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