fix(adopt): tmux 接管场景修复真实 CLI PID 解析 + worker 重启保住 bridge(替代 #293) - #608
Conversation
## 问题 用 tmux `/adopt` 接管一个「被 launcher 包一层」(node/ttadk/aiden 等)启动的 Claude Code 会话时,CLI 的回复无法返回飞书。 ## 根因链(已在 master 上复现) 1. 进程树 `tmux → node <wrapper> → claude(真实)`;真实 claude 的 comm=claude, 但 wrapper 的 argv 里带 "claude" 字面量。 2. `discoverAdoptableSessions` 的 findCliProcess 先按 argv 命中 **wrapper 层的 pid**(matchedByComm=false)。 3. 原代码只对 `cliId==='codex'` 做「真实子 pid 解析」,claude-code 不做 → 记录 wrapper pid。 4. `readClaudeSessionMeta(wrapperPid)` 读不到(session JSON 按真实 claude 子进程 pid 存)→ sessionId=undefined。 5. daemon 侧 bridgeJsonlPath 只在有 sessionId 时算得出;worker 侧 claude 分支 **只有 bridgeJsonlPath 才起 transcript bridge**(不像 codex/traex/cursor 有 pid 兜底)→ bridge 不启动 → 回复回不来。 ## 修复 - `session-discovery.ts`:把「launcher 包装下解析真实 CLI 子 pid」从 codex-only 放开到所有 argv-matched 的 CLI(复用现成的 matchedByComm 标志)。codex 行为 完全不变(两者都要求 !matchedByComm);matchedByComm=true 保持 match.pid 不变; 找不到子进程时回落 match.pid。 - `worker-pool.ts`:forkAdoptWorker 里 claude 的 cwd 兜底从 herdr-only 放开到所有 adopt 来源(tmux 也走),作为双保险。**未改** findUniqueClaudeSessionByCwd 的 歧义返回语义(保持 return undefined,原 PR293 被 reviewer 拦下的红测试仍绿)。 ## 影响面 - 跨 CLI:仅影响 /adopt 发现路径。codex 路径字节级不变(已过 codex-coco-pid smoke);其它 20+ CLI 中只有「comm 落在 COMM_ARGV_LAUNCHERS、靠 argv 命中」的 才新走子 pid 解析,正是需要修的场景。 - 跨后端:tmux 为主;herdr 已有同款兜底,本次让 tmux 对齐。 - 跨平台:findLaunchedCliPid / readComm 已是 Linux(/proc)+macOS(ps) 双实现。 ## 测试 - 新增真实进程冒烟测试 test/adopt-tmux-claude-wrapper-repro.smoke.test.ts: WRAPPED(复现 bug)+ DIRECT(回归保护)。去掉修复→WRAPPED 失败、DIRECT 通过; 加上修复→两者都过。 - pnpm build 通过(tsc 干净)。 - 受影响路径全绿:adopt/discovery/session/daemon 共 165 tests passed。 Co-Authored-By: Claude <noreply@anthropic.com>
## 问题(PR#293 issue #3,master 上真实存在) adopt 会话的 bridge worker 退出后(CLI 崩溃,或「adopted session ended」kill 路径),daemon 的 worker-null 重启分支(handleThreadReply / handleDocComment) 无条件走 forkWorker → 起一个全新的 botmux 托管 bmx-* CLI、丢掉 observe/bridge 语义,把 `<user_message>` 裹的 prompt 怼进新 CLI 而非用户原本的外部 pane。 idle-worker-sweeper 里有注释明确记了这个坑(靠「永不 suspend adopt」绕过,但 崩溃/自然退出路径没兜住)。 ## 修复 - `worker-pool.ts`:forkAdoptWorker 接受 `{ prompt?, turnId? }` 并透传进 init (原来硬编码 prompt: '')。init handler 会把 prompt 入队 pendingMessages, adopt 的 idle 检测(setupAdoptIdleDetection → markPromptReady)在观察到 pane 空闲时冲刷到 pane —— 与 live-worker follow-up 完全同路。 - `daemon.ts`:handleThreadReply / handleDocComment 的 worker-null 分支按 `ds.adoptedFrom` 分流到 forkAdoptWorker。内容已由 buildReforkCliInput / buildDocCommentTurnInput(mode:'refork') → buildBridgeInputContent 做成 bridge raw 格式,不会有 XML 包裹漏进用户未注入的外部 CLI。 ## 影响面 - 仅改 daemon 消息路由的 worker-null 分支 + forkAdoptWorker 签名;live-worker 分支(worker 存活时)本就正确走 sendWorkerInput bridge 路径,不受影响。 - 对已退出的 adopt target 重启:forkAdoptWorker 不预校验,靠 worker observe backend 的 onExit → claude_exit 优雅收尾(与 live 路径一致,TmuxPipeBackend spawn 抛错也在 worker spawnCli 的 try/catch 内,不会崩 daemon)。 - restore 路径(restoreActiveSessions)仍走 forkAdoptWorker({restoredFromMetadata}), prompt 缺省为 '',行为不变。 ## 测试 - session-lifecycle-start.test.ts 新增 issue #3 两测: (1) 带 {prompt,turnId} 时 init 正确透传(去掉透传→该测失败,判别力已验证); (2) restore 路径缺省 prompt='' / turnId undefined。 - pnpm build 通过;受影响路径全绿:adopt/discovery/session/daemon/lifecycle 共 248 tests passed。 Co-Authored-By: Claude <noreply@anthropic.com>
| const deadline = Date.now() + deadlineMs; | ||
| while (Date.now() < deadline) { | ||
| if (existsSync(pidFile)) { | ||
| const raw = spawnSync('cat', [pidFile], { encoding: 'utf-8' }).stdout.trim(); |
首审(Claude)— 无阻塞可合方向,但发现分流不完整需作者确认在最新 master 合并态上审(merge-tree 0 冲突;master 在 PR base 后动过 daemon.ts/worker-pool.ts 但不撞本 PR 改动行)。 白话讲解:这个 PR 在修什么commit 1(discovery 真实子 pid 解析):用户的 claude 被一层 launcher 包着( commit 2(worker-null 分支 adopt 分流):adopt 会话的 bridge worker 退出后(崩溃/kill),新消息会无条件 验证记录(实际跑过)
🟡 P2(主关注点):commit 2 的 adopt 分流不完整daemon 里有 3 处同构的「worker-null → 可达性(逐环确认,另有独立 agent 交叉验证一致):
建议:prewarm 的 worker-null 分支同样分流: if (ds.adoptedFrom) forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId });
else forkWorker(ds, wrappedInput, ds.hasHistory);🟡 P2(次要,触发更窄):
|
|
To use Codex here, create a Codex account and connect to github. |
Codex 复审:两处漏分流均成立,当前建议阻塞合入我按当前 PR head 🔴 1.
|
双审收敛(Claude 首审 + codex 复审)— 给作者的完整修复清单codex 复审确认我首审提的两处漏改均成立,并额外发现第三处(warmup 的 live-worker 路径)。我已独立复核第三处成立(合并态、PR head ✅ 已修正确(无新问题)
🟡 需补:同一 bug 模式的三个遗漏入口1. if (ds.adoptedFrom) forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId });
else forkWorker(ds, wrappedInput, ds.hasHistory);2. 3.(codex 新发现)warmup 的 live-worker 路径 — 我已复核成立
建议:把 warmup 的 live 与 refork 两路都 adopt-aware(live 路在 🟢 P3:
|
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>
背景
替代并关闭 #293。#293 把 5 个问题打包在一个 WIP 分支,且被 reviewer 拦下若干点(改了
findUniqueClaudeSessionByCwd歧义返回致红测试、文档评论双通道噪声、硬编码 ttadk env)。本 PR 用干净实现重做其中仍在 master 上真实存在的两条,规避原 reviewer 的所有拦点,并补上原 PR 缺失的测试。原 #293 的 5 条经逐一在当前 master 核实:
responseType:'text'):master 的callTenant已不再设responseType,根因已自然消失,无需处理。adoptedFrom过滤。均未观测到独立可复现故障,暂不纳入本 PR。改了什么
commit 1 —
fix(adopt): tmux 接管 launcher 包装的 claude 时解析真实 CLI 子 pid(核心 #1)现象:用 tmux
/adopt接管一个「被 launcher 包一层」(node/ttadk/aiden 等)启动的 Claude Code 会话时,CLI 回复无法返回飞书。根因链(已在 master 复现):
tmux → node <wrapper> → claude(真实);真实 claudecomm=claude,wrapper 的 argv 里带 "claude" 字面量。discoverAdoptableSessions的findCliProcess先按 argv 命中 wrapper 层 pid(matchedByComm=false)。cliId==='codex'解析真实子 pid,claude-code 不做 → 记 wrapper pid。readClaudeSessionMeta(wrapperPid)读不到(session JSON 按真实 claude 子进程 pid 存)→sessionId=undefined。bridgeJsonlPath只在有 sessionId 时算得出;worker 侧 claude adopt 分支只有bridgeJsonlPath才起 transcript bridge(不像 codex/traex/cursor 有 pid 兜底)→ bridge 不启动 → 回复回不来。修复:
session-discovery.ts:真实子 pid 解析从 codex-only 放开到所有!matchedByComm(argv-matched launcher)的 CLI,复用现成matchedByComm标志。codex 行为字节级不变;matchedByComm=true保持match.pid;找不到子进程回落match.pid。worker-pool.ts:forkAdoptWorker里 claude 的findUniqueClaudeSessionByCwdcwd 兜底从 herdr-only 放开到所有 adopt 来源(tmux 也走)。未改findUniqueClaudeSessionByCwd的歧义返回语义(保持return undefined,原 feat(adopt): tmux adopt 模式下 bridge 初始化修复 + 文档评论重复回复修复 #293 红测试仍绿)。commit 2 —
fix(adopt): worker 退出后按 adoptedFrom 走 forkAdoptWorker 重启,不丢 bridge 语义(#3)现象:adopt 会话的 bridge worker 退出后(CLI 崩溃,或「adopted session ended」kill 路径),daemon 的 worker-null 重启分支(
handleThreadReply/handleDocComment)无条件走forkWorker→ 起全新 bmx-* CLI、丢 observe/bridge 语义、把<user_message>裹的 prompt 怼进新 CLI 而非用户原本的外部 pane。idle-worker-sweeper.ts有注释记录此坑(靠「永不 suspend adopt」绕过,但崩溃/退出路径没兜住)。修复:
worker-pool.ts:forkAdoptWorker接受{ prompt?, turnId? }并透传进 init(原硬编码prompt: '')。init handler 把 prompt 入队pendingMessages,adopt idle 检测(setupAdoptIdleDetection → markPromptReady)在观察到 pane 空闲时冲刷到 pane —— 与 live-worker follow-up 完全同路。daemon.ts:两处 worker-null 分支按ds.adoptedFrom分流到forkAdoptWorker。内容已由buildReforkCliInput/buildDocCommentTurnInput(mode:'refork')→buildBridgeInputContent做成 bridge raw 格式,不会漏 XML 进用户 CLI。影响面
/adopt发现路径。codex 路径字节级不变(已过codex-coco-pidsmoke);其它 20+ CLI 中只有「comm 落在COMM_ARGV_LAUNCHERS、靠 argv 命中」的才新走子 pid 解析,正是需要修的场景。originalCliPid校验,不受影响。findLaunchedCliPid/readComm已是 Linux(/proc) + macOS(ps) 双实现。sendWorkerInputbridge 路径。对已退出 target 重启靠 worker observe backend 的onExit → claude_exit优雅收尾(TmuxPipeBackend.spawn抛错也在 workerspawnCli的 try/catch 内,不崩 daemon)。restore 路径prompt缺省'',行为不变。测试
test/adopt-tmux-claude-wrapper-repro.smoke.test.ts(真实进程冒烟,起真 tmux pane + node→假 claude 子进程 + 按真实 pid 落 session JSON,调生产discoverAdoptableSessions):test/session-lifecycle-start.test.ts中 issue fix(cli): 修复 claude-code 在 root 账户下启动失败 #3 两测:init 正确透传{prompt,turnId}(去掉透传→失败);restore 路径缺省prompt=''。pnpm build通过(tsc 干净)。/etcsandbox 视图、以及依赖 dist 的 CLI 测试),把本 PR 改动 stash 掉后在干净 master 上一模一样失败,与本次无关。🤖 Generated with Claude Code