Skip to content

feat(backend): 支持 ZMX 持久会话后端 - #458

Open
LucasIcarus wants to merge 25 commits into
deepcoldy:masterfrom
LucasIcarus:feat/support_zmx
Open

feat(backend): 支持 ZMX 持久会话后端#458
LucasIcarus wants to merge 25 commits into
deepcoldy:masterfrom
LucasIcarus:feat/support_zmx

Conversation

@LucasIcarus

@LucasIcarus LucasIcarus commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

背景

在现有 PTY / Tmux / Herdr / Zellij 之外,增加 ZMX 作为显式 opt-in 的轻量持久会话后端。目标是让 CLI 在 botmux daemon 重启后继续存活,同时保留用户从本机 zmx attach 进入同一会话的能力。

本实现不再通过 node-pty 常驻一个假的 leader client。ZMX 的 tail 只承担低延迟变化/存活信号,history 是 botmux 唯一权威的纯文本屏幕,输入通过修复后的 send 注入;因此飞书侧行为尽量贴近 tmux 的持久会话生命周期,但不伪装成 raw ANSI 终端镜像。

上游前置

本集成要求 zmx >= 0.7.0。该下限对应上游 issue #201 的修复 —— commit 8ba312d7 fix(send): preserve client leadership已随 v0.7.0(2026-07-23)正式发布zmx send 改用独立的 .Send IPC tag,只把输入排进 PTY 队列,不抢占 attach leader,也不改写终端尺寸。

早期版本的本 PR 曾把「0.7.1 是首个包含该修复的版本」写成前置假设并据此设门禁。实际上上游从未发布 0.7.1,修复随 0.7.0 落地(PR #202 本身是 closed-not-merged,维护者用自己的 commit 落地了同一设计)。该假设已在本轮修正,否则门禁会拒绝所有已发布版本。

ZMX 仍是显式选择:botmux 不自动安装、不因 PATH 中存在 zmx 就自动选用,版本/控制面不满足时 fail closed,也不会静默回落 PTY。0.6 的既有逐会话 daemon 需要升级后手动关闭并重建;本 PR 不做自动冷迁移,单独重启 botmux 不能替换旧 daemon —— ZMX 的 IPC Tag 枚举是 non-exhaustive 的,0.6 daemon 收到新的 .Send tag 会直接丢弃,而 zmx send 仍退出 0,表现为命令成功但输入从未送达。

改动

  • 新增 ZmxBackend 及完整生命周期:确定性 bmx-* 会话名、私有启动握手、创建/恢复、探活、挂起、转移、关闭和 ownership 校验。
  • 建立 tail + history + send 传输:
    • tail 的字节不进入 worker,避开上游 ANSI 过滤丢失 UTF-8(中文/emoji)的缺陷;
    • history 采用异步 single-flight、dirty latch、热/冷错峰轮询和 idle 定稿屏障,重连后按权威快照重建;
    • CLI 退出后保留短暂 history 采集窗口,覆盖立即退出前的中文尾段输出;
    • send 以 1 KiB 有序分片,单次输入在写入前限制为 64 KiB;协议没有 ACK 时不对不确定结果盲目重试,括号粘贴部分失败会尽力闭合。
  • 提交键、C-c / Escape 等状态关键输入走严格失败通道;中断发送失败会重试一次并给用户可见通知,避免 UI 显示“已停止”而 CLI 仍在运行。
  • 接入 bot 配置、环境变量、setup、Dashboard、CLI list/attach、本机打开、飞书卡片、恢复/冷启动和 session store;ZMX 不可用时返回可操作错误。
  • 对关闭、切仓、恢复冲突、worker 世代消息增加 fail-closed/隔离处理,拒绝把 ownership 不确定的 ZMX 会话误杀或静默遗留为孤儿。
  • 补齐中英文 README 与文档,明确安装前置、升级流程、运行模型、终端能力和排错方式。
  • CI 在原有 build 之外执行 canonical pnpm test;默认 unit project 不运行 *.e2e.ts,普通贡献者和 CI 不需要安装 zmx。

能力边界

能力 ZMX 后端
daemon 重启后保持 CLI、恢复 worker 支持,生命周期语义对齐持久后端
飞书流式卡片/纯文本屏幕 支持;最终一致,以 zmx history 为准
中文、emoji、控制键和多 chunk 输入 支持;真实 E2E 覆盖
本机进入同一会话 支持 botmux list / zmx attach;macOS 可走“本机 CLI 直开”
botmux Web TUI / raw ANSI / OSC / resize 不支持;使用本机 attach 获取完整终端
Adopt 外部会话 不支持
sandbox: true / 全局 sandbox / macOS 有效 read isolation 不支持,组合出现时 fail closed
codex-app / mira / mir 隐藏 OSC final/thread runner 不支持,启动时 fail closed
无损 transcript / raw PTY replay 不保证;ZMX history 是有界纯文本当前状态

上游 send 当前没有 PTY 级 ACK/backpressure,history 也受 ZMX/ghostty scrollback 上限约束;这些限制在实现和文档中都显式保留,没有用“成功返回”伪装成强投递保证。

对外只读查询与安全边界

同步补齐 Dashboard 对外会话观测字段:

  • backendType:实际 spawn 时记录的后端;
  • backendSessionName:受管持久后端的确定性会话名;
  • titleUpdatedAt / titleSource:标题更新时间与信息性来源标签。

这些字段会随 GET /api/sessions 和对外 GET /events SSE 的 session row/update 输出,均为可选字段,兼容旧会话/旧 daemon。backendSessionName 只用于定位,不构成 socket/进程存活证明;titleSource 也不是可信审计身份。

publicReadOnly 开启时,上述只读元数据可无 token 获取;全部写操作、非白名单 GET、原始 PTY 和诊断日志仍要求当前 Dashboard token。只读白名单保持 fail closed,新增 GET 不会自动暴露;跨 bot 文件回退只用于读取,变更端点只接受当前 daemon 实际拥有的 session。

影响面

  • 跨后端:公共 worker 把“已 ready”与“存在 Web Terminal 端口”解耦,并增加 screen settle/世代守卫;ZMX 专属能力通过 backend capability gate 生效,PTY/Tmux/Herdr/Zellij 的 Web TUI、resize 和现有生命周期不变。持久后端不可用仍统一 fail closed。
  • 跨 CLI:标准终端文本型 CLI 共用现有 adapter,不引入对某一个 CLI 的硬编码;仅 ZMX 路径增加严格输入包装。依赖隐藏 OSC 完成事件的 runner 明确拒绝,不让它们静默丢终态。
  • 会话类型:普通话题/群会话及恢复、转移、挂起、关闭、切仓路径均接入;adopt 明确不支持。workflow 若选择 ZMX 只获得纯文本最终一致屏幕,不承诺 raw PTY replay。
  • 跨平台:跟随 ZMX 支持范围,仅 macOS / Linux;私有 bootstrap 使用 POSIX shell。Windows 不提供 ZMX 后端能力,探测失败时拒绝启动。
  • 兼容性:默认仍为 tmux;不配置 backendType: "zmx" / BACKEND_TYPE=zmx 的部署行为不变。新增 session/query 字段全部可选,旧记录可继续读取。

本分支已 rebase 到 upstream/master@d21d159f。该轮变基跨越 32 个上游提交,其中与本 PR 重叠最深的是 upstream 对持久后端寻址的重构(persistentSessionNamepersistentBackendTarget / probePersistentBackendTarget / killPersistentBackendTarget,以支持 Herdr 在共享 host 内持有 agent)。冲突解法与其中几个变基才会出现的集成缺陷,见下方「变基集成」。

验证

  • pnpm build:通过(含 public-domain audit、TypeScript、Dashboard bundle、dist audit)。

  • pnpm test674 files / 10596 passed / 23 skipped / 0 failed

  • 真实 ZMX E2E:首次可对着正式发布版跑。此前只能自建 PR [codex] add HD2D office dashboard tab #202 分支 + 用 wrapper 伪造版本号,现在直接用官方 0.7.0:

    ZMX_DIR=<临时目录> BOTMUX_E2E_REQUIRE_ZMX=1 \
      pnpm vitest run --project e2e test/zmx-backend.e2e.ts

    结果:1 file / 4 tests passed(10.1–10.4s,三轮复验一致),结束后 zmx list 无残留 bmx-e2e-* 会话。覆盖 fresh/reattach、UTF-8/emoji、纯中文无 tail 信号安全轮询、tail 崩溃恢复、C-c、多 chunk 原始输入、立即退出尾段采集和 kill 无孤儿。

  • 门禁实测:用本机真实 zmx 0.7.0 跑 probeZmxFunctional() —— 修正前被拒(门禁要求 >= 0.7.1),修正后 ok=true、version 规范化为 zmx 0.7.0

  • git diff --check:通过。

默认 pnpm test 只跑 mock/纯函数 unit,不探测或启动本机 zmx;真实 zmx 仅在显式 E2E 命令中参与,BOTMUX_E2E_REQUIRE_ZMX=1 会让缺失/版本不符直接失败而不是静默跳过。

变基集成

上游的持久后端寻址重构与本 PR 的 ZMX 支持在同一批函数上重叠,机械合并会引入几个静默缺陷,已逐个修掉并补测:

  • killPersistentBackendTarget 不传 sessionId:ZMX 的销毁是按 botmux 标签做身份校验的,killPersistentSession 对 zmx 显式 throw 拒绝 name-only kill。upstream 的新 target helper 不传 sessionId,会让每一次 zmx kill 都抛错。已给 helper 加上 sessionId 并在四个调用点透传。
  • selectSessionBackend 的 zmx 分支缺 isReattach:upstream 改为从 selectedBackend.isReattach 推导 willReattachPersistent,而 zmx 是唯一没在返回对象上带该字段的后端 —— 合并后每个 zmx 会话都会被当成全新冷启。同时补上 persistentSessionName / persistentBackendTarget
  • PersistentBackendType 丢掉 'zmx':git 把该行静默并成了 upstream 的收紧版(Extract<…, 'tmux'|'herdr'|'zellij'>),与同文件 isSuspendableBackendType 自相矛盾却没有冲突标记。
  • 恢复路径的批量探测 vs agent 级 target:本 PR 的批量快照(一次 zmx list 分类所有会话,避免重启时 O(N²))按 session 名寻址,无法表达 Herdr 的 agent 级 target。合并后按目标类型分流:agent 级走 probePersistentBackendTarget,其余仍走批量快照。
  • 后端重选顺序:upstream 用可重入的 selectBackend() thunk 在每个 gate 杀掉陈旧 pane 后重选,本 PR 原本是把选择整体后移。两者解决同一问题,已收敛到 upstream 写法,并把冷启顺序断言改为校验新形状的不变量(任何杀 pane 的 gate 之后必须重选并刷新 isReattach)。

本轮自检

在变基之后又做了一轮针对性自检(多路并行审计 + 对抗性验证,37 条原始发现 → 34 条被驳回、3 条成立),成立项已修:

  • 版本门禁不可满足(见「上游前置」)。
  • 探测失败归因错误probeZmxVersion 裸 catch + 丢弃 stderr,把任何失败都说成「zmx 二进制不在 PATH 上」。但 zmx version 会触碰 socket dir,ZMX_DIR / XDG_RUNTIME_DIR 只读或不可创建时非零退出(实测 error: ReadOnlyFileSystem)—— daemon 跑在 Linux、systemd --user 未开 lingering 时正好命中,用户会照着提示反复重装 zmx。本仓库 ensure-tmux.tschildFailureReason 早已固化「只有 ENOENT 才算不存在」的约定,zmx 探针绕过了它,现按同一套分支补齐并附上生效的 socket dir 来源。
  • tail 连接判定假阴性waitForTailClientzmx listclients= 差值判断自己的 tail 是否连上,但该计数是聚合值 —— 用户的 zmx attach(本集成正在文档里主推)与 botmux 自己每次 list/get/history/send 的瞬时连接完全等价计数(实测起 1 个 tail → clients=1,2 个 → 2,杀掉 → 0)。用户在这 3 秒窗口内 detach 会让净增量为 0,一个健康会话直接恢复失败。改为 clients >= 1,把错误方向反过来:残留的假阳性代价很小且自愈(tail 只是唤醒信号,权威屏幕来自不依赖 tail 的 history 轮询,且 scheduleTailRecovery 会重连)。新增回归用例已确认在旧差值语义下失败、改后通过。

另外补文档:受管会话固定跑在 120×24createFreshSession 用非 TTY 客户端建会话,落到 ZMX getTerminalSize 的兜底值;而 ZMX 没有提供任何「不当 leader 也能 resize」的接口,所以 resize() 是结构性 no-op,不会随上游修 send 而变得可去掉 —— 0.7.0 让 send 不再抢 leader,恰恰使它更不可能顺带 resize。

界面

Dashboard 的 Bot 配置页可显式选择 ZMX,并直接说明 tail + history + send 契约与无 Web TUI 边界。

Dashboard 中的 ZMX 后端选项与能力说明

Review

通过 bot 链路邀请 Claude 做了三轮对抗性 review:

  • R1 的 P0(上游 tail 丢中文)经 history 权威重构解决;
  • R2 的退出竞态经前台 gate、稳定 PID 和退出宽限收敛;
  • R3 ship-gate 与最终小 delta 复核结论均为 PASS:0 P0 / 0 P1 / 0 blocking

最终小 delta a9711a28..4db5ac23 进一步关闭了 busy-pattern idle 绕过 ZMX settle、可能从陈旧快照提前定稿的路径;Claude 逐行复核确认非 ZMX 后端保持原行为。其余非阻断边缘项按退出窄窗、维护路径 teardown 可见性、probe 鲁棒性/性能分组,合入后另行跟进。

@LucasIcarus
LucasIcarus requested a review from deepcoldy as a code owner July 13, 2026 19:03
@LucasIcarus
LucasIcarus marked this pull request as draft July 14, 2026 00:24
@LucasIcarus
LucasIcarus force-pushed the feat/support_zmx branch 4 times, most recently from ed9e9e1 to 1f908f5 Compare July 17, 2026 06:30
@LucasIcarus
LucasIcarus marked this pull request as ready for review July 23, 2026 00:34
@LucasIcarus
LucasIcarus marked this pull request as draft July 23, 2026 06:19
@LucasIcarus
LucasIcarus force-pushed the feat/support_zmx branch 4 times, most recently from 7af19fb to 9592f7a Compare July 28, 2026 10:01
@LucasIcarus
LucasIcarus marked this pull request as ready for review July 28, 2026 14:35
@LucasIcarus

Copy link
Copy Markdown
Contributor Author

对抗性复审 follow-up(552f4dee

针对第二轮 maintainer-style 审计,本轮补了 4 个独立提交:

  • 5f73edc6 fix(session): 修复会话续跑与注册竞态
    • 定时任务复用已注册的 worker-less 会话做 cold resume;live injection 失败时重启同一会话,不再创建必输 CAS 的新行。
    • shared 普通群的入群 seed 改为注册成功后发送;CAS loser 零外部消息,seed 失败会按身份回滚并允许重试。
    • /close await 后只删除仍指向原 DaemonSession 的路由键。
  • 218ad5d9 fix(worker): 收紧输入失败与提示卡确认
    • read-isolation kill 后先刷新 ZMX probe,再重选 backend。
    • raw slash 写入失败会发 ambiguous turn terminal 与用户可见通知,不再静默吞 follow-up 正文。
    • 普通 TUI 卡增加同步 processing claim,并在 IPC error / resolved / submit_failed 对称释放,阻断双击重复注键。
  • 33eb7b6d fix(zmx): 按实际字节恢复部分粘贴
    • recovery 按已成功发送字节中的 bracketed-paste 开闭标记推导;覆盖 OhMyPi 通过 sendText 自带 200~ 的路径。
  • 552f4dee chore(cli): 移除误带的 title 命令
    • 删除本 PR 误带的 botmux title CLI、参数解析、类型字段和中英文档条目;保留 upstream 已合入的 /rename 与通用标题元数据。

验证

  • pnpm build:通过(domain audit / TypeScript / dashboard bundle / dist audit)。
  • pnpm test728 files / 11238 passed / 35 skipped / 0 failed
  • BOTMUX_E2E_REQUIRE_ZMX=1 npx vitest run --project e2e test/zmx-backend.e2e.ts1 file / 5 passed / 0 failed(本机 ZMX 0.7.0)。
  • pnpm daemon:restart:4 个 PM2 进程均 online;既有 ZMX 会话完成 reattach,worker ready。
  • git diff --check:通过。
  • pnpm test:all11439 passed / 14 failed / 70 skipped。14 个失败均不在本轮改动路径:缺少飞书浏览器 E2E 环境变量、CoCo/Gemini/Codex 本机 CLI 启动/账号状态,以及修改前已存在的 multi-bot-session.e2e.ts mock 未导出 buildNewTopicCliInput。相关 ZMX e2e 在同一全量运行中仍为 5/5 通过。

影响面复核:session/core 与 Lark card 是公共层,新增用例覆盖 thread/chat、live/worker-less、key/text TUI、stale ACK、IPC failure 与 shared group join;ZMX 字节恢复只在专属 backend 内,PTY/Tmux/Zellij/Herdr 未改。新逻辑未新增平台 syscall;真实 ZMX 仅在 macOS 实跑,Linux 路径由相同 Node/Buffer 逻辑覆盖但本机未做 Linux live 验证。

upstream/master 把 `idleDetector.onIdle(async () =>` 改成了
`async (evidenceSource) =>`,导致 worker-pipe-initial-screen-order 的
源码切片锚点匹配不到、slice 出空串,断言随之失败。

锚点改为不绑定参数列表的正则,并对 idleStart/idleEnd 显式断言,
避免以后再出现「锚点失效 → 静默切出空串」这种假绿/假红。

Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
zmx 上游已在 2026-07-23 发布 v0.7.0,其中包含 commit 8ba312d7
"fix(send): preserve client leadership"(issue deepcoldy#201 的修复,已通过
GitHub compare 确认包含在该 tag 内)。`zmx send` 改用独立的 `.Send`
IPC tag,只把输入排进 PTY 队列,不再抢 leader、不再改写终端尺寸。

原实现按「0.7.1 是首个包含该修复的版本」这一**假设**写死门禁:
compareVersion(parsed, [0,7,1]) < 0 即拒绝。但上游从未发布 0.7.1,
实际修复随 0.7.0 落地 —— 于是这条门禁会拒绝**所有已发布版本**,
zmx 后端在任何机器上都起不来。本机装的 0.7.0 也被拒。

改动:
- ensure-zmx.ts: 抽出 ZMX_MIN_VERSION = [0,7,0] 常量,门禁与错误
  文案统一引用它,不再散落硬编码
- probeZmxVersion 返回规范化的单行 "zmx x.y.z"。`zmx version` 实际
  会打印 ghostty_vt / socket_dir / log_dir 四行,而这个字符串被
  dashboard-ipc-server.ts:2832 原样回给 Dashboard API —— 原来会把
  本机 socket / log 路径泄漏出去,并且相对 "tmux 3.5" 这类同侪显示
  成多行糊字
- 门禁失败文案从「等待上游发布正式版」改成可操作的安装指引
  (brew / release binary / mise)
- zmx-backend.ts 里引用 "PR deepcoldy#202" 的注释改为引用已发布行为:该 PR
  是 closed-not-merged,维护者用自己的 commit 落地了同一设计,继续
  引用一个没合并的 PR 会误导后来者。注释描述的约束本身经核对 v0.7.0
  源码仍然成立(ipc 4096 字节帧、PTY_WRITE_BUF_MAX = 256 KiB、
  queuePtyInput 溢出静默丢弃且无 ACK),故 1 KiB 分片与 64 KiB 上限
  保留不变
- README / docs-site 中英文同步:去掉「上游尚未发布、请勿启用」的
  前置警告,补上 0.6 daemon 因 Tag 枚举 non-exhaustive 而静默丢弃
  `.Send`(`zmx send` 仍退出 0)的具体机制

验证:
- pnpm build 通过
- 相关单测全绿(zmx-backend-helpers / backend-gate /
  backend-availability / zmx-backend-recovery / backend-capabilities)
- 用本机真实 zmx 0.7.0 跑 probeZmxFunctional():改前被拒,改后
  ok=true、version 规范化为 "zmx 0.7.0"

Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
`resize()` 原本只有一句「send 不当 leader 所以不能 resize」,没说清
两件对使用者有实际影响的事:

1. ZMX 压根没有「不当 leader 也能 resize」的接口(`zmx --help` 里
   attach/run/send/print/write/detach/list/get/set/clear/kill/history/
   wait/tail/completions/version 全集里没有 resize)。所以这个 no-op
   是结构性的,不是偷懒,也不会随上游修 send 而变得可以去掉 —— 0.7.0
   让 send 不再抢 leader,恰恰使它更不可能顺带 resize。
2. 那么受管会话到底跑在多大?`createFreshSession` 用
   `stdio: ['ignore','ignore','pipe']` 建会话,没有 TTY,于是落到 ZMX
   `ipc.zig` `getTerminalSize` 的兜底分支 `.{ .rows = 24, .cols = 120 }`。
   即 botmux 建的每个 zmx 会话都固定 120x24,CLI 的 TUI 按 120 列折行,
   这正是飞书侧看到的宽度。

把这两点写进注释和中英文文档的「显示、输入与终端尺寸边界」一节,
免得后来者把 no-op 误当成待修的 TODO 再去翻一遍上游源码。

Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
upstream 把 worker 的后端选择重构成 `selectBackend()` thunk:先选一次,
每个 gate 杀掉陈旧 pane 后再 `selectedBackend = selectBackend()` 重选,
并由 `selectedBackend.isReattach` 推导 willReattachPersistent。

原断言锚定的是本分支旧写法「`const selectedBackend = selectSessionBackend({`
必须出现在 gate 之后」,重构后该字面量已不存在,切片取到空串 / -1。

不变量本身变了形状,所以不是简单改锚点:现在要保的是「任何杀 pane 的
gate 之后必须重选,且必须同步刷新 isReattach」—— 否则 gate 刚删掉 pane,
陈旧的 isReattach=true 会让新后端 reattach 到已被销毁的 pane。断言改为
对 read-isolation 与 mcp-gateway 两个 gate 分别校验这两点。

第二条断言的探针名同步更新:合并后 ZMX 走 probeOwnedZmxSession(按冻结
PID 校验归属),其它后端走 probePersistentBackendTarget(Herdr 可能持有
的是 agent 而非整个 session),fail-closed 的 unknown / postKillProbe /
resolvedZmxSessionProbe 断言保持不变。

验证:pnpm build 通过;pnpm test 10592 passed / 23 skipped / 0 failed;
真实 zmx 0.7.0 E2E 4/4 通过且无孤儿会话。

Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
自检轮次里两条经对抗验证成立的问题。

**1. probeZmxVersion 把所有异常都归因为「不在 PATH 上」**

裸 catch + `stdio` 第三位 'ignore',真实 stderr 直接丢弃。但
`zmx version` 并不是纯打印——它会解析并触碰 socket dir,因此
ZMX_DIR / XDG_RUNTIME_DIR 只读或不可创建时会非零退出(本机实测
`ZMX_DIR=/nonexistent-ro-xyz/nope zmx version` → `error: ReadOnlyFileSystem`,
exit 1)。daemon 实际跑在 Linux,systemd --user 未开 lingering 时
/run/user/$UID 会在登录会话结束后消失,正好命中。

结果是:zmx 装得好好的,飞书卡片却说「zmx 二进制不在 PATH 上,
请 brew install …」,用户反复重装无效,真实原因被 'ignore' 吞掉。

本仓库其实已经修过同一类问题并固化了约定:ensure-tmux.ts 的
`childFailureReason` 明确注释「Only ENOENT proves absence,超时/EACCES
不得转成 misleading not-on-PATH」。zmx 探针绕过了这条约定,现按同样的
分支补齐(ENOENT / EACCES / EMFILE / 超时 / stderr / exit code),
stderr 改为 'pipe',并在 reason 里附上生效的 socket dir 来源,
让无头部署有可操作信息。

**2. waitForTailClient 用全局 clients 计数差值判断自己的 tail**

zmx 的 `clients=` 是聚合值——main.zig 只扣掉发起 `zmx list` 的那条
连接,用户的 `zmx attach`(本集成正在文档里主推)和 botmux 自己每次
list/get/history/send 的瞬时连接,与我们的 tail 完全等价计数。本机实测
起 1 个 tail → clients=1,起 2 个 → 2,杀掉 → 0。

于是差值判定会产生**假阴性**:用户在这 3 秒窗口内 detach,净增量为 0,
一个完全健康、history 可读的会话直接 `ZMX tail 未能连接会话 …` 恢复失败。

改为 `clients >= 1`,把错误方向反过来。残留的假阳性(别人占着客户端而
我们的 tail 挂了)代价很小且能自愈:tail 只是唤醒信号、从不作为字节来源,
权威屏幕来自 history 轮询(根本不依赖 tail),且 scheduleTailRecovery
会重连。挡住恢复才是贵得多的错误。

顺带去掉 `?? 0` 这个会把 baseline 静默归零的兜底。
注:这里无法改为直接判定自己的子进程——等待是同步的(sleepSync),
子进程的 'error'/'close' 回调在返回前根本不会执行;会话身份校验保留。

验证:
- pnpm build 通过;pnpm test 10596 passed / 23 skipped / 0 failed
- 新增 tail 回归用例先在旧差值语义下复现失败、改后通过(非空转断言)
- 新增三条探测归因用例(socket dir 失败 / 真 ENOENT / 超时)
- 真实 zmx 0.7.0 E2E 4/4 通过,无孤儿会话

Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
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