From 49021ac5d96ea9e3b1d3d82e7280ba351d6fa3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Thu, 2 Jul 2026 14:43:31 +0800 Subject: [PATCH 01/26] =?UTF-8?q?feat(backend):=20=E6=94=AF=E6=8C=81=20ZMX?= =?UTF-8?q?=20=E6=8C=81=E4=B9=85=E4=BC=9A=E8=AF=9D=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-site/docs/en/bots-json.md | 2 + docs-site/docs/en/cli-commands.md | 3 +- docs-site/docs/en/dashboard.md | 24 + docs-site/docs/en/env.md | 1 + docs-site/docs/en/zmx.md | 78 ++ docs-site/docs/zh/bots-json.md | 2 + docs-site/docs/zh/cli-commands.md | 3 +- docs-site/docs/zh/dashboard.md | 24 + docs-site/docs/zh/env.md | 1 + docs-site/docs/zh/zmx.md | 78 ++ docs-site/rspress.config.ts | 2 + .../backend/session-backend-selector.ts | 61 +- src/adapters/backend/types.ts | 8 +- src/adapters/backend/zmx-backend.ts | 1128 +++++++++++++++++ src/bot-registry.ts | 2 +- src/cli.ts | 420 ++++-- src/cli/session-list-liveness.ts | 2 +- src/core/command-handler.ts | 12 +- src/core/dashboard-ipc-server.ts | 21 +- src/core/dashboard-rows.ts | 22 + src/core/idle-worker-sweeper.ts | 2 +- src/core/persistent-backend.ts | 57 +- src/core/session-board.ts | 7 +- src/core/session-manager.ts | 223 +++- src/core/session-title.ts | 34 +- src/core/startup-commands.ts | 2 +- src/core/trigger-session.ts | 10 +- src/core/worker-pool.ts | 442 ++++++- src/daemon.ts | 950 ++++++++++---- src/dashboard.ts | 2 +- src/dashboard/web/bot-defaults-page.tsx | 1 + src/dashboard/web/i18n.ts | 14 +- src/i18n/en.ts | 5 +- src/i18n/zh.ts | 5 +- src/im/lark/card-handler.ts | 27 +- src/services/backend-availability.ts | 17 +- src/services/backend-type-store.ts | 2 +- src/services/bot-config-store.ts | 2 +- src/services/local-cli-opener.ts | 20 +- src/services/session-store.ts | 10 + src/setup/bot-config-editor.ts | 4 +- src/setup/ensure-zmx.ts | 103 ++ src/setup/index.ts | 2 +- src/setup/setup-args.ts | 2 +- src/types.ts | 8 +- src/utils/pending-input-queue.ts | 18 + src/worker.ts | 105 +- test/backend-availability.test.ts | 16 + test/backend-gate.test.ts | 52 +- test/backend-type-store.test.ts | 5 +- test/bot-config-editor.test.ts | 7 + test/bridge-final-output-retry.test.ts | 107 +- test/card-handler-relay-pickup.test.ts | 32 + test/command-handler.test.ts | 10 +- test/dashboard-attention-signals.test.ts | 41 +- test/dashboard-create-session.test.ts | 8 +- test/doc-comment-daemon-concurrency.test.ts | 455 +++++++ test/initial-passthrough-ownership.test.ts | 34 + test/kill-worker-orphaned-backend.test.ts | 19 +- test/local-cli-opener.test.ts | 19 + test/persistent-backend-type.test.ts | 41 + test/ready-gate.test.ts | 2 +- test/restore-zombie-close.test.ts | 63 +- test/session-kanban.test.ts | 1 + test/session-manager-auto-recover.test.ts | 3 +- test/session-resume.test.ts | 227 +++- test/session-store.test.ts | 10 + test/session-title.test.ts | 39 +- test/tmux-reattach-backend.test.ts | 54 +- test/transfer-session.test.ts | 388 +++++- test/trigger-session-root-message.test.ts | 6 + test/worker-queue-merge.test.ts | 30 + test/worker-ready-display-mode.test.ts | 28 + test/worker-suspend.test.ts | 3 +- test/zmx-backend-helpers.test.ts | 275 ++++ test/zmx-backend-recovery.test.ts | 237 ++++ test/zmx-backend.e2e.ts | 203 +++ 77 files changed, 5843 insertions(+), 540 deletions(-) create mode 100644 docs-site/docs/en/zmx.md create mode 100644 docs-site/docs/zh/zmx.md create mode 100644 src/adapters/backend/zmx-backend.ts create mode 100644 src/setup/ensure-zmx.ts create mode 100644 test/doc-comment-daemon-concurrency.test.ts create mode 100644 test/zmx-backend-helpers.test.ts create mode 100644 test/zmx-backend-recovery.test.ts create mode 100644 test/zmx-backend.e2e.ts diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index a14320b63..09bf9e7e2 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -134,6 +134,8 @@ You can also add it to the corresponding bot entry directly (manual `bots.json` | `sandboxReadonlyPaths` | Extra existing paths mounted read-only inside the sandbox, useful for shared source snapshots, reference repos, or generated docs the bot should inspect but not modify | | `sandboxNetwork` | Network policy for sandboxed sessions. Omitted / `true` keeps current network and proxy access; `false` adds `--unshare-net` and blocks normal network egress | +> The ZMX backend cannot currently enforce the file sandbox or read isolation. Combining `backendType: "zmx"` with a per-bot/global sandbox, or with the standalone effective `readIsolation` mode on macOS, fails closed and returns an actionable session notification. On Linux, the bare legacy `readIsolation` flag is a no-op under the worker's unified semantics and does not incorrectly gate ZMX; enable the sandbox and use tmux / PTY when isolation is required. See [ZMX backend boundaries](/en/zmx#unsupported-combinations). + ## Cards and terminal | Field | Description | diff --git a/docs-site/docs/en/cli-commands.md b/docs-site/docs/en/cli-commands.md index a5665d468..186ee8541 100644 --- a/docs-site/docs/en/cli-commands.md +++ b/docs-site/docs/en/cli-commands.md @@ -11,7 +11,8 @@ Manage the daemon and sessions from the terminal. | `botmux logs [--lines N]` | View logs | | `botmux status` | View daemon status | | `botmux upgrade` | Upgrade to the latest version | -| `botmux list` (alias `ls`) | List all active sessions | +| `botmux list` (alias `ls`) | Interactively list active sessions; select a managed tmux / ZMX session and press Enter to attach (use `--plain` in scripts) | +| `botmux title [--session-id ] ` | Rename the current or specified session | | `botmux delete ` (aliases `del`/`rm`) | Close the specified session, with ID prefix matching | | `botmux delete all` | Close all active sessions | | `botmux delete stopped` | Clean up zombie sessions whose processes have exited | diff --git a/docs-site/docs/en/dashboard.md b/docs-site/docs/en/dashboard.md index 52d43ce5e..2bf9ea3f9 100644 --- a/docs-site/docs/en/dashboard.md +++ b/docs-site/docs/en/dashboard.md @@ -22,6 +22,30 @@ botmux dashboard > **Two things live outside the Dashboard**: a v3 workflow's **humanGate approve / reject** happens on a **Lark approval card** (not clicked in the Dashboard); triggering a workflow with parameters currently goes through the **connector (Webhook)** path (see [Connectors](/en/webhook)) — there is no "Workflow Catalog + parameterized trigger" page in the Dashboard. The Dashboard's Workflows panel focuses on observation and cancel. +## External read-only queries + +The Dashboard HTTP service exposes two session read surfaces for the board and external observers: + +- `GET /api/sessions`: the current aggregate of active + closed session rows. +- `GET /events`: the Dashboard's external SSE stream. For session events, `session.spawned` carries the full values in `body.session`, while `session.update` carries changes in `body.patch`. Each daemon also has a loopback-only `/api/events` endpoint for Dashboard aggregator IPC; it is not the external URL. + +The following fields are all **optional**. Consumers must handle older sessions/daemons that omit them: + +| Field | Meaning | +|------|---------| +| `backendType` | The effective backend recorded for the latest worker spawn (`pty` / `tmux` / `herdr` / `zellij` / `zmx`), suitable for filtering/display; it may change after a cold resume | +| `backendSessionName` | Present only for managed persistent-backend sessions; currently `bmx-`. PTY, adopted, and some legacy rows omit it. It is deterministic locator metadata and **does not prove that the process/socket is currently live** | +| `titleUpdatedAt` | ISO-8601 timestamp of the last title update | +| `titleSource` | Title-source tag: `initial` / `user` / `agent` / `cli` / `dashboard` / `system`. It is display/debug metadata, **not a trusted identity or audit field** | + +### `publicReadOnly` and the token boundary + +`publicReadOnly` is on by default. While it is enabled, `GET /api/sessions` and `GET /events` are reachable **without a token** on the Dashboard listener, so session names, titles, backends, and the other row metadata must be treated as public to that network. + +- Every POST / PATCH / DELETE mutation, every GET outside the read-only allow-list, and every raw PTY / diagnostic log still requires the current token issued by `botmux dashboard`. The allow-list is fail-closed: a newly added GET endpoint does not become public merely because public read-only mode is enabled. +- Each `botmux dashboard` invocation rotates the token and invalidates the previous link. The token is application-layer Dashboard access, not a replacement for host firewall, VPN, or reverse-proxy authentication. +- If tokenless observation is unnecessary, turn off **Public read-only** under Dashboard Settings. You can also start with `BOTMUX_DASHBOARD_PUBLIC_READONLY=false`; once the setting has been saved in the UI, the persisted value in `~/.botmux/config.json` takes precedence over the environment variable. + ## Deployment details The dashboard runs as a separate pm2 process `botmux-dashboard`, starting and stopping together with the daemon. Each daemon exposes an internal IPC on `127.0.0.1` (local only), and the dashboard process acts as a reverse proxy + HMAC auth: the secret file `~/.botmux/.dashboard-secret` (mode 0600) is the internal daemon↔dashboard signing key and is **never sent down to the browser** (the browser side uses the rotating login token above). diff --git a/docs-site/docs/en/env.md b/docs-site/docs/en/env.md index 7dd8c531d..d30b9624c 100644 --- a/docs-site/docs/en/env.md +++ b/docs-site/docs/en/env.md @@ -32,6 +32,7 @@ Most configuration goes through `bots.json` / the dashboard — you **usually do | `BOTMUX_PUBLIC_URL` | _(unset)_ | Self-hosted reverse-proxy base (`scheme://host[:port]`). Set it when you don't use the central platform but front the dashboard with your own nginx etc. on a single public/intranet domain; dashboard and card terminal links then emit `/…` and `/s/` through the dashboard front door, with no per-bot port. Unset falls back to the local `host:port`. Must be written into `~/.botmux/.env` (a restart launched from inside a session reads only the file, it does not inherit the shell) | | `BOTMUX_DAEMON_IPC_BASE_PORT` | `7892` | Each daemon's IPC port = base + botIndex | | `BOTMUX_WORKFLOW_RUNS_DIR` | `~/.botmux/workflow-runs` | Workflow run storage directory | +| `BOTMUX_DASHBOARD_PUBLIC_READONLY` | `true` | Allow tokenless access to the Dashboard's allow-listed read-only APIs / SSE. Once this switch has been saved in Dashboard Settings, the value persisted in `~/.botmux/config.json` takes precedence over this environment variable | ## File locations diff --git a/docs-site/docs/en/zmx.md b/docs-site/docs/en/zmx.md new file mode 100644 index 000000000..d9ab5e946 --- /dev/null +++ b/docs-site/docs/en/zmx.md @@ -0,0 +1,78 @@ +# ZMX Session Backend + +[ZMX](https://github.com/neurosnap/zmx) is an optional persistent-session backend for botmux. It is intended for macOS and Linux hosts that want native terminal features while only needing attach/detach and session persistence. + +ZMX is an **explicit opt-in** backend. botmux does not install ZMX, and merely finding it on `PATH` never makes botmux select it automatically. + +## Install and probe + +botmux requires **zmx >= 0.6.0**. ZMX officially supports macOS and Linux. + +```bash +# Homebrew (macOS / Linuxbrew) +brew install neurosnap/tap/zmx + +# Verify the daemon user's PATH and control plane +zmx version +zmx list +``` + +On other hosts, download the prebuilt binary for your architecture from the [official ZMX installation instructions](https://github.com/neurosnap/zmx#install), then put `zmx` on the `PATH` of the same system user that runs the botmux daemon. + +Before creating a new ZMX-backed session, botmux checks the executable, version, and `zmx list` control plane. Any failure **fails closed** with an actionable session error; botmux never silently falls back to PTY. + +For contributors, the default `pnpm test` command runs mocked/pure unit tests and **does not require ZMX to be installed**. Coverage that launches a real `zmx` binary lives in `*.e2e.ts`, runs only when E2E is requested explicitly, and skips automatically when ZMX is unavailable—the same pattern already used by the tmux and Herdr E2E suites. + +## Enable ZMX + +Prefer enabling it only for the bots that need it in `~/.botmux/bots.json`: + +```json +{ + "name": "codex-zmx", + "cliId": "codex", + "backendType": "zmx" +} +``` + +To make ZMX the deployment-wide default backend, set this in `~/.botmux/.env` instead: + +```bash +BACKEND_TYPE=zmx +``` + +Run `botmux restart` after editing. A per-bot `backendType` overrides the deployment default. + +## Runtime model + +botmux assigns each managed session the deterministic name `bmx-`. The worker runs one real `zmx attach` client inside node-pty. This single bidirectional path carries raw ANSI output, keyboard input, paste, and resize; it does not split transport across `zmx tail` and `zmx send`. On reconnect, ZMX's terminal snapshot is restored over that same attach path. + +| Event | ZMX session / CLI | botmux behavior | +|------|-------------------|-----------------| +| `botmux restart` | Stays alive | Rebuilds workers in staggered batches and attaches to the original session without restarting the CLI | +| Backing session is missing during restore | Original process is gone | Retains the active/transcript record instead of destroying it as a zombie; lazily resumes on the next message | +| Worker / attach client disconnects | Stays alive | Reconnects after confirming that the ZMX session still exists | +| `/close` or close button | Destroyed / terminated | Force-kills the backing session instead of leaving an unmanaged process behind | + +## Enter the same session locally + +```bash +botmux list +``` + +`botmux list` shows the effective backend for every session. Select a ZMX row and press Enter to attach safely to its existing `bmx-*` session. If the backing session has disappeared, the command refuses to create an empty shell that could masquerade as the original CLI. + +When the daemon runs on macOS, you can also explicitly enable **Native CLI opening** under Dashboard Settings and keep **Attach current session** mode selected. The Lark card's **Open CLI** button will then attach iTerm2 / Terminal to the same ZMX session instead of starting a second CLI. This feature is off by default and requires operate permission. + +## Unsupported combinations + +- **Adopt**: ZMX is not scanned or accepted as a `/adopt` source. Use the supported tmux / Herdr / Zellij path when adopting an existing external session. +- **File sandbox and read isolation**: the child PTY belongs to the ZMX session daemon, so botmux cannot currently apply its bwrap / Seatbelt filesystem boundary. Combining `backendType: "zmx"` with `sandbox: true`, global `BOTMUX_SANDBOX=1`, or the standalone effective `readIsolation: true` mode on macOS therefore **fails closed**; the worker posts an actionable session notification before refusing to start. On Linux, the bare legacy `readIsolation` flag is a no-op under the unified worker semantics: it neither provides isolation nor incorrectly gates ZMX. When isolation is required, enable the sandbox and switch to tmux / PTY; otherwise explicitly disable the corresponding isolation setting. + +## Troubleshooting + +1. Run `zmx version` and `zmx list` as the same user that runs the daemon to verify the version, `PATH`, and socket directory. +2. If you set `ZMX_DIR`, make sure the daemon and the shell used for local attach share the same value. botmux preserves `ZMX_DIR`, but strips inherited `ZMX_SESSION` / `ZMX_SESSION_PREFIX` so nested sessions and prefixes cannot rewrite the deterministic `bmx-*` target. +3. Inspect `botmux logs`. When a probe is inconclusive, botmux conservatively refuses to start/recreate a session so it cannot launch a duplicate CLI or delete a still-live one. + +Dashboard session queries can report the ZMX backend and deterministic session name, but those fields are not liveness checks. See [Dashboard external read-only queries and security boundary](/en/dashboard#external-read-only-queries). diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index c8e44abc2..4bbcba192 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -134,6 +134,8 @@ | `sandboxReadonlyPaths` | 在沙盒内额外只读挂载的已存在路径,适合共享源码快照、参考仓库或生成文档等只允许查看、不允许修改的输入 | | `sandboxNetwork` | 沙盒会话的网络策略。缺省 / `true` 保留当前网络和代理访问;`false` 添加 `--unshare-net`,阻断普通网络出口 | +> ZMX 后端当前无法执行文件沙盒或读隔离。`backendType: "zmx"` 与 per-bot / 全局沙盒,或 macOS 上独立生效的 `readIsolation` 同时启用时会 fail closed,并向会话返回操作提示。Linux 上单独设置旧 `readIsolation` 标志按 worker 统一语义是 no-op,不会误拦 ZMX;需要真实隔离时请启用 sandbox 并改用 tmux / PTY。见 [ZMX 后端边界](/zmx#不支持的组合)。 + ## 卡片与终端 | 字段 | 说明 | diff --git a/docs-site/docs/zh/cli-commands.md b/docs-site/docs/zh/cli-commands.md index 8615591f5..44b0fa68c 100644 --- a/docs-site/docs/zh/cli-commands.md +++ b/docs-site/docs/zh/cli-commands.md @@ -11,7 +11,8 @@ | `botmux logs [--lines N]` | 查看日志 | | `botmux status` | 查看 daemon 状态 | | `botmux upgrade` | 升级到最新版本 | -| `botmux list` (别名 `ls`) | 列出所有活跃会话 | +| `botmux list` (别名 `ls`) | 交互式列出活跃会话;选中受管 tmux / ZMX 会话后按 Enter 可 attach(脚本使用 `--plain`) | +| `botmux title [--session-id ] <新标题>` | 修改当前或指定会话的标题 | | `botmux delete ` (别名 `del`/`rm`) | 关闭指定会话,支持 ID 前缀匹配 | | `botmux delete all` | 关闭所有活跃会话 | | `botmux delete stopped` | 清理进程已退出的僵尸会话 | diff --git a/docs-site/docs/zh/dashboard.md b/docs-site/docs/zh/dashboard.md index e2ece1f8c..008644c1b 100644 --- a/docs-site/docs/zh/dashboard.md +++ b/docs-site/docs/zh/dashboard.md @@ -22,6 +22,30 @@ botmux dashboard > **两件事在 Dashboard 之外**:v3 workflow 的 **humanGate 批准 / 拒绝** 走**飞书审批卡**(不在 Dashboard 上点);带参触发 workflow 目前是**接入点(Webhook)** 那条路径(见 [接入点](/webhook)),Dashboard 没有「Workflow Catalog 带参触发」页。Dashboard 的 Workflows 面板专注观测与 cancel。 +## 对外只读查询 + +Dashboard HTTP 服务提供两个可供看板或外部观测端消费的会话读接口: + +- `GET /api/sessions`:当前聚合的 active + closed session rows。 +- `GET /events`:Dashboard 对外 SSE 流,其中 `session.spawned` 的 `body.session` 和 `session.update` 的 `body.patch` 会携带对应的完整值/变更值。每个 daemon 内部还有只绑定 loopback 的 `/api/events`,这是 Dashboard 聚合器的 IPC,不是对外地址。 + +会话输出中的下列字段都是**可选字段**,消费者必须兼容旧会话/旧 daemon 不返回它们: + +| 字段 | 语义 | +|------|------| +| `backendType` | 最近一次 worker spawn 时记录的有效后端(`pty` / `tmux` / `herdr` / `zellij` / `zmx`),用于过滤/展示;cold resume 后可能随配置切换 | +| `backendSessionName` | 仅受管的持久后端会话才有,当前规则为 `bmx-`;PTY、adopt 会话和部分 legacy row 没有该字段。它是确定性定位信息,**不代表对应进程/socket 当前存活** | +| `titleUpdatedAt` | 标题最后更新的 ISO-8601 时间字符串 | +| `titleSource` | 标题来源标签:`initial` / `user` / `agent` / `cli` / `dashboard` / `system`。仅供展示和调试,**不是可信的身份/审计字段** | + +### `publicReadOnly` 与 token 边界 + +`publicReadOnly` 默认开启。开启时,`GET /api/sessions` 和 `GET /events` 在 Dashboard 监听地址上可以**无 token** 访问,因此会话名称、标题、后端和 row 中的其它元数据都应按可公开信息对待。 + +- 全部 POST / PATCH / DELETE 写操作、不在只读白名单中的 GET,以及原始 PTY / 诊断日志,始终需要 `botmux dashboard` 生成的当前 token。白名单是 fail-closed 的:新增 GET 不会因公开只读开启就自动暴露。 +- 每次运行 `botmux dashboard` 都会轮换 token,之前的链接失效。token 只提供 Dashboard 应用层访问权,不代替主机防火墙、VPN 或反向代理鉴权。 +- 不需要无 token 观测时,在 Dashboard 「设置」中关闭「公开只读」。也可先设 `BOTMUX_DASHBOARD_PUBLIC_READONLY=false`;但设置页一旦保存过该开关,`~/.botmux/config.json` 的持久值会优先于环境变量。 + ## 部署细节 dashboard 走单独 pm2 进程 `botmux-dashboard`,跟 daemon 一起起停。每个 daemon 在 `127.0.0.1` 暴露内部 IPC(仅本机),dashboard 进程做反向代理 + HMAC 鉴权:密钥文件 `~/.botmux/.dashboard-secret`(mode 0600),是 daemon↔dashboard 的内部签名密钥,**不下发给浏览器**(浏览器侧走上面的轮换登录 token)。 diff --git a/docs-site/docs/zh/env.md b/docs-site/docs/zh/env.md index 40c94a1b3..04c3ce75b 100644 --- a/docs-site/docs/zh/env.md +++ b/docs-site/docs/zh/env.md @@ -32,6 +32,7 @@ | `BOTMUX_PUBLIC_URL` | _(未设置)_ | 自建反代对外基址(`scheme://host[:port]`)。没接中心平台、但自己用 nginx 等把 dashboard 反代到单一公网/内网域名时设它,dashboard / 卡片终端链接改吐 `<基址>/…`、`<基址>/s/`,走 dashboard 前门、无需 per-bot 端口。未设回退本地 `host:port`。必须写在 `~/.botmux/.env`(会话内发起的重启只读文件、不继承 shell) | | `BOTMUX_DAEMON_IPC_BASE_PORT` | `7892` | 每个 daemon 的 IPC 端口 = base + botIndex | | `BOTMUX_WORKFLOW_RUNS_DIR` | `~/.botmux/workflow-runs` | workflow run 存储目录 | +| `BOTMUX_DASHBOARD_PUBLIC_READONLY` | `true` | 是否允许无 token 访问 Dashboard 白名单只读 API / SSE;一旦在 Dashboard 设置页保存过该开关,`~/.botmux/config.json` 中的值优先于本环境变量 | ## 文件位置 diff --git a/docs-site/docs/zh/zmx.md b/docs-site/docs/zh/zmx.md new file mode 100644 index 000000000..2ea1577da --- /dev/null +++ b/docs-site/docs/zh/zmx.md @@ -0,0 +1,78 @@ +# ZMX 会话后端 + +[ZMX](https://github.com/neurosnap/zmx) 是 botmux 的一个可选持久会话后端。它适合希望保留原生终端特性,又只需要 attach / detach 与会话常驻能力的 macOS / Linux 主机。 + +ZMX 是**显式 opt-in** 后端:botmux 不会自动安装 ZMX,也不会因为它已在 `PATH` 中就自动选用。 + +## 安装与探测 + +botmux 要求 **zmx >= 0.6.0**。ZMX 官方支持 macOS 和 Linux。 + +```bash +# Homebrew(macOS / Linuxbrew) +brew install neurosnap/tap/zmx + +# 验证 daemon 用户的 PATH 与控制面 +zmx version +zmx list +``` + +其它环境可从 [ZMX 官方安装说明](https://github.com/neurosnap/zmx#install) 下载对应架构的预编译二进制,并将 `zmx` 放进运行 botmux daemon 的同一系统用户的 `PATH`。 + +每次启动新 ZMX 会话前,botmux 都会校验可执行文件、版本和 `zmx list` 控制面。任意一项失败都会 **fail closed** 并向会话返回可操作的错误;绝不会悄悄降级到 PTY。 + +开发时,默认 `pnpm test` 只跑 mock / 纯函数单测,**不要求本机安装 ZMX**。会启动真实 `zmx` 的覆盖位于 `*.e2e.ts`,只在显式运行 E2E 时参与,并在 ZMX 不可用时自动跳过;这与仓库现有 tmux / Herdr E2E 的处理方式一致。 + +## 开启 ZMX + +推荐只为需要的 bot 在 `~/.botmux/bots.json` 中配置: + +```json +{ + "name": "codex-zmx", + "cliId": "codex", + "backendType": "zmx" +} +``` + +若要让本部署的默认后端都改为 ZMX,也可在 `~/.botmux/.env` 中设置: + +```bash +BACKEND_TYPE=zmx +``` + +修改后运行 `botmux restart`。单 bot 的 `backendType` 会覆盖部署默认值。 + +## 运行模型 + +botmux 为每个受管会话使用确定性名称 `bmx-`。worker 在 node-pty 中运行一个真实的 `zmx attach` 客户端,由这一条双向链路承载原始 ANSI 输出、键盘输入、粘贴和 resize;不使用 `zmx tail` + `zmx send` 的分离旁路。重连时,ZMX 自己的终端快照也通过同一 attach 链路恢复。 + +| 事件 | ZMX session / CLI | botmux 行为 | +|------|-------------------|-------------| +| `botmux restart` | 保持存活 | 恢复时分批重建 worker 并 attach 原会话,不重起 CLI | +| 恢复时 backing session 已不存在 | 原进程已不在 | 保留 active / transcript 记录,不当作僵尸销毁;下一条消息时 lazy resume | +| worker / attach 客户端断开 | 保持存活 | 确认 ZMX session 仍在后自动重连 | +| `/close` 或关闭按钮 | 销毁 / 终止 | 执行强制 kill,不会只留一个脱管会话 | + +## 在本机进入同一会话 + +```bash +botmux list +``` + +`botmux list` 会显示每条会话的实际后端。选中 ZMX 会话并按 Enter 会安全 attach 到现有的 `bmx-*` 会话;如果 backing session 已消失,命令会拒绝创建一个空 shell 来冒充原 CLI。 + +当 daemon 运行在 macOS 上时,还可在 Dashboard 的「设置」中显式开启「本机 CLI 直开」,并保持「附加当前会话」模式。此时飞书卡片的「打开 CLI」按钮会在 iTerm2 / Terminal 中 attach 到同一个 ZMX 会话,而不是启动第二个 CLI。该功能默认关闭,且只允许有操作权限的用户触发。 + +## 不支持的组合 + +- **Adopt**:ZMX 不是 `/adopt` 的扫描/接入源;需要 adopt 现有外部会话时,使用 tmux / Herdr / Zellij 支持的路径。 +- **文件沙盒与读隔离**:ZMX 子 PTY 属于会话 daemon,当前无法套用 botmux 的 bwrap / Seatbelt 文件边界。因此 `sandbox: true`、全局 `BOTMUX_SANDBOX=1`,或 macOS 上独立生效的 `readIsolation: true` 与 `backendType: "zmx"` 同时出现时会 **fail closed**;worker 会先向会话返回可操作提示,再拒绝启动。Linux 上单独设置旧 `readIsolation` 标志按统一 worker 语义是 no-op,不代表会话已隔离,也不会误拦 ZMX。需要真实隔离时,请启用 sandbox 并改用 tmux / PTY;否则明确关闭相应隔离配置。 + +## 排错 + +1. 以运行 daemon 的同一用户执行 `zmx version` 和 `zmx list`,确认版本、`PATH` 和 socket 目录可用。 +2. 如果显式设了 `ZMX_DIR`,确保 daemon 和本地 attach 的 shell 使用同一值。botmux 会保留 `ZMX_DIR`,但会清掉继承的 `ZMX_SESSION` / `ZMX_SESSION_PREFIX`,避免嵌套会话和名称前缀改写 `bmx-*` 目标。 +3. 查看 `botmux logs`。探测结果不确定时,botmux 会保守拒绝启动/重建,避免重复启动 CLI 或误删仍存活的会话。 + +Dashboard 的会话查询可返回 ZMX 后端与确定性会话名,但这些字段不等于存活检查。见 [Dashboard 对外只读查询与安全边界](/dashboard#对外只读查询)。 diff --git a/docs-site/rspress.config.ts b/docs-site/rspress.config.ts index d31f6bad1..b4d4f85e6 100644 --- a/docs-site/rspress.config.ts +++ b/docs-site/rspress.config.ts @@ -38,6 +38,7 @@ const zhSidebar = [ { text: '本地白板', link: '/whiteboard' }, { text: '角色与团队', link: '/roles' }, { text: 'tmux 会话常驻', link: '/tmux' }, + { text: 'ZMX 会话后端', link: '/zmx' }, { text: '会话接入 Adopt', link: '/adopt' }, { text: '会话接力 Relay', link: '/relay' }, { text: '一键建会话群', link: '/group' }, @@ -112,6 +113,7 @@ const enSidebar = [ { text: 'Local Whiteboard', link: '/en/whiteboard' }, { text: 'Roles & Teams', link: '/en/roles' }, { text: 'tmux Session Persistence', link: '/en/tmux' }, + { text: 'ZMX Session Backend', link: '/en/zmx' }, { text: 'Adopt a Session', link: '/en/adopt' }, { text: 'Relay a Session', link: '/en/relay' }, { text: 'One-Click Session Groups', link: '/en/group' }, diff --git a/src/adapters/backend/session-backend-selector.ts b/src/adapters/backend/session-backend-selector.ts index 4ea0b5bad..6adb2a18e 100644 --- a/src/adapters/backend/session-backend-selector.ts +++ b/src/adapters/backend/session-backend-selector.ts @@ -4,6 +4,7 @@ import { RiffBackend, type RiffBackendConfig } from './riff-backend.js'; import { TmuxBackend } from './tmux-backend.js'; import { TmuxPipeBackend } from './tmux-pipe-backend.js'; import { ZellijBackend } from './zellij-backend.js'; +import { ZmxBackend } from './zmx-backend.js'; import type { BackendType, PersistentBackendTarget, SessionBackend } from './types.js'; export type BackendGateDecision = @@ -11,7 +12,7 @@ export type BackendGateDecision = | { action: 'gate'; reason: string }; /** - * Hard gate (PTY 退役): a requested *persistent* backend (tmux/herdr/zellij) + * Hard gate (PTY 退役): a requested *persistent* backend (tmux/herdr/zellij/zmx) * that isn't functional on this host no longer silently degrades to raw PTY. * That silent fallback was the root of the "secretly running on PTY, then * hitting all of PTY's problems (no survival across daemon restart, etc.)" @@ -22,12 +23,11 @@ export type BackendGateDecision = * and is always allowed straight through. * * `hasExistingSession` lets an already-running persistent session reattach - * regardless of a transient probe failure (a disposable "can we start a new - * server?" probe is far less authoritative than a live session — see PR#249): + * regardless of a transient functional-probe failure (the known live session + * is more authoritative than a separate capability check — see PR#249): * abandoning it would spawn a duplicate CLI and orphan the real conversation. - * The caller computes it only for backends whose probe is a disposable - * session (tmux, zellij); herdr's probe is a cheap non-destructive - * `herdr --version`, so it passes `hasExistingSession: false`. + * tmux/zellij capability probes use disposable sessions; ZMX checks its + * version and full-list control plane; Herdr uses `herdr --version`. */ export function decideBackendGate(opts: { requested: BackendType; @@ -45,6 +45,8 @@ export function backendGateUserMessage(backend: BackendType, reason: string): st const installHint = backend === 'tmux' ? 'macOS: brew install tmux | Debian/Ubuntu: sudo apt-get install -y tmux | 其它发行版用对应包管理器安装 tmux' + : backend === 'zmx' + ? '需要 zmx >= 0.6.0;macOS: brew install neurosnap/tap/zmx | Linux: 安装官方 release binary' : `请确认 ${backend} 已正确安装并可用`; return [ `⚠️ 本机 ${backend} 不可用,无法启动会话。`, @@ -55,6 +57,38 @@ export function backendGateUserMessage(backend: BackendType, reason: string): st ].join('\n'); } +/** + * ZMX owns the child PTY inside its per-session daemon, outside botmux's + * bwrap/Seatbelt launch wrapper. Until ZMX can enforce the same filesystem + * boundary, a requested sandbox must fail closed instead of silently running + * the CLI with broader access than configured. + */ +export function backendSandboxCompatibilityError(opts: { + backendType: BackendType; + fileSandboxRequested: boolean; + /** True only when the legacy standalone readIsolation flag is effective + * on this host. The raw config value is intentionally not accepted here: + * on Linux that legacy flag is a no-op unless the unified sandbox is on. */ + effectiveReadIsolationRequested: boolean; +}): string | undefined { + if ( + opts.backendType === 'zmx' && + (opts.fileSandboxRequested || opts.effectiveReadIsolationRequested) + ) { + return 'backend "zmx" does not support file/read isolation; use tmux/pty or disable sandbox for this bot'; + } + return undefined; +} + +/** Actionable card shown before an incompatible ZMX/isolation launch fails. */ +export function backendSandboxCompatibilityUserMessage(reason: string): string { + return [ + '⚠️ ZMX 当前无法执行 botmux 的文件沙盒或读隔离,已拒绝启动以避免未隔离运行。', + `原因:${reason}`, + '请将该 bot 的 backendType 改为 tmux / pty,或关闭 sandbox(含全局 BOTMUX_SANDBOX)及 macOS 上独立生效的 readIsolation 后重试。', + ].join('\n'); +} + export interface SelectedSessionBackend { backend: SessionBackend; isTmuxMode: boolean; @@ -78,6 +112,7 @@ export function selectSessionBackend(opts: { /** Migration compatibility for sessions previously placed in a shared user host. */ reuseRecordedHerdrTarget?: boolean; persistentBackendTarget?: PersistentBackendTarget; + hasExistingSession?: boolean; }): SelectedSessionBackend { if (opts.backendType === 'riff') { if (!opts.backendConfig) { @@ -91,6 +126,20 @@ export function selectSessionBackend(opts: { }; } + if (opts.backendType === 'zmx') { + const sessionName = ZmxBackend.sessionName(opts.sessionId); + const reattach = opts.hasExistingSession ?? ZmxBackend.hasSession(sessionName); + return { + backend: new ZmxBackend(sessionName, { ownsSession: true, isReattach: reattach }), + isTmuxMode: false, + isPipeMode: false, + isZellijMode: false, + persistentSessionName: sessionName, + persistentBackendTarget: { backendType: 'zmx', sessionName }, + isReattach: reattach, + }; + } + if (opts.backendType === 'zellij') { const sessionName = ZellijBackend.sessionName(opts.sessionId); const reattach = ZellijBackend.hasSession(sessionName); diff --git a/src/adapters/backend/types.ts b/src/adapters/backend/types.ts index c709e470c..170017665 100644 --- a/src/adapters/backend/types.ts +++ b/src/adapters/backend/types.ts @@ -1,4 +1,4 @@ -export type BackendType = 'pty' | 'tmux' | 'herdr' | 'zellij' | 'riff'; +export type BackendType = 'pty' | 'tmux' | 'herdr' | 'zellij' | 'zmx' | 'riff'; /** * Durable identity of the backing resource owned by one Botmux session. @@ -9,7 +9,7 @@ export type BackendType = 'pty' | 'tmux' | 'herdr' | 'zellij' | 'riff'; * untouched on restore/close paths that run without a live worker. */ export type PersistentBackendTarget = - | { backendType: 'tmux' | 'zellij'; sessionName: string } + | { backendType: 'tmux' | 'zellij' | 'zmx'; sessionName: string } | { backendType: 'herdr'; sessionName: string; agentName?: string }; /** @@ -34,7 +34,7 @@ export interface SpawnOpts { env: Record; /** * Per-bot env (bots.json `env`) to inject into the CLI process ONLY. Kept - * separate from `env` on purpose: the persistent backends (tmux/zellij) must + * separate from `env` on purpose: the persistent backends (tmux/zellij/zmx) must * NOT put these into the shared backing-server global env — they inject them * via the per-pane `/usr/bin/env KEY=VAL` prefix so one bot's provider creds * can't leak into another bot's panes. The pty backend (no shared server) @@ -43,7 +43,7 @@ export interface SpawnOpts { injectEnv?: Record; /** * Per-bot shell override (BotConfig.launchShell). When set, the persistent - * backends (tmux/zellij) launch the CLI under this shell instead of `$SHELL` + * backends (tmux/zellij/zmx) launch the CLI under this shell instead of `$SHELL` * — the escape hatch for a login `$SHELL` whose rcfile `exec`-trampolines into * another shell. Bare name (`zsh`) or absolute path; see resolveUserShell. * Ignored by the pty backend (no shell wrapper). diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts new file mode 100644 index 000000000..ac9fdb3e0 --- /dev/null +++ b/src/adapters/backend/zmx-backend.ts @@ -0,0 +1,1128 @@ +import * as pty from 'node-pty'; +import { execFileSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { chmodSync, mkdtempSync, rmSync, rmdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import xtermHeadless from '@xterm/headless'; +import type { SessionBackend, SpawnOpts, SessionProbe } from './types.js'; +import { zmxEnv, probeZmxFunctional } from '../../setup/ensure-zmx.js'; +import { + buildBotmuxEnvAssignments, + buildDebugKeepShellScript, + resolveUserShell, + SHELL_WRAPPER_SCRIPT, +} from './tmux-backend.js'; +import { logger } from '../../utils/logger.js'; + +const { Terminal } = xtermHeadless; + +const EARLY_BUFFER_MAX = 1024 * 1024; +const RECOVERY_WRITE_BUFFER_MAX = 256 * 1024; +const RECOVERY_DELAY_MAX_MS = 2000; +const RECOVERY_WRITE_FLUSH_DELAY_MS = 150; +const FRESH_ATTACH_READY_TIMEOUT_MS = 5000; +const FRESH_ATTACH_READY_BUFFER_MAX = 1024 * 1024; +const FRESH_BOOTSTRAP_WATCHDOG_SECONDS = 8; +const STALE_BOOTSTRAP_POLL_MS = 100; +const STALE_BOOTSTRAP_WAIT_MAX_MS = (FRESH_BOOTSTRAP_WATCHDOG_SECONDS + 3) * 1000; +const REATTACH_BOOTSTRAP_SNIFF_MS = 300; +const ZMX_READY_MARKER_RE = /\x1b\]5150;botmux-zmx-ready=([0-9a-f]{32})\x1b\\/; +const ZMX_READY_MARKER_MAX = 96; +const LAUNCH_PAYLOAD_CLEANUP_MS = 5 * 60_000; +const TERMINAL_FG = '#a9b1d6'; +const TERMINAL_BG = '#1a1b26'; +const TERMINAL_CURSOR = '#c0caf5'; +const TERMINAL_ANSI = [ + '#15161e', '#f7768e', '#9ece6a', '#e0af68', + '#7aa2f7', '#bb9af7', '#7dcfff', '#a9b1d6', + '#414868', '#f7768e', '#9ece6a', '#e0af68', + '#7aa2f7', '#bb9af7', '#7dcfff', '#c0caf5', +] as const; + +type AttachMode = 'fresh' | 'reattach'; +type BackendState = 'idle' | 'connecting' | 'attached' | 'recovering' | 'stopped' | 'exited'; + +interface ZmxSessionProbeResult { + ok: true; + sessions: string[]; + unhealthySessions: string[]; + raw: string; +} + +interface ZmxLaunchPayload { + bootstrapPath: string; + readyMarker: string; + completionMarker: string; + releaseToken: string; + cleanup: () => void; +} + +/** + * Persistent backend driven by one real `zmx attach` client inside node-pty. + * + * The attach client is the ordered, bidirectional transport: it preserves raw + * terminal bytes, gives the backing PTY its real dimensions, and replays ZMX's + * terminal snapshot on reconnect. Killing the client only detaches; explicit + * session teardown uses `zmx kill --force`. + */ +export class ZmxBackend implements SessionBackend { + private process: pty.IPty | null = null; + private readonly dataCbs: Array<(data: string) => void> = []; + private readonly exitCbs: Array<(code: number | null, signal: string | null) => void> = []; + private reattaching: boolean; + private intentionalExit = false; + private exited = false; + private state: BackendState = 'idle'; + private epoch = 0; + private reconnectAttempt = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + private stableAttachTimer: NodeJS.Timeout | null = null; + private freshAttachReadyTimer: NodeJS.Timeout | null = null; + private recoveryWriteFlushTimer: NodeJS.Timeout | null = null; + private recoveryWriteProbeAttempt = 0; + private queryTerminal: InstanceType | null = null; + /** Quarantine may be observing a same-name session owned by another checkout. */ + private preserveSessionOnDestroy = false; + private pendingExit: { code: number | null; signal: string | null } | null = null; + private earlyBuffer = ''; + private recoveryWriteBuffer = ''; + private lastOpts: SpawnOpts | null = null; + private cols = 200; + private rows = 50; + + claudeJsonlPath?: string; + cliPid?: number; + cliCwd?: string; + + constructor( + private readonly sessionName: string, + private readonly opts: { ownsSession?: boolean; isReattach?: boolean } = {}, + ) { + this.reattaching = opts.isReattach ?? false; + } + + static isAvailable(): boolean { + return probeZmxFunctional().ok; + } + + static sessionName(sessionId: string): string { + return `bmx-${sessionId.slice(0, 8)}`; + } + + /** + * Pair the authoritative healthy-name surface (`list --short`) with the full + * list's `err=` rows. The full command field is not a line protocol (literal + * newlines in argv spill onto continuation lines), so it must never be the + * sole source of truth for a healthy session name. + */ + static probeSessions(): ZmxSessionProbeResult | { ok: false } { + try { + const shortOut = execFileSync('zmx', ['list', '--short'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 3000, + env: zmxEnv(), + }); + const out = execFileSync('zmx', ['list'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 3000, + env: zmxEnv(), + }); + const short = parseZmxShortList(shortOut); + const parsed = parseZmxList(out); + if (short.malformedLines.length > 0 || parsed.malformedLines.length > 0) return { ok: false }; + if (short.sessions.length > 0 && parsed.sessions.length + parsed.unhealthySessions.length === 0) { + return { ok: false }; + } + + const healthy = new Set(short.sessions); + // A healthy-looking full row absent from --short is ambiguous (the row + // can have been forged by a multiline cmd field). Preserve it as unknown, + // never as authoritative existence or absence. Conversely --short wins + // over a forged err= continuation for a genuinely healthy name. + const unhealthy = new Set([ + ...parsed.unhealthySessions.filter(name => !healthy.has(name)), + ...parsed.sessions.filter(name => !healthy.has(name)), + ]); + return { + ok: true, + sessions: short.sessions, + unhealthySessions: [...unhealthy], + raw: out, + }; + } catch { + return { ok: false }; + } + } + + static hasSession(name: string): boolean { + return ZmxBackend.probeSession(name) === 'exists'; + } + + static probeSession(name: string): SessionProbe { + const probe = ZmxBackend.probeSessions(); + if (!probe.ok) return 'unknown'; + if (probe.sessions.includes(name)) return 'exists'; + if (probe.unhealthySessions.includes(name)) return 'unknown'; + return 'missing'; + } + + /** + * ZMX has one daemon per session, not one shared server. This value therefore + * describes botmux-owned sessions only and must not be used to infer whether + * another missing ZMX session is a zombie. + */ + static serverState(): 'running' | 'down' | 'unknown' { + const probe = ZmxBackend.probeSessions(); + if (!probe.ok) return 'unknown'; + if (probe.unhealthySessions.some(s => s.startsWith('bmx-'))) return 'unknown'; + return probe.sessions.some(s => s.startsWith('bmx-')) ? 'running' : 'down'; + } + + static killSession(name: string): void { + try { + execFileSync('zmx', ['kill', name, '--force'], { + stdio: 'ignore', + timeout: 5000, + env: zmxEnv(), + }); + } catch { /* already gone */ } + } + + static listBotmuxSessions(): string[] { + const probe = ZmxBackend.probeSessions(); + return probe.ok ? probe.sessions.filter(s => s.startsWith('bmx-')) : []; + } + + static listDetails(): string { + const probe = ZmxBackend.probeSessions(); + return probe.ok ? probe.raw : ''; + } + + get isReattach(): boolean { + return this.reattaching; + } + + spawn(bin: string, args: string[], opts: SpawnOpts): void { + this.lastOpts = { + ...opts, + env: { ...opts.env }, + injectEnv: opts.injectEnv ? { ...opts.injectEnv } : undefined, + }; + this.cols = opts.cols; + this.rows = opts.rows; + + const probe = ZmxBackend.probeSession(this.sessionName); + if (probe === 'unknown') { + throw new Error(`无法确认 ZMX 会话 ${this.sessionName} 的状态`); + } + + // An explicit reattach decision is sticky. If the session disappears in + // the probe-to-attach race, the sentinel command exits instead of silently + // creating a duplicate CLI with a fresh shell. + this.reattaching = this.reattaching || probe === 'exists'; + const mode: AttachMode = this.reattaching ? 'reattach' : 'fresh'; + logger.debug( + `[zmx:${this.sessionName}] spawn ${mode} ` + + `bin=${bin} args=${JSON.stringify(args)} cwd=${opts.cwd} ${opts.cols}x${opts.rows}`, + ); + this.openAttach(mode, bin, args, opts); + } + + write(data: string): void { + if (!data || this.exited || this.intentionalExit) return; + // Once input has been queued during a reconnect, keep all subsequent input + // behind it until the target is authoritatively live. Letting new bytes go + // direct while the older buffer awaits its probe would reverse FIFO order. + if ( + this.process && + this.state === 'attached' && + !this.recoveryWriteBuffer && + !this.recoveryWriteFlushTimer + ) { + this.process.write(data); + return; + } + const next = this.recoveryWriteBuffer + data; + if (next.length > RECOVERY_WRITE_BUFFER_MAX) { + logger.warn(`[zmx:${this.sessionName}] recovery input buffer full; dropping oldest bytes`); + } + this.recoveryWriteBuffer = next.slice(-RECOVERY_WRITE_BUFFER_MAX); + if (this.process && this.state === 'attached') { + this.scheduleRecoveryWriteFlush(this.epoch, this.process); + } + } + + sendText(text: string): void { + this.write(text); + } + + sendSpecialKeys(...keys: string[]): void { + for (const key of keys) this.write(tmuxKeyToBytes(key)); + } + + pasteText(text: string): void { + this.write(`\x1b[200~${text}\x1b[201~`); + } + + resize(cols: number, rows: number): void { + this.cols = cols; + this.rows = rows; + try { this.process?.resize(cols, rows); } catch { /* client may be reconnecting */ } + try { this.queryTerminal?.resize(cols, rows); } catch { /* responder may be rotating */ } + } + + onData(cb: (data: string) => void): void { + this.dataCbs.push(cb); + if (this.earlyBuffer) { + const buffered = this.earlyBuffer; + this.earlyBuffer = ''; + try { cb(buffered); } catch { /* listener failure must not kill transport */ } + } + } + + onExit(cb: (code: number | null, signal: string | null) => void): void { + this.exitCbs.push(cb); + if (this.pendingExit) { + const exit = this.pendingExit; + this.pendingExit = null; + cb(exit.code, exit.signal); + } + } + + getChildPid(): number | null { + if (this.cliPid) return this.cliPid; + const pid = findSessionPid(this.sessionName); + if (pid) this.cliPid = pid; + return pid; + } + + /** Detach the viewer while leaving the per-session ZMX daemon and CLI alive. */ + kill(): void { + if (this.state === 'stopped' || this.state === 'exited') return; + this.intentionalExit = true; + this.state = 'stopped'; + this.epoch++; + this.clearReconnectTimer(); + this.clearStableAttachTimer(); + this.clearFreshAttachReadyTimer(); + this.clearRecoveryWriteFlushTimer(); + this.clearTerminalResponder(); + this.recoveryWriteBuffer = ''; + this.recoveryWriteProbeAttempt = 0; + const process = this.process; + this.process = null; + try { process?.kill(); } catch { /* already gone */ } + } + + destroySession(): void { + this.kill(); + if ((this.opts.ownsSession ?? true) && !this.preserveSessionOnDestroy) { + ZmxBackend.killSession(this.sessionName); + } + } + + private openAttach(mode: AttachMode, bin: string, args: string[], opts: SpawnOpts): void { + this.clearReconnectTimer(); + this.clearStableAttachTimer(); + this.clearFreshAttachReadyTimer(); + this.clearRecoveryWriteFlushTimer(); + const epoch = ++this.epoch; + let launchPayload: ZmxLaunchPayload | null = null; + const zmxArgs = mode === 'fresh' + ? (() => { + launchPayload = createZmxLaunchPayload(bin, args, opts); + return buildFreshAttachArgs(this.sessionName, launchPayload.bootstrapPath); + })() + : buildReattachArgs(this.sessionName); + + let process: pty.IPty; + try { + process = pty.spawn('zmx', zmxArgs, { + name: 'xterm-256color', + cols: this.cols, + rows: this.rows, + cwd: opts.cwd, + // Per-session and per-bot values are delivered through the 0600 launch + // payload. Keep them out of the long-lived ZMX daemon environment. + env: zmxControlEnv(opts), + }); + } catch (err) { + launchPayload?.cleanup(); + throw err; + } + if (launchPayload) { + const payload = launchPayload as ZmxLaunchPayload; + const cleanupTimer = setTimeout(payload.cleanup, LAUNCH_PAYLOAD_CLEANUP_MS); + cleanupTimer.unref?.(); + } + this.process = process; + this.state = 'connecting'; + this.recoveryWriteProbeAttempt = 0; + this.resetTerminalResponder(process, epoch); + + let freshReadyMarker = launchPayload?.readyMarker ?? null; + let freshCompletionMarker = launchPayload?.completionMarker ?? null; + let freshReleaseSent = false; + let freshReadyBuffer = ''; + let reattachSniffing = mode === 'reattach'; + let reattachSniffBuffer = ''; + let reattachMarkerTail = ''; + + const acceptAttachedData = (attachedData: string) => { + if (epoch !== this.epoch || this.process !== process || this.intentionalExit || this.exited) return; + this.state = 'attached'; + if (!this.stableAttachTimer) { + this.stableAttachTimer = setTimeout(() => { + this.stableAttachTimer = null; + if (epoch === this.epoch && this.state === 'attached') this.reconnectAttempt = 0; + }, 5000); + this.stableAttachTimer.unref?.(); + } + // Keep one authoritative headless terminal beside the attach transport. + // It tracks cursor state and answers terminal queries even with no browser + // connected. ZMX web terminals register no-reply parser handlers so they + // render these zero-width queries without becoming a second responder. + if (attachedData) { + this.queryTerminal?.write(attachedData); + this.emitData(attachedData); + } + this.scheduleRecoveryWriteFlush(epoch, process); + }; + + const quarantineBootstrapAttach = (reason: string) => { + if ( + epoch !== this.epoch || + this.process !== process || + this.intentionalExit || + this.exited + ) return; + freshReadyMarker = null; + freshCompletionMarker = null; + freshReadyBuffer = ''; + reattachSniffing = false; + reattachSniffBuffer = ''; + reattachMarkerTail = ''; + this.clearFreshAttachReadyTimer(); + this.clearStableAttachTimer(); + this.clearRecoveryWriteFlushTimer(); + this.clearTerminalResponder(); + // Before the private release token has been sent it is safe to remove the + // payload. cleanup() deliberately leaves the watchdog guard directory in + // place, so the bootstrap still times out and terminates its ZMX session. + if (!freshReleaseSent) launchPayload?.cleanup(); + logger.error(`[zmx:${this.sessionName}] quarantining unverified bootstrap attach: ${reason}`); + this.preserveSessionOnDestroy = true; + + // Detach this viewer without killing the named session: the ready marker + // may have come from a same-name session owned by another checkout. The + // private bootstrap watchdog is solely responsible for deleting its own + // payload and terminating its own session. Emit one exit only after that + // session is authoritatively missing, so the daemon can restart the worker + // and recompute fresh-vs-reattach from scratch. If it never disappears, + // remain quarantined instead of auto-restarting into a foreign session. + const quarantineEpoch = ++this.epoch; + if (this.process === process) this.process = null; + this.state = 'recovering'; + try { process.kill(); } catch { /* already detached */ } + + const deadline = Date.now() + STALE_BOOTSTRAP_WAIT_MAX_MS; + let overdueWarningEmitted = false; + const poll = () => { + this.reconnectTimer = null; + if ( + quarantineEpoch !== this.epoch || + this.intentionalExit || + this.exited + ) return; + const probe = ZmxBackend.probeSession(this.sessionName); + if (probe === 'missing') { + this.fireExit(75, null); + return; + } + if (Date.now() >= deadline && !overdueWarningEmitted) { + overdueWarningEmitted = true; + logger.error( + `[zmx:${this.sessionName}] quarantined bootstrap did not disappear ` + + `(${probe}); continuing to refuse attach/input until ownership is safe`, + ); + launchPayload?.cleanup(); + } + this.reconnectTimer = setTimeout( + poll, + overdueWarningEmitted ? RECOVERY_DELAY_MAX_MS : STALE_BOOTSTRAP_POLL_MS, + ); + this.reconnectTimer.unref?.(); + }; + this.reconnectTimer = setTimeout(poll, STALE_BOOTSTRAP_POLL_MS); + this.reconnectTimer.unref?.(); + }; + + const failFreshAttach = (reason: string) => { + quarantineBootstrapAttach(`fresh ownership check failed: ${reason}`); + }; + + const armFreshAttachReadyTimer = (reason: string) => { + this.clearFreshAttachReadyTimer(); + this.freshAttachReadyTimer = setTimeout(() => { + failFreshAttach(reason); + }, FRESH_ATTACH_READY_TIMEOUT_MS); + this.freshAttachReadyTimer.unref?.(); + }; + if (freshReadyMarker) { + armFreshAttachReadyTimer('bootstrap ready marker timed out'); + } else if (reattachSniffing) { + // A worker can die after creating the private bootstrap but before sending + // its nonce-bound release token. The surviving ZMX child then repeats a + // ready OSC until its watchdog fires. Briefly quarantine every reattach so + // that marker can be recognized across chunk boundaries before any output + // is exposed or any queued user input is flushed. + this.freshAttachReadyTimer = setTimeout(() => { + this.freshAttachReadyTimer = null; + if ( + epoch !== this.epoch || + this.process !== process || + this.intentionalExit || + this.exited || + !reattachSniffing + ) return; + reattachSniffing = false; + const buffered = reattachSniffBuffer; + reattachSniffBuffer = ''; + acceptAttachedData(buffered); + }, REATTACH_BOOTSTRAP_SNIFF_MS); + this.freshAttachReadyTimer.unref?.(); + } + + process.onData((data) => { + if (epoch !== this.epoch || this.intentionalExit || this.exited) return; + + // Keep scanning after the initial quarantine too. The bootstrap emits at + // 50 ms intervals, but an overloaded host can delay delivery beyond the + // sniff window. Its independent release token means ordinary input still + // cannot launch the CLI; a late marker is quarantined before this chunk is + // forwarded and the watchdog is allowed to reap the stale session. + const markerScan = reattachMarkerTail + data; + if (mode === 'reattach' && ZMX_READY_MARKER_RE.test(markerScan)) { + quarantineBootstrapAttach('stale private bootstrap marker detected during reattach'); + return; + } + reattachMarkerTail = markerScan.slice(-ZMX_READY_MARKER_MAX); + if (reattachSniffing) { + reattachSniffBuffer += data; + if (reattachSniffBuffer.length > FRESH_ATTACH_READY_BUFFER_MAX) { + quarantineBootstrapAttach('reattach bootstrap sniff buffer exceeded limit'); + } + return; + } + + let attachedData = data; + if (freshReadyMarker && freshCompletionMarker) { + freshReadyBuffer += data; + if (freshReadyBuffer.length > FRESH_ATTACH_READY_BUFFER_MAX) { + failFreshAttach('bootstrap ready buffer exceeded limit'); + return; + } + + if (!freshReleaseSent) { + if (!freshReadyBuffer.includes(freshReadyMarker)) return; + freshReleaseSent = true; + // Only our private bootstrap can emit the nonce. Releasing its read + // barrier here proves this PTY created the session instead of attaching + // to a same-name session that appeared after the liveness probe. + try { + process.write(`${launchPayload!.releaseToken}\r`); + } catch { + failFreshAttach('could not release bootstrap barrier'); + return; + } + // The bootstrap repeats its ready marker because output produced + // before the first ZMX client connects is not replayed. Wait for its + // post-release completion marker so no late repeat can leak through. + armFreshAttachReadyTimer('bootstrap completion marker timed out'); + } + + const completionIndex = freshReadyBuffer.indexOf(freshCompletionMarker); + if (completionIndex < 0) return; + attachedData = + freshReadyBuffer.slice(0, completionIndex) + + freshReadyBuffer.slice(completionIndex + freshCompletionMarker.length); + attachedData = attachedData.split(freshReadyMarker).join(''); + freshReadyMarker = null; + freshCompletionMarker = null; + freshReadyBuffer = ''; + this.clearFreshAttachReadyTimer(); + } + acceptAttachedData(attachedData); + }); + process.onExit(({ exitCode, signal }) => { + if (epoch !== this.epoch || this.intentionalExit || this.exited) return; + const freshAttachWasUnverified = freshCompletionMarker !== null; + freshReadyMarker = null; + freshCompletionMarker = null; + freshReadyBuffer = ''; + this.clearFreshAttachReadyTimer(); + this.clearStableAttachTimer(); + this.clearRecoveryWriteFlushTimer(); + this.clearTerminalResponder(); + if (this.process === process) this.process = null; + if (freshAttachWasUnverified) { + launchPayload?.cleanup(); + // An unverified fresh attach may have hit a foreign same-name session. + // Never reconnect to it; surface a deterministic launch failure. + this.fireExit(exitCode === 0 ? 75 : exitCode, null); + return; + } + this.state = 'recovering'; + this.scheduleRecovery( + exitCode, + signal !== undefined && signal !== null && signal !== 0 ? String(signal) : null, + ); + }); + + } + + private scheduleRecovery(code: number | null, signal: string | null): void { + if (this.intentionalExit || this.exited || !this.lastOpts) return; + const delay = Math.min(50 * (2 ** this.reconnectAttempt), RECOVERY_DELAY_MAX_MS); + this.reconnectAttempt++; + this.clearReconnectTimer(); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.intentionalExit || this.exited || !this.lastOpts) return; + + const probe = ZmxBackend.probeSession(this.sessionName); + if (probe === 'missing') { + this.fireExit(code, signal); + return; + } + if (probe === 'unknown') { + this.scheduleRecovery(code, signal); + return; + } + + logger.warn(`[zmx:${this.sessionName}] attach client exited while session is alive; reconnecting`); + try { + this.openAttach('reattach', '/bin/sh', [], this.lastOpts); + } catch (err) { + logger.warn( + `[zmx:${this.sessionName}] attach reconnect failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + this.scheduleRecovery(code, signal); + } + }, delay); + this.reconnectTimer.unref?.(); + } + + private scheduleRecoveryWriteFlush(epoch: number, process: pty.IPty): void { + if ( + !this.recoveryWriteBuffer || + this.recoveryWriteFlushTimer || + epoch !== this.epoch || + this.process !== process || + this.state !== 'attached' + ) return; + + // A reattach TOCTOU sentinel can emit a clear frame before its short-lived + // session exits. Wait for a still-live target before releasing input. An + // inconclusive control-plane probe re-arms with bounded backoff: a quiet + // attach must not strand the buffer forever merely because its first probe + // timed out. + const delay = Math.min( + RECOVERY_WRITE_FLUSH_DELAY_MS * (2 ** this.recoveryWriteProbeAttempt), + RECOVERY_DELAY_MAX_MS, + ); + this.recoveryWriteFlushTimer = setTimeout(() => { + this.recoveryWriteFlushTimer = null; + if ( + epoch !== this.epoch || + this.process !== process || + this.state !== 'attached' || + !this.recoveryWriteBuffer + ) return; + + const probe = ZmxBackend.probeSession(this.sessionName); + if (probe === 'unknown') { + this.recoveryWriteProbeAttempt++; + this.scheduleRecoveryWriteFlush(epoch, process); + return; + } + if (probe === 'missing') return; + + this.recoveryWriteProbeAttempt = 0; + const buffered = this.recoveryWriteBuffer; + this.recoveryWriteBuffer = ''; + try { + process.write(buffered); + } catch { + this.recoveryWriteBuffer = buffered + this.recoveryWriteBuffer; + this.scheduleRecoveryWriteFlush(epoch, process); + } + }, delay); + this.recoveryWriteFlushTimer.unref?.(); + } + + private clearReconnectTimer(): void { + if (!this.reconnectTimer) return; + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private clearStableAttachTimer(): void { + if (!this.stableAttachTimer) return; + clearTimeout(this.stableAttachTimer); + this.stableAttachTimer = null; + } + + private clearFreshAttachReadyTimer(): void { + if (!this.freshAttachReadyTimer) return; + clearTimeout(this.freshAttachReadyTimer); + this.freshAttachReadyTimer = null; + } + + private clearRecoveryWriteFlushTimer(): void { + if (!this.recoveryWriteFlushTimer) return; + clearTimeout(this.recoveryWriteFlushTimer); + this.recoveryWriteFlushTimer = null; + } + + private resetTerminalResponder(process: pty.IPty, epoch: number): void { + this.clearTerminalResponder(); + const terminal = new Terminal({ + cols: this.cols, + rows: this.rows, + allowProposedApi: true, + }); + const respond = (response: string) => { + if ( + epoch !== this.epoch || + this.process !== process || + this.intentionalExit || + this.exited + ) return; + try { process.write(response); } catch { /* transport recovery handles it */ } + }; + terminal.onData(respond); + // Browser xterm has a ThemeService and answers OSC color queries; headless + // xterm intentionally does not. Supply stable colors here so no-browser + // sessions still work and ZMX browser tabs can safely suppress duplicates. + for (const ident of [4, 10, 11, 12] as const) { + terminal.parser.registerOscHandler(ident, (data) => { + const replies = terminalOscColorQueryReplies(ident, data); + if (replies.length === 0) return false; + for (const reply of replies) respond(reply); + return true; + }); + } + this.queryTerminal = terminal; + } + + private clearTerminalResponder(): void { + const terminal = this.queryTerminal; + this.queryTerminal = null; + try { terminal?.dispose(); } catch { /* already disposed */ } + } + + private emitData(data: string): void { + if (this.dataCbs.length === 0) { + const next = this.earlyBuffer + data; + if (next.length > EARLY_BUFFER_MAX && this.earlyBuffer.length <= EARLY_BUFFER_MAX) { + logger.warn(`[zmx:${this.sessionName}] early output exceeded 1 MiB; keeping newest bytes`); + } + this.earlyBuffer = next.slice(-EARLY_BUFFER_MAX); + return; + } + for (const cb of this.dataCbs) { + try { cb(data); } catch { /* listener failure must not kill transport */ } + } + } + + private fireExit(code: number | null, signal: string | null): void { + if (this.exited) return; + this.exited = true; + this.state = 'exited'; + this.clearReconnectTimer(); + this.clearStableAttachTimer(); + this.clearFreshAttachReadyTimer(); + this.clearRecoveryWriteFlushTimer(); + this.clearTerminalResponder(); + this.process = null; + this.recoveryWriteBuffer = ''; + this.recoveryWriteProbeAttempt = 0; + if (this.exitCbs.length === 0) { + this.pendingExit = { code, signal }; + return; + } + for (const cb of this.exitCbs) { + try { cb(code, signal); } catch { /* listener failure must not block teardown */ } + } + } +} + +/** + * The command is ignored when the named session exists. If it disappeared + * after the liveness probe, the sentinel exits immediately instead of creating + * an unrelated interactive shell. + */ +export function buildReattachArgs(sessionName: string): string[] { + return ['attach', sessionName, '/bin/sh', '-c', 'exit 75']; +} + +export function buildFreshAttachArgs( + sessionName: string, + bootstrapPath: string, +): string[] { + // The ZMX daemon retains its original command for the whole session and + // exposes it through `zmx list`. Keep cwd, env values and CLI argv (including + // an initial user prompt) out of that retained command. The 0600 bootstrap + // unlinks itself before starting the real shell. + return ['attach', sessionName, '/bin/sh', bootstrapPath]; +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * Render the two private files used for a fresh launch. The bootstrap contains + * no user prompt or environment values; the payload is sourced only after the + * user's rcfiles load, then immediately unlinked. Keeping `set --` in the file + * also prevents arbitrary argv bytes from becoming shell syntax. + */ +export function buildZmxLaunchFiles( + bin: string, + args: string[], + opts: SpawnOpts, + payloadPath: string, + readyMarker: string, + completionMarker: string, + releaseToken: string, +): { bootstrap: string; payload: string } { + const shellSpec = resolveUserShell(process.env, opts.launchShell); + const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv) + .filter(assignment => !/^ZMX_(?:SESSION|SESSION_PREFIX)=/.test(assignment)); + const debugKeepShell = process.env.BOTMUX_DEBUG_KEEP_SHELL === '1'; + const wrapped = debugKeepShell + ? buildDebugKeepShellScript(shellSpec.shell) + : SHELL_WRAPPER_SCRIPT; + const payloadArgv = [opts.cwd, ...envAssignments, bin, ...args]; + const payload = `set -- ${payloadArgv.map(shellSingleQuote).join(' ')}\n`; + const userScript = [ + 'payload=$1', + 'if [ ! -r "$payload" ]; then printf "[botmux] ZMX launch payload unavailable\\n" >&2; exit 126; fi', + '. "$payload" || exit 126', + 'rm -f -- "$payload"', + 'payload_dir=${payload%/*}', + 'rmdir -- "$payload_dir" 2>/dev/null || true', + 'unset payload payload_dir ZMX_SESSION ZMX_SESSION_PREFIX', + wrapped, + ].join('\n'); + const shellCommand = [ + shellSingleQuote(shellSpec.shell), + ...shellSpec.flags.map(shellSingleQuote), + '-c', shellSingleQuote(userScript), + '_', shellSingleQuote(payloadPath), + ].join(' '); + const bootstrap = [ + '#!/bin/sh', + 'self=$0', + 'rm -f -- "$self"', + 'unset self ZMX_SESSION ZMX_SESSION_PREFIX', + `payload_path=${shellSingleQuote(payloadPath)}`, + 'payload_dir=${payload_path%/*}', + 'watchdog_guard="$payload_dir/bootstrap-watchdog"', + 'mkdir -- "$watchdog_guard" || exit 126', + 'bootstrap_pid=$$', + // The nonce distinguishes a newly executed private bootstrap from the + // probe-to-attach race where ZMX ignores this command and attaches to an + // existing same-name session. The daemon can start before its first client, + // so repeat it until botmux releases the read barrier. Both markers are + // stripped before terminal processing. + '(', + ' while :; do', + ` printf '%s' ${shellSingleQuote(readyMarker)}`, + ' sleep 0.05', + ' done', + ') &', + 'ready_marker_pid=$!', + '(', + ` sleep ${FRESH_BOOTSTRAP_WATCHDOG_SECONDS}`, + ' if rmdir -- "$watchdog_guard" 2>/dev/null; then', + ' kill "$ready_marker_pid" 2>/dev/null || true', + ' wait "$ready_marker_pid" 2>/dev/null || true', + ' rm -f -- "$payload_path"', + ' rmdir -- "$payload_dir" 2>/dev/null || true', + ' kill -TERM "$bootstrap_pid" 2>/dev/null || true', + ' fi', + ') &', + 'watchdog_pid=$!', + 'stop_ready_marker() {', + ' kill "$ready_marker_pid" 2>/dev/null || true', + ' wait "$ready_marker_pid" 2>/dev/null || true', + '}', + 'stop_watchdog() {', + ' kill "$watchdog_pid" 2>/dev/null || true', + ' wait "$watchdog_pid" 2>/dev/null || true', + '}', + 'cleanup_uncommitted_launch() {', + ' stop_ready_marker', + ' stop_watchdog', + ' if [ "${launch_committed:-0}" != 1 ]; then', + ' rmdir -- "$watchdog_guard" 2>/dev/null || true', + ' rm -f -- "$payload_path"', + ' rmdir -- "$payload_dir" 2>/dev/null || true', + ' fi', + '}', + 'launch_committed=0', + "trap 'cleanup_uncommitted_launch' 0", + "trap 'exit 75' 1 2 15", + // The release capability is independent from the visible ready nonce and + // exists only in this already-unlinked 0600 bootstrap plus worker memory. + // A stale reattach's ordinary user input can therefore only abort the + // bootstrap; it can never authorize launching a second CLI. + 'stty -echo 2>/dev/null || exit 126', + `IFS= read -r release_line && [ "$release_line" = ${shellSingleQuote(releaseToken)} ] || exit 75`, + 'unset release_line', + 'rmdir -- "$watchdog_guard" 2>/dev/null || exit 75', + 'stop_watchdog', + 'stop_ready_marker', + 'stty echo 2>/dev/null || exit 126', + 'launch_committed=1', + 'trap - 0 1 2 15', + 'unset ready_marker_pid watchdog_pid watchdog_guard bootstrap_pid launch_committed', + `printf '%s' ${shellSingleQuote(completionMarker)}`, + `exec ${shellCommand}`, + '', + ].join('\n'); + return { bootstrap, payload }; +} + +function createZmxLaunchPayload(bin: string, args: string[], opts: SpawnOpts): ZmxLaunchPayload { + const dir = mkdtempSync(join(tmpdir(), 'botmux-zmx-launch-')); + chmodSync(dir, 0o700); + const bootstrapPath = join(dir, 'bootstrap.sh'); + const payloadPath = join(dir, 'payload.sh'); + const cleanup = () => { + // Never recursively remove the directory: a running bootstrap's watchdog + // uses its child guard directory as an atomic release-vs-timeout lock. It is + // safe to remove the private files early, but deleting that guard would + // strand the bootstrap (and its ZMX daemon) at the read barrier forever. + try { rmSync(payloadPath, { force: true }); } catch { /* already consumed */ } + try { rmSync(bootstrapPath, { force: true }); } catch { /* already unlinked */ } + try { rmdirSync(dir); } catch { /* watchdog guard or active bootstrap remains */ } + }; + try { + const markerNonce = randomBytes(16).toString('hex'); + const releaseToken = randomBytes(16).toString('hex'); + // Use an otherwise inert private OSC sequence: ZMX forwards the raw bytes + // to this client, while its terminal snapshot does not render the nonce or + // retain it as visible scrollback for a later reattach. + const readyMarker = `\x1b]5150;botmux-zmx-ready=${markerNonce}\x1b\\`; + const completionMarker = `\x1b]5150;botmux-zmx-started=${markerNonce}\x1b\\`; + const files = buildZmxLaunchFiles( + bin, + args, + opts, + payloadPath, + readyMarker, + completionMarker, + releaseToken, + ); + writeFileSync(payloadPath, files.payload, { mode: 0o600, flag: 'wx' }); + writeFileSync(bootstrapPath, files.bootstrap, { mode: 0o600, flag: 'wx' }); + return { bootstrapPath, readyMarker, completionMarker, releaseToken, cleanup }; + } catch (err) { + cleanup(); + throw err; + } +} + +/** Strip every payload-delivered key from the persistent ZMX control process. */ +export function zmxControlEnv(opts: SpawnOpts): NodeJS.ProcessEnv { + const env = zmxEnv(opts.env); + for (const assignment of buildBotmuxEnvAssignments(opts.env, opts.injectEnv)) { + const equals = assignment.indexOf('='); + if (equals > 0) delete env[assignment.slice(0, equals)]; + } + if (opts.injectEnv) { + for (const key of Object.keys(opts.injectEnv)) delete env[key]; + } + return env; +} + +function colorToOscRgb(hex: string): string { + const value = hex.startsWith('#') ? hex.slice(1) : hex; + const parts = [0, 2, 4].map(offset => { + const byte = Number.parseInt(value.slice(offset, offset + 2), 16); + return (byte * 257).toString(16).padStart(4, '0'); + }); + return `rgb:${parts.join('/')}`; +} + +function indexedTerminalColor(index: number): string | null { + if (!Number.isInteger(index) || index < 0 || index > 255) return null; + if (index < TERMINAL_ANSI.length) return TERMINAL_ANSI[index]!; + if (index < 232) { + const n = index - 16; + const levels = [0, 95, 135, 175, 215, 255]; + const red = levels[Math.floor(n / 36)]!; + const green = levels[Math.floor((n % 36) / 6)]!; + const blue = levels[n % 6]!; + return `#${[red, green, blue].map(v => v.toString(16).padStart(2, '0')).join('')}`; + } + const gray = 8 + (index - 232) * 10; + const byte = gray.toString(16).padStart(2, '0'); + return `#${byte}${byte}${byte}`; +} + +/** Replies for the OSC color-query families handled by browser xterm's theme service. */ +export function terminalOscColorQueryReplies( + ident: 4 | 10 | 11 | 12, + data: string, +): string[] { + if (ident === 4) { + const parts = data.split(';'); + if (parts.length < 2 || parts.length % 2 !== 0) return []; + const replies: string[] = []; + for (let i = 0; i < parts.length; i += 2) { + if (!/^\d+$/.test(parts[i]!)) return []; + if (parts[i + 1] !== '?') continue; + const index = Number(parts[i]); + const color = indexedTerminalColor(index); + if (!color) return []; + replies.push(`\x1b]4;${index};${colorToOscRgb(color)}\x1b\\`); + } + return replies; + } + + const colors = [TERMINAL_FG, TERMINAL_BG, TERMINAL_CURSOR]; + const replies: string[] = []; + for (const [offset, token] of data.split(';').entries()) { + const target = ident + offset; + if (token !== '?' || target > 12) continue; + replies.push(`\x1b]${target};${colorToOscRgb(colors[target - 10]!)}\x1b\\`); + } + return replies; +} + +export function parseZmxList(output: string): { + sessions: string[]; + unhealthySessions: string[]; + malformedLines: string[]; +} { + const sessions: string[] = []; + const unhealthySessions: string[] = []; + const malformedLines: string[] = []; + let sawRecord = false; + for (const line of output.split('\n')) { + if (!line.trim()) continue; + const looksLikeRecord = /^\s*name=[^\t]*\t(?:pid=\d+(?:\t|$)|err=)/.test(line); + if (!looksLikeRecord) { + // The full list renders cmd= verbatim, including literal newlines. Once + // a real row has started, even a continuation beginning with `name=` is + // opaque unless it also has ZMX's tab-delimited pid=/err= status field. + // A warning or changed row format before the first record is malformed. + if (!sawRecord) malformedLines.push(line); + continue; + } + sawRecord = true; + const row = parseZmxListRow(line); + if (!row) { + malformedLines.push(line); + } else if (row.state === 'unhealthy') { + unhealthySessions.push(row.name); + } else { + sessions.push(row.name); + } + } + return { sessions, unhealthySessions, malformedLines }; +} + +export function parseZmxShortList(output: string): { + sessions: string[]; + malformedLines: string[]; +} { + const sessions: string[] = []; + const malformedLines: string[] = []; + const seen = new Set(); + for (const raw of output.split('\n')) { + // ZMX 0.6 permits ordinary spaces in session names. `--short` is one name + // per line, so preserve them verbatim (apart from a CRLF terminator) and + // reject only bytes that make that line boundary/status protocol ambiguous. + const name = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + if (!name) continue; + if (/[ \x00-\x1f\x7f]/.test(name) || seen.has(name)) { + malformedLines.push(raw); + continue; + } + seen.add(name); + sessions.push(name); + } + return { sessions, malformedLines }; +} + +function parseZmxListRow(line: string): { + name: string; + state: 'healthy' | 'unhealthy'; + pid?: number; +} | null { + // The first field is the name and the SECOND tab-delimited field is the + // authoritative status (`pid=...` or `err=...`). Later fields include the + // cwd and full command; treating an `err=`/`pid=` substring there as status + // would misclassify a healthy session whose path or argv contains that text. + const fields = line.replace(/^\s*/, '').split('\t'); + const nameField = fields[0]; + const name = nameField?.startsWith('name=') ? nameField.slice('name='.length) : ''; + const status = fields[1]; + if (!name || /[\x00-\x1f\x7f]/.test(name) || !status) return null; + const pid = status.match(/^pid=(\d+)$/)?.[1]; + if (pid) return { name, state: 'healthy', pid: Number(pid) }; + if (/^err=/.test(status)) return { name, state: 'unhealthy' }; + return null; +} + +export function findSessionPid(sessionName: string): number | null { + const probe = ZmxBackend.probeSessions(); + if (!probe.ok || !probe.sessions.includes(sessionName)) return null; + const matches: number[] = []; + for (const line of probe.raw.split('\n')) { + const row = parseZmxListRow(line); + if (row?.name === sessionName && row.state === 'healthy' && row.pid) matches.push(row.pid); + } + // A multiline command can forge a record-shaped continuation. Refuse an + // ambiguous PID rather than signaling or stamping the wrong process. + return matches.length === 1 ? matches[0]! : null; +} + +export function tmuxKeyToBytes(key: string): string { + const named: Record = { + Enter: '\r', + Tab: '\t', + Escape: '\x1b', + Esc: '\x1b', + Space: ' ', + BSpace: '\x7f', + Backspace: '\x7f', + Up: '\x1b[A', + Down: '\x1b[B', + Right: '\x1b[C', + Left: '\x1b[D', + Home: '\x1b[H', + End: '\x1b[F', + PageUp: '\x1b[5~', + PPage: '\x1b[5~', + PageDown: '\x1b[6~', + NPage: '\x1b[6~', + 'M-Enter': '\x1b\r', + }; + if (key in named) return named[key]!; + + const ctrl = key.match(/^C-([A-Za-z])$/); + if (ctrl) return String.fromCharCode(ctrl[1]!.toLowerCase().charCodeAt(0) - 96); + const meta = key.match(/^M-(.)$/); + if (meta) return `\x1b${meta[1]}`; + return key; +} diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 9f8c61149..893a8654f 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -885,7 +885,7 @@ export interface BotConfig { */ wrapperCli?: string; /** - * Per-bot launch-shell override for the persistent backends (tmux/zellij). + * Per-bot launch-shell override for the persistent backends (tmux/zellij/zmx). * When set, botmux launches the CLI under this shell instead of the daemon's * `$SHELL`. Accepts a bare name (`zsh`/`bash`/`sh`) or an absolute path * (`/usr/bin/zsh`). The escape hatch for a login `$SHELL` (e.g. bash) whose diff --git a/src/cli.ts b/src/cli.ts index b1c8fb8c6..81e9f366d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,8 +13,9 @@ * botmux status — show daemon status * botmux upgrade|update — upgrade to latest version * botmux device enroll|status|logout — manage the host desktop device credential - * botmux list — interactive session picker (TUI), attach to tmux + * botmux list — interactive session picker (TUI), attach to managed tmux/ZMX sessions * botmux list --plain — plain table output (for piping / scripts) + * botmux title [--session-id ] — rename a session title * botmux delete <id> — close a session by ID prefix * botmux delete all — close all active sessions * botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd / Windows Task Scheduler) @@ -38,6 +39,7 @@ import { pickTurnReplyTarget } from './core/reply-target.js'; import { recordDispatchRegistryEntry } from './core/dispatch-registry.js'; import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js'; import { tmuxEnv } from './setup/ensure-tmux.js'; +import { zmxEnv } from './setup/ensure-zmx.js'; import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js'; import { applyBotConfigEdits, @@ -76,6 +78,7 @@ import { import { interactiveSelect, pickChoice, pickCliSelection } from './setup/interactive-select.js'; import { buildPreset, serializePreset, presetFilename } from './setup/agent-preset.js'; import type { CliId } from './adapters/cli/types.js'; +import type { BackendType, SessionProbe } from './adapters/backend/types.js'; import { logger } from './utils/logger.js'; import { scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js'; import { scheduleTimeZone } from './utils/timezone.js'; @@ -164,6 +167,15 @@ import { stopExactPm2Process, type BotmuxPm2Inspection, } from './core/bot-live-control.js'; +import { + isSuspendableBackendType, + killPersistentSession, + persistentSessionName, + probePersistentSession, + probePersistentSessions, + type PersistentBackendType, +} from './core/persistent-backend.js'; +import { normalizeSessionTitle } from './core/session-board.js'; // Resolve the CLI's UI locale once from the global config file, so subsequent // CLI output (and any t() callers that don't pass an explicit locale) honour @@ -1333,9 +1345,9 @@ async function promptEditBotConfig( } printInputHelp('会话后端 backendType', [ - '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr 支持托管持久会话;zellij 为实验后端(需 zellij >= 0.44)。', + '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr/zmx 支持托管持久会话;zellij 为实验后端(需 zellij >= 0.44)。', '选择 traex + herdr 时,可在 Dashboard Settings 中开启 TraeX herdr plugin opt-in 并填写可信插件 spec;默认不会自动安装第三方插件。', - '留空保留当前值;输入 - 回到自动检测;接受 pty / tmux / herdr / zellij。', + '留空保留当前值;输入 - 回到全局默认(未设置 BACKEND_TYPE 时为 tmux);接受 pty / tmux / herdr / zellij / zmx。', ]); input.backendType = await ask(rl, `会话后端 backendType [${formatOptionalValue(bot.backendType)}]: `); @@ -3074,6 +3086,8 @@ interface SessionData { memberEpoch: number; }; title: string; + titleUpdatedAt?: string; + titleSource?: string; status: 'active' | 'closed'; createdAt: string; lastMessageAt?: string; @@ -3108,6 +3122,7 @@ interface SessionData { // here, so they're typed loosely. Used by cmdList to avoid reporting an // unconfirmed /adopt scratch as a crashed CLI session. cliId?: string; + backendType?: BackendType; lastCliInput?: string; adoptedFrom?: AdoptedFromData; /** Deliberately suspended by the resident-session cap. No process/backing @@ -3343,6 +3358,7 @@ function formatSessionRow( multiBot: boolean, botLabels: Map<string, string>, cols: { id: number; bot?: number; title: number; dir: number; pid: number; uptime: number; status: number; target: number }, + probeSnapshot: BackingProbeSnapshot, ): { text: string; alive: boolean } { const id = padEndDisplay(s.sessionId.substring(0, 8), cols.id); const parts = [id]; @@ -3357,13 +3373,13 @@ function formatSessionRow( const uptime = formatDuration(Date.now() - new Date(s.createdAt).getTime()).padEnd(cols.uptime); const alive = isSessionAliveForList(s); const status = padEndDisplay(sessionStatusLabel(s), cols.status); - const target = padEndDisplay(truncate(sessionTargetLabel(s), cols.target), cols.target); + const target = padEndDisplay(truncate(sessionTargetLabel(s, probeSnapshot), cols.target), cols.target); parts.push(title, dir, pid, uptime, status, target); return { text: parts.join(' │ '), alive }; } /** Print plain session table (non-interactive). */ -function printSessionTable(active: SessionData[]): void { +function printSessionTable(active: SessionData[], probeSnapshot: BackingProbeSnapshot): void { const botConfigs = loadBotConfigsForDisplay(); const multiBot = botConfigs.length > 1 || new Set(active.map(s => s.larkAppId).filter(Boolean)).size > 1; const botLabels = new Map<string, string>(); @@ -3392,7 +3408,7 @@ function printSessionTable(active: SessionData[]): void { console.log(separator); for (const s of active) { - const { text } = formatSessionRow(s, multiBot, botLabels, cols); + const { text } = formatSessionRow(s, multiBot, botLabels, cols, probeSnapshot); console.log(text); } @@ -3400,16 +3416,6 @@ function printSessionTable(active: SessionData[]): void { console.log(`共 ${active.length} 个活跃会话`); } -/** Check if a tmux session exists. */ -function tmuxSessionExists(name: string): boolean { - try { - execSync(`tmux has-session -t ${name} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() }); - return true; - } catch { - return false; - } -} - function applyTmuxWindowSizeLargest(sessionName: string): void { try { execFileSync('tmux', ['set-option', '-t', sessionName, 'window-size', 'largest'], { @@ -3549,14 +3555,104 @@ function sessionStatusLabel(s: SessionData): string { return s.pid && isProcessAlive(s.pid) ? 'online' : s.pid ? 'stopped' : 'idle'; } -function sessionTargetLabel(s: SessionData, tmuxName?: string, hasTmux?: boolean): string { +type BackingProbeSnapshot = ReadonlyMap<string, SessionProbe>; + +function backingProbeKey(backendType: PersistentBackendType, name: string): string { + return `${backendType}\0${name}`; +} + +function buildBackingProbeSnapshot(sessions: readonly SessionData[]): BackingProbeSnapshot { + const namesByBackend = new Map<PersistentBackendType, Set<string>>(); + const add = (backendType: PersistentBackendType, name: string) => { + const names = namesByBackend.get(backendType) ?? new Set<string>(); + names.add(name); + namesByBackend.set(backendType, names); + }; + + for (const session of sessions) { + if (isAdoptedSession(session) || session.backendType === 'pty') continue; + if (isSuspendableBackendType(session.backendType)) { + add(session.backendType, persistentSessionName(session.backendType, session.sessionId)); + } else { + // Rows created before backend stamping used tmux as their only + // externally attachable backing session. + add('tmux', `bmx-${session.sessionId.substring(0, 8)}`); + } + } + + const snapshot = new Map<string, SessionProbe>(); + for (const [backendType, names] of namesByBackend) { + for (const [name, probe] of probePersistentSessions(backendType, names)) { + snapshot.set(backingProbeKey(backendType, name), probe); + } + } + return snapshot; +} + +function backingProbe( + snapshot: BackingProbeSnapshot | undefined, + backendType: PersistentBackendType, + name: string, +): SessionProbe { + return snapshot?.get(backingProbeKey(backendType, name)) + ?? probePersistentSession(backendType, name); +} + +function sessionBackingInfo(s: SessionData, snapshot?: BackingProbeSnapshot): { + backendType?: BackendType; + name?: string; + probe: 'exists' | 'missing' | 'unknown'; + label: string; + attachBackend?: 'tmux' | 'zmx'; +} { + if (isSuspendableBackendType(s.backendType)) { + const name = persistentSessionName(s.backendType, s.sessionId); + const probe = backingProbe(snapshot, s.backendType, name); + const suffix = probe === 'exists' ? '' : ` (${probe})`; + return { + backendType: s.backendType, + name, + probe, + label: `${s.backendType}: ${name}${suffix}`, + attachBackend: s.backendType === 'tmux' || s.backendType === 'zmx' + ? s.backendType + : undefined, + }; + } + if (s.backendType === 'pty') { + return { backendType: 'pty', probe: 'missing', label: 'pty' }; + } + // Legacy rows predate backend stamping. Only tmux was externally attachable. + const name = `bmx-${s.sessionId.substring(0, 8)}`; + const probe = backingProbe(snapshot, 'tmux', name); + return { + backendType: 'tmux', + name, + probe, + label: probe === 'exists' ? `tmux: ${name}` : '-', + attachBackend: 'tmux', + }; +} + +function sessionTargetLabel(s: SessionData, snapshot?: BackingProbeSnapshot): string { if (isAdoptedSession(s)) return adoptTargetLabel(s); - if (hasTmux === undefined) { - const name = tmuxName ?? `bmx-${s.sessionId.substring(0, 8)}`; - hasTmux = tmuxSessionExists(name); - tmuxName = name; + return sessionBackingInfo(s, snapshot).label; +} + +function hasRecoverableBackingSession(s: SessionData, snapshot?: BackingProbeSnapshot): boolean { + if (isSuspendableBackendType(s.backendType)) { + // Unknown means the backend probe itself was inconclusive; keep the session + // rather than closing a potentially recoverable conversation from `list`. + // ZMX has one daemon per session. A clean "missing" result cannot + // distinguish a host reboot from an individual CLI exit, so keep the + // transcript-backed row for lazy resume instead of auto-pruning it. + if (s.backendType === 'zmx') return true; + const name = persistentSessionName(s.backendType, s.sessionId); + const probe = backingProbe(snapshot, s.backendType, name); + return probe === 'exists' || probe === 'unknown'; } - return hasTmux ? `tmux: ${tmuxName}` : '-'; + // Legacy sessions created before backendType stamping only had tmux recovery. + return backingProbe(snapshot, 'tmux', `bmx-${s.sessionId.substring(0, 8)}`) === 'exists'; } /** Shorten path for display: replace $HOME with ~. */ @@ -3566,7 +3662,7 @@ function shortenPath(p: string): string { } /** Interactive TUI session picker — returns a promise that resolves when done. */ -function interactiveSessionPicker(active: SessionData[]): Promise<void> { +function interactiveSessionPicker(active: SessionData[], probeSnapshot: BackingProbeSnapshot): Promise<void> { const botConfigs = loadBotConfigsForDisplay(); const multiBot = botConfigs.length > 1 || new Set(active.map(s => s.larkAppId).filter(Boolean)).size > 1; const botLabels = new Map<string, string>(); @@ -3603,17 +3699,19 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { session: SessionData; text: string; alive: boolean; - tmuxName: string; - hasTmux: boolean; + backendName?: string; + backingProbe: 'exists' | 'missing' | 'unknown'; + attachBackend?: 'tmux' | 'zmx'; isAdopt: boolean; targetLabel: string; canAttach: boolean; }> { return active.map(s => { - const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`; const isAdopt = isAdoptedSession(s); - const hasTmux = !isAdopt && tmuxSessionExists(tmuxName); - const targetLabel = sessionTargetLabel(s, tmuxName, hasTmux); + const backing = isAdopt + ? { probe: 'missing' as const, label: adoptTargetLabel(s) } + : sessionBackingInfo(s, probeSnapshot); + const targetLabel = backing.label; // Build row text with shortened dir const id = padEndDisplay(s.sessionId.substring(0, 8), cols.id); const parts = [id]; @@ -3631,7 +3729,17 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { const target = padEndDisplay(truncate(targetLabel, cols.target), cols.target); parts.push(title, dir, pid, uptime, status, target); - return { session: s, text: parts.join(' │ '), alive, tmuxName, hasTmux, isAdopt, targetLabel, canAttach: hasTmux && !isAdopt }; + return { + session: s, + text: parts.join(' │ '), + alive, + backendName: 'name' in backing ? backing.name : undefined, + backingProbe: backing.probe, + attachBackend: 'attachBackend' in backing ? backing.attachBackend : undefined, + isAdopt, + targetLabel, + canAttach: !isAdopt && backing.probe === 'exists' && !!('attachBackend' in backing && backing.attachBackend), + }; }); } @@ -3657,6 +3765,7 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { let cursor = 0; let confirmDelete = false; // true when waiting for y/n confirmation + let deleting = false; let flashMsg = ''; function render(): void { @@ -3692,9 +3801,9 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { const selected = rows[cursor]; const targetHint = selected.isAdopt ? `\x1b[33m${selected.targetLabel}\x1b[0m \x1b[2mEnter 已禁用;请直接使用原 tmux/zellij/herdr 客户端。\x1b[0m` - : selected.hasTmux - ? `\x1b[32mtmux: ${selected.tmuxName}\x1b[0m` - : `\x1b[2mtmux: 无会话\x1b[0m`; + : selected.canAttach + ? `\x1b[32m${selected.attachBackend}: ${selected.backendName}\x1b[0m` + : `\x1b[2m${selected.targetLabel}(不可连接)\x1b[0m`; process.stdout.write(`\n ${targetHint}\n`); // Flash message or confirmation prompt @@ -3738,6 +3847,13 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { flashMsg = `\x1b[31m✗ 删除失败: ${result.error}\x1b[0m`; return; } + if (daemonClose.state === 'offline') { + // Offline fallback. For adopted sessions, never kill the user's + // original CLI pid if an old record stored it in `pid`. + const originalPid = adoptedCliPid(s); + if (s.pid && s.pid !== originalPid && isProcessAlive(s.pid)) { + killProcess(s.pid); + } // Remove from active list and TUI rows const activeIdx = active.indexOf(s); @@ -3802,7 +3918,7 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { return; } - // Enter — attach to tmux + // Enter — attach to a managed persistent backend. if (key === '\r' || key === '\n') { const selected = rows[cursor]; if (selected.isAdopt) { @@ -3811,16 +3927,26 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { return; } if (!selected.canAttach) { - flashMsg = '\x1b[33m该会话没有 tmux,无法连接\x1b[0m'; + flashMsg = '\x1b[33m该会话没有可连接的持久后端\x1b[0m'; render(); return; } - applyTmuxWindowSizeLargest(selected.tmuxName); cleanup(); - spawnSync('tmux', ['attach-session', '-t', selected.tmuxName], { - stdio: 'inherit', - env: tmuxEnv(), - }); + if (selected.attachBackend === 'zmx') { + // The sentinel is ignored by an existing session. If it disappears + // between probe and attach, it exits immediately instead of leaving + // an accidental login shell behind. + spawnSync('zmx', ['attach', selected.backendName!, '/bin/sh', '-c', 'exit 75'], { + stdio: 'inherit', + env: zmxEnv(), + }); + } else { + applyTmuxWindowSizeLargest(selected.backendName!); + spawnSync('tmux', ['attach-session', '-t', `=${selected.backendName!}`], { + stdio: 'inherit', + env: tmuxEnv(), + }); + } resolve(); return; } @@ -3831,8 +3957,13 @@ function interactiveSessionPicker(active: SessionData[]): Promise<void> { async function cmdList(): Promise<void> { const sessions = loadSessions(); const active = [...sessions.values()].filter(s => s.status === 'active'); + // One immutable control-plane snapshot per invocation. In particular, ZMX's + // full-list probe walks every per-session daemon, so running it once per row + // would make a large session list quadratic and amplify socket timeouts. + const probeSnapshot = buildBackingProbeSnapshot(active); - // Auto-prune unrecoverable sessions: process dead and no tmux session. + // Auto-prune unrecoverable sessions: process dead and no recoverable backing + // session (tmux/herdr/zellij/zmx). // Split into two buckets so a never-activated daemon-command scratch (e.g. an // unconfirmed /adopt that only posted a picker card, /help, an abandoned // /relay picker) isn't reported as a crashed CLI. Such a scratch never forked @@ -3858,24 +3989,45 @@ async function cmdList(): Promise<void> { } const hasPid = !!(s.pid && isProcessAlive(s.pid)); - const hasTmux = tmuxSessionExists(`bmx-${s.sessionId.substring(0, 8)}`); - const disposition = sessionListDisposition(s, { hasPid, hasBackingSession: hasTmux }); + const hasBackingSession = hasRecoverableBackingSession(s, probeSnapshot); + const disposition = sessionListDisposition(s, { hasPid, hasBackingSession }); if (disposition === 'prune_real') pruned.push(s); else if (disposition === 'prune_scratch') prunedScratch.push(s); else live.push(s); } - const closeNow = (arr: SessionData[]) => { + const closeOffline = (s: SessionData) => { + s.status = 'closed'; + s.closedAt = new Date().toISOString(); + saveSession(s); + }; + const closeNow = async (arr: SessionData[], kind: 'scratch' | 'real'): Promise<number> => { + let closed = 0; for (const s of arr) { - s.status = 'closed'; - s.closedAt = new Date().toISOString(); - saveSession(s); + const daemonClose = await closeSessionViaDaemon(s); + if (daemonClose.state === 'closed') { + closed++; + } else if (daemonClose.state === 'offline') { + closeOffline(s); + closed++; + } else { + // Keep it visible: mutating only the store while a possible owner + // daemon still has the row in memory lets the next message resurrect + // exactly the session auto-prune claimed to close. + live.push(s); + console.warn( + `⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}:${daemonClose.reason}`, + ); + } } + return closed; }; // Scratches: close silently — they were placeholders, not dead sessions. - closeNow(prunedScratch); + await closeNow(prunedScratch, 'scratch'); if (pruned.length > 0) { - closeNow(pruned); - console.log(`已自动清理 ${pruned.length} 个不可恢复的会话(进程已退出或无可恢复后端)`); + const closed = await closeNow(pruned, 'real'); + if (closed > 0) { + console.log(`已自动清理 ${closed} 个不可恢复的会话(进程已退出或无可恢复后端)`); + } } // Sort by creation time, newest first @@ -3888,12 +4040,12 @@ async function cmdList(): Promise<void> { // Non-TTY (piped output) or explicit --plain flag: plain table if (!process.stdout.isTTY || process.argv.includes('--plain')) { - printSessionTable(live); + printSessionTable(live, probeSnapshot); return; } // Interactive TUI - await interactiveSessionPicker(live); + await interactiveSessionPicker(live, probeSnapshot); } async function cmdDelete(): Promise<void> { @@ -3905,6 +4057,7 @@ async function cmdDelete(): Promise<void> { const sessions = loadSessions(); const active = [...sessions.values()].filter(s => s.status === 'active'); + const probeSnapshot = buildBackingProbeSnapshot(active); if (active.length === 0) { console.log('没有活跃会话。'); @@ -3928,8 +4081,7 @@ async function cmdDelete(): Promise<void> { return pid ? !isProcessAlive(pid) : !(s.pid && isProcessAlive(s.pid)); } const hasPid = !!(s.pid && isProcessAlive(s.pid)); - const hasTmux = tmuxSessionExists(`bmx-${s.sessionId.substring(0, 8)}`); - return !hasPid && !hasTmux; + return !hasPid && !hasRecoverableBackingSession(s, probeSnapshot); }); if (toDelete.length === 0) { console.log('没有 stopped 状态的会话。'); @@ -4187,24 +4339,19 @@ async function cmdRoleSwitch(argv: string[]): Promise<void> { * so SESSION_DATA_DIR / breadcrumb-overridden deployments find the right * descriptor directory. */ -function listOnlineDaemons(): Array<{ +interface DaemonDescriptorLite { ipcPort: number; larkAppId: string; + pid?: number; bootInstanceId?: string; workflowIpcProtocol?: string; lastHeartbeat?: number; -}> { +} + +function listDaemonDescriptors(): DaemonDescriptorLite[] { const regDir = join(resolveDataDir(), 'dashboard-daemons'); if (!existsSync(regDir)) return []; - const STALE_MS = 90_000; - const now = Date.now(); - const all: Array<{ - ipcPort: number; - larkAppId: string; - bootInstanceId?: string; - workflowIpcProtocol?: string; - lastHeartbeat?: number; - }> = []; + const all: DaemonDescriptorLite[] = []; let names: string[] = []; try { names = readdirSync(regDir); } catch { return []; } for (const f of names) { @@ -4212,29 +4359,34 @@ function listOnlineDaemons(): Array<{ try { const d = JSON.parse(readFileSync(join(regDir, f), 'utf-8')); if (typeof d?.ipcPort !== 'number' || typeof d?.larkAppId !== 'string') continue; - if (now - (d.lastHeartbeat ?? 0) > STALE_MS) continue; all.push({ ipcPort: d.ipcPort, larkAppId: d.larkAppId, + ...(typeof d.pid === 'number' ? { pid: d.pid } : {}), ...(typeof d.bootInstanceId === 'string' && d.bootInstanceId ? { bootInstanceId: d.bootInstanceId } : {}), ...(typeof d.workflowIpcProtocol === 'string' && d.workflowIpcProtocol ? { workflowIpcProtocol: d.workflowIpcProtocol } : {}), - lastHeartbeat: d.lastHeartbeat, + ...(typeof d.lastHeartbeat === 'number' ? { lastHeartbeat: d.lastHeartbeat } : {}), }); } catch { /* skip malformed */ } } return all; } -function findDaemon(larkAppId?: string): { - ipcPort: number; - larkAppId: string; - bootInstanceId?: string; - workflowIpcProtocol?: string; -} | null { +function daemonDescriptorDefinitelyDead(d: DaemonDescriptorLite): boolean { + return typeof d.pid === 'number' && d.pid > 0 && !isProcessAlive(d.pid); +} + +function listOnlineDaemons(): DaemonDescriptorLite[] { + const STALE_MS = 90_000; + const now = Date.now(); + return listDaemonDescriptors().filter(d => now - (d.lastHeartbeat ?? 0) <= STALE_MS); +} + +function findDaemon(larkAppId?: string): DaemonDescriptorLite | null { const all = listOnlineDaemons(); if (larkAppId) return all.find(d => d.larkAppId === larkAppId) ?? null; return all[0] ?? null; @@ -4669,12 +4821,136 @@ async function cmdResume(): Promise<void> { console.error('❌ adopt 接管会话不支持 resume。'); } else if (errCode === 'deferred_unmaterialized') { console.error('❌ 该静默定时轮次未创建话题,隐藏会话只保留审计记录,不能 resume。'); + } else if (errCode === 'resume_cancelled') { + console.error('❌ 恢复过程中会话被关闭,本次 resume 已取消。'); } else { console.error(`❌ 恢复失败: ${errCode}`); } process.exit(1); } +function titleCliUsage(): never { + console.error('用法: botmux title [--session-id <session-id|prefix>] <新标题>'); + console.error(' 在 botmux 会话里运行时可省略 --session-id,会自动使用当前会话。'); + process.exit(1); +} + +function parseTitleArgs(rest: string[]): { sessionIdArg?: string; title: string; json: boolean } { + const titleParts: string[] = []; + let sessionIdArg: string | undefined; + let json = false; + for (let i = 0; i < rest.length; i++) { + const token = rest[i]!; + if (token === '--session-id' || token === '-s') { + sessionIdArg = rest[i + 1]; + i++; + continue; + } + if (token === '--json') { + json = true; + continue; + } + if (token === '--') { + titleParts.push(...rest.slice(i + 1)); + break; + } + titleParts.push(token); + } + return { sessionIdArg, title: titleParts.join(' ').trim(), json }; +} + +function resolveSessionByIdOrPrefix(sessions: SessionData[], idOrPrefix: string): SessionData | null { + const exact = sessions.find(s => s.sessionId === idOrPrefix); + if (exact) return exact; + const matches = sessions.filter(s => s.sessionId.startsWith(idOrPrefix)); + if (matches.length === 1) return matches[0]!; + if (matches.length > 1) { + console.error(`❌ "${idOrPrefix}" 匹配了 ${matches.length} 个会话,请提供更长的 ID 前缀:`); + for (const s of matches) console.error(` ${s.sessionId.substring(0, 12)} ${s.title}`); + process.exit(1); + } + return null; +} + +async function cmdTitle(rest: string[]): Promise<void> { + const parsed = parseTitleArgs(rest); + const title = normalizeSessionTitle(parsed.title); + if (!title) titleCliUsage(); + + process.env.SESSION_DATA_DIR ??= resolveDataDir(); + const sessions = [...loadSessions().values()]; + const inferredSid = parsed.sessionIdArg ?? findAncestorSessionId(); + let session: SessionData | null = null; + + if (inferredSid) { + session = resolveSessionByIdOrPrefix(sessions, inferredSid); + } else { + const active = sessions.filter(s => s.status === 'active'); + if (active.length === 1) session = active[0]!; + else titleCliUsage(); + } + + if (!session) { + console.error(`❌ 未找到会话:${inferredSid}`); + process.exit(1); + } + + if (!session.larkAppId) { + const online = listOnlineDaemons(); + if (online.length > 1) { + console.error(`❌ 会话 ${session.sessionId.substring(0, 12)} 缺少 larkAppId,多 bot 部署下无法判定归属。`); + console.error(` 在线 daemon (${online.length}): ${online.map(d => d.larkAppId).join(', ')}`); + process.exit(1); + } + if (online.length === 0) { + console.error('❌ 没有在线 daemon。改名需要 daemon 广播 SSE;请先:botmux start'); + process.exit(1); + } + } + + const daemon = findDaemon(session.larkAppId); + if (!daemon) { + console.error('❌ 未找到在线 daemon。改名需要 daemon 广播 SSE;请确认 daemon 正在运行:botmux status'); + process.exit(1); + } + + const source = process.env.BOTMUX_SESSION_ID === session.sessionId ? 'agent' : 'cli'; + let res: Response; + try { + res = await fetch( + `http://127.0.0.1:${daemon.ipcPort}/api/sessions/${encodeURIComponent(session.sessionId)}/rename`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title, source }), + }, + ); + } catch (err: any) { + console.error(`❌ 无法连接到 daemon (port=${daemon.ipcPort}): ${err?.message ?? err}`); + process.exit(1); + } + + let body: any = {}; + try { body = await res.json(); } catch { /* */ } + if (!res.ok || !body?.ok) { + console.error(`❌ 改名失败: ${body?.error ?? `HTTP ${res.status}`}`); + process.exit(1); + } + + if (parsed.json) { + console.log(JSON.stringify({ + ok: true, + sessionId: session.sessionId, + title: body.title, + titleUpdatedAt: body.titleUpdatedAt, + titleSource: body.titleSource, + })); + return; + } + console.log(`✅ 会话标题已更新: ${body.title}`); + console.log(` 会话: ${session.sessionId.substring(0, 12)}`); +} + /** * `botmux term-link [session-id|prefix]` — get the writable ("可操作") terminal * for an active session. The link carries a write token, so rather than print it diff --git a/src/cli/session-list-liveness.ts b/src/cli/session-list-liveness.ts index c212b365d..fc96cd1e9 100644 --- a/src/cli/session-list-liveness.ts +++ b/src/cli/session-list-liveness.ts @@ -12,7 +12,7 @@ export type SessionListDisposition = 'keep' | 'prune_real' | 'prune_scratch'; * process/backing-session probes have already been evaluated by the caller. * * A cap-suspended session deliberately has neither a process PID nor a backing - * tmux/herdr/zellij session: that is how its memory is reclaimed. The persisted + * tmux/herdr/zellij/zmx session: that is how its memory is reclaimed. The persisted * cold-resume marker is therefore authoritative and must beat the generic * zombie heuristic. */ diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index edc23c3e3..7fd121c24 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1408,7 +1408,7 @@ export async function handleCommand( await sessionReply(rootId, t('cmd.rename.usage', undefined, loc)); break; } - const updated = updateSessionTitle(ds.session, rawTitle); + const updated = updateSessionTitle(ds.session, rawTitle, 'user'); if (!updated.ok) { await sessionReply(rootId, t('cmd.rename.usage', undefined, loc)); break; @@ -1428,7 +1428,6 @@ export async function handleCommand( logger.info(`[${logTag}] Session renamed by /rename: ${updated.title} (agentSync=${agentSync.status})`); break; } - case '/repo': { const repoArg = message.content.replace(/^\/repo\s*/, '').trim(); @@ -1625,6 +1624,8 @@ export async function handleCommand( // the new repo's cwd. The new repo is pinned onto the fresh session // below instead. const claimedCard = claimCurrentRepoCard(ds!, undefined); + const oldSession = ds!.session; + const oldSessionId = oldSession.sessionId; const closedCard = buildClosedSessionCard(ds!, loc); killWorker(ds!); sessionStore.closeSession(ds!.session.sessionId); @@ -1636,7 +1637,12 @@ export async function handleCommand( () => sessionReply(rootId, closedCard, 'interactive'), ); - const oldSession = ds!.session; + const stillOwnsGeneration = activeSessions.get(sessionKey(rootId, larkAppId!)) === ds + && ds!.session === oldSession; + if (!stillOwnsGeneration) { + logger.warn(`[${logTag}] Repo switch cancelled after closing ${oldSessionId.substring(0, 8)}; routing generation changed during card delivery`); + return false; + } // `rootId` is the routing anchor. For chat-scope sessions it is the // `oc_...` chat id, not the traceable `om_...` message root stored on // Session. Preserve the old identity and explicitly persist scope so diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 5af39699a..624b359db 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -922,7 +922,7 @@ ipcRoute('POST', '/api/sessions/:sessionId/board', async (req, res, params) => { const column = normalizeKanbanColumn(body.column); const position = normalizeKanbanPosition(body.position); if (!column && position === null) return jsonRes(res, 400, { ok: false, error: 'bad_request' }); - const session = findSessionRecord(params.sessionId); + const session = findOwnedSessionRecord(params.sessionId); if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); // 待办池(queued)会话被拖到「进行中」= 激活:把暂存内容当首轮发给 CLI 开跑。 // activateQueuedSession 内部会清 queued + 把列设成 in_progress + forkWorker。 @@ -1219,19 +1219,26 @@ ipcRoute('GET', '/api/owner-profile', async (_req, res) => { // Codex/Claude Code 再收到一条 best-effort 原生 /rename,同步其 resume picker。 // 飞书话题标题不受影响。全视图(看板/状态板/表格/抽屉)读同一字段。 ipcRoute('POST', '/api/sessions/:sessionId/rename', async (req, res, params) => { - let body: { title?: unknown }; + let body: { title?: unknown; source?: unknown }; try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } const title = normalizeSessionTitle(body.title); if (!title) return jsonRes(res, 400, { ok: false, error: 'bad_title' }); const active = findActiveBySessionId(params.sessionId); - const session = active?.session ?? sessionStore.getSession(params.sessionId); + const session = active?.session ?? sessionStore.getOwnedSession(params.sessionId); if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); - const updated = updateSessionTitle(session, title); + const source = normalizeSessionTitleSource(body.source, 'dashboard'); + const updated = updateSessionTitle(session, title, source); if (!updated.ok) return jsonRes(res, 400, { ok: false, error: updated.error }); const agentSync = active ? requestAgentSessionRename(active, updated.title) : { status: 'not_running' as const }; - jsonRes(res, 200, { ...updated, agentSync: agentSync.status }); + jsonRes(res, 200, { + ok: true, + title: updated.title, + titleUpdatedAt: updated.updatedAt, + titleSource: updated.source, + agentSync: agentSync.status, + }); }); // 会话锁定:保护被锁定会话不被 dashboard「清理空闲」批量关闭。锁定是会话元数据, @@ -1240,7 +1247,7 @@ ipcRoute('POST', '/api/sessions/:sessionId/lock', async (req, res, params) => { let body: { locked?: unknown }; try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } if (typeof body.locked !== 'boolean') return jsonRes(res, 400, { ok: false, error: 'bad_locked' }); - const session = findSessionRecord(params.sessionId); + const session = findOwnedSessionRecord(params.sessionId); if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); if (body.locked) session.locked = true; else delete session.locked; @@ -3154,7 +3161,7 @@ ipcRoute('PUT', '/api/bot-read-isolation', async (req, res) => { jsonRes(res, 200, { ok: true, readIsolation: r.readIsolation, suspendedSessions }); }); -// Per-bot session backend override (pty | tmux | herdr | zellij), or clear it +// Per-bot session backend override (pty | tmux | herdr | zellij | zmx), or clear it // ('' / 'auto' / null → follow the daemon default). next-session 生效:running // sessions keep their spawn-time backend (Session.backendType stamp), only new // spawns read the new value — so switching here can't strand live sessions. diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index ce278292e..44f901e62 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -13,6 +13,7 @@ import { getBotBrand } from '../bot-registry.js'; import { type Brand, chatAppLink } from '../im/lark/lark-hosts.js'; import { getSessionTokenUsage, type SessionTokenUsage } from './cost-calculator.js'; import { getIdentity } from '../im/lark/identity-cache.js'; +import { isSuspendableBackendType, persistentSessionName } from './persistent-backend.js'; export interface SessionRow { sessionId: string; @@ -41,6 +42,13 @@ export interface SessionRow { * Absent on rows from older daemons → callers keep the locate behavior. */ scope?: 'thread' | 'chat'; title?: string; + titleUpdatedAt?: string; + /** Informational only; callers must not treat it as authenticated identity. */ + titleSource?: Session['titleSource']; + /** Backend stamped at spawn time. Exposed so external surfaces can filter zmx/tmux/etc. */ + backendType?: Session['backendType']; + /** Deterministic backing multiplexer name, not proof the session is currently live. */ + backendSessionName?: string; /** 看板视图的手动放置(列 id / 列内排序位置),用户拖拽后持久化在 Session 上。 * 未设置时前端按运行状态推导默认列。 */ kanbanColumn?: string; @@ -137,6 +145,12 @@ function directChatDisplayName(s: Session, larkAppId?: string): string | undefin return undefined; } +function backendSessionNameForRow(s: Session): string | undefined { + const backendType = s.backendType; + if (s.adoptedFrom || !isSuspendableBackendType(backendType)) return undefined; + return persistentSessionName(backendType, s.sessionId); +} + export function composeRowFromActive(ds: DaemonSession): SessionRow { return { sessionId: ds.session.sessionId, @@ -162,6 +176,10 @@ export function composeRowFromActive(ds: DaemonSession): SessionRow { lastInputFromBot: ds.session.quoteTargetSenderIsBot === true, scope: ds.session.scope, title: ds.session.title, + titleUpdatedAt: ds.session.titleUpdatedAt, + titleSource: ds.session.titleSource, + backendType: ds.session.backendType, + backendSessionName: backendSessionNameForRow(ds.session), kanbanColumn: ds.session.kanbanColumn, kanbanPosition: ds.session.kanbanPosition, locked: !!ds.session.locked, @@ -208,6 +226,10 @@ export function composeRowFromClosed(s: Session): SessionRow { lastInputFromBot: s.quoteTargetSenderIsBot === true, scope: s.scope, title: s.title, + titleUpdatedAt: s.titleUpdatedAt, + titleSource: s.titleSource, + backendType: s.backendType, + backendSessionName: backendSessionNameForRow(s), kanbanColumn: s.kanbanColumn, kanbanPosition: s.kanbanPosition, locked: !!s.locked, diff --git a/src/core/idle-worker-sweeper.ts b/src/core/idle-worker-sweeper.ts index bbb44e679..87000a0d6 100644 --- a/src/core/idle-worker-sweeper.ts +++ b/src/core/idle-worker-sweeper.ts @@ -56,7 +56,7 @@ export function sweepIdleWorkers( const candidates = running // Never suspend an adopted session. forkAdoptWorker stamps its - // initConfig.backendType as tmux/herdr/zellij (so it would otherwise pass + // initConfig.backendType as tmux/herdr/zellij/zmx (so it would otherwise pass // isSuspendableBackendType), but the worker-null resume path in daemon.ts // re-forks via forkWorker — NOT forkAdoptWorker — so a suspended adopt // session would come back as a normal botmux bmx-* session, losing its diff --git a/src/core/persistent-backend.ts b/src/core/persistent-backend.ts index a91b89330..bbcc060d9 100644 --- a/src/core/persistent-backend.ts +++ b/src/core/persistent-backend.ts @@ -1,6 +1,6 @@ /** * Shared helpers for sessions backed by a persistent multiplexer - * (tmux / herdr / zellij). These backends keep the CLI alive across worker + * (tmux / herdr / zellij / zmx). These backends keep the CLI alive across worker * exits BY DESIGN (idle-suspend, lazy restore), so several daemon paths must * resolve / name / probe / kill the backing session WITHOUT a live worker: * the restore-time zombie sweep and terminal wake (session-manager.ts), and @@ -15,16 +15,17 @@ import { getBot } from '../bot-registry.js'; import { TmuxBackend } from '../adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../adapters/backend/herdr-backend.js'; import { ZellijBackend } from '../adapters/backend/zellij-backend.js'; +import { ZmxBackend } from '../adapters/backend/zmx-backend.js'; import type { BackendType, PersistentBackendTarget, SessionProbe } from '../adapters/backend/types.js'; import type { DaemonSession } from './types.js'; import type { Session } from '../types.js'; -export type PersistentBackendType = Extract<BackendType, 'tmux' | 'herdr' | 'zellij'>; +export type PersistentBackendType = Extract<BackendType, 'tmux' | 'herdr' | 'zellij' | 'zmx'>; export function isSuspendableBackendType( backendType: BackendType | undefined, ): backendType is PersistentBackendType { - return backendType === 'tmux' || backendType === 'herdr' || backendType === 'zellij'; + return backendType === 'tmux' || backendType === 'herdr' || backendType === 'zellij' || backendType === 'zmx'; } /** @@ -132,6 +133,7 @@ export function shutdownBackendDisposition(ds: DaemonSession): 'detach' | 'close export function persistentSessionName(backendType: PersistentBackendType, sessionId: string): string { if (backendType === 'tmux') return TmuxBackend.sessionName(sessionId); if (backendType === 'zellij') return ZellijBackend.sessionName(sessionId); + if (backendType === 'zmx') return ZmxBackend.sessionName(sessionId); return HerdrBackend.sessionName(sessionId); } @@ -197,9 +199,56 @@ export function killPersistentBackendTarget(target: PersistentBackendTarget): vo export function probePersistentSession(backendType: PersistentBackendType, name: string): SessionProbe { if (backendType === 'tmux') return TmuxBackend.probeSession(name); if (backendType === 'zellij') return ZellijBackend.probeSession(name); + if (backendType === 'zmx') return ZmxBackend.probeSession(name); return HerdrBackend.probeSession(name); } +/** + * Take one liveness snapshot for a set of backing-session names. + * + * ZMX and Zellij expose all session states in one command, so probing each row + * separately would repeatedly scan the same control plane (and makes `botmux + * list` quadratic for ZMX). tmux and Herdr keep their established per-session + * probes, but duplicate names are still coalesced here. + */ +export function probePersistentSessions( + backendType: PersistentBackendType, + names: Iterable<string>, +): ReadonlyMap<string, SessionProbe> { + const uniqueNames = [...new Set(names)]; + const result = new Map<string, SessionProbe>(); + + if (backendType === 'zmx') { + const snapshot = ZmxBackend.probeSessions(); + for (const name of uniqueNames) { + result.set( + name, + !snapshot.ok + ? 'unknown' + : snapshot.sessions.includes(name) + ? 'exists' + : snapshot.unhealthySessions.includes(name) + ? 'unknown' + : 'missing', + ); + } + return result; + } + + if (backendType === 'zellij') { + const snapshot = ZellijBackend.probeLiveSessions(); + for (const name of uniqueNames) { + result.set(name, !snapshot.ok ? 'unknown' : snapshot.sessions.includes(name) ? 'exists' : 'missing'); + } + return result; + } + + for (const name of uniqueNames) { + result.set(name, probePersistentSession(backendType, name)); + } + return result; +} + /** * Tri-state liveness of the backend's multiplexer SERVER itself (not one * session). The restore path consults this when a session probes 'missing' to @@ -216,6 +265,7 @@ export function probePersistentBackendServer( ): 'running' | 'down' | 'unknown' { if (backendType === 'tmux') return TmuxBackend.serverState(); if (backendType === 'zellij') return ZellijBackend.serverState(); + if (backendType === 'zmx') return ZmxBackend.serverState(); return 'unknown'; } @@ -223,5 +273,6 @@ export function probePersistentBackendServer( export function killPersistentSession(backendType: PersistentBackendType, name: string): void { if (backendType === 'tmux') TmuxBackend.killSession(name); else if (backendType === 'zellij') ZellijBackend.killSession(name); + else if (backendType === 'zmx') ZmxBackend.killSession(name); else HerdrBackend.killSession(name); } diff --git a/src/core/session-board.ts b/src/core/session-board.ts index f1f73d995..3113d1efc 100644 --- a/src/core/session-board.ts +++ b/src/core/session-board.ts @@ -18,8 +18,11 @@ export function normalizeKanbanPosition(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } -/** 重命名标题:单行化、移除会被终端解释的 C0/DEL 控制字符、限长; - * 空串视为非法。标题会被送进原生 TUI 的 `/rename`,所以这里也是输入边界。 */ +/** + * 重命名标题:单行化、移除会被终端解释的 C0/C1/DEL 控制字符、限长; + * 空串视为非法。标题既会写到 `botmux list` 的本机 TTY,也会送进原生 + * TUI 的 `/rename`,所以这里是两条路径共用的输入边界。 + */ export function normalizeSessionTitle(value: unknown): string | null { if (typeof value !== 'string') return null; const title = value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim(); diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 761b4ee1a..13fe4a4f7 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -24,11 +24,16 @@ import { import { getSessionPersistentBackendType, persistentBackendTargetForSession, - probePersistentBackendTarget, + persistentSessionName, probePersistentBackendServer, + probePersistentBackendTarget, + probePersistentSession, + probePersistentSessions, killPersistentBackendTarget, + killPersistentSession, type PersistentBackendType, } from './persistent-backend.js'; +import type { PersistentBackendTarget } from '../adapters/backend/types.js'; import { adoptTargetLabel, validateAdoptTargetState } from './session-discovery.js'; import { getBot, getAllBots, getOwnerOpenId, findOncallChat, effectiveDefaultWorkingDir } from '../bot-registry.js'; import type { CliId } from '../adapters/cli/types.js'; @@ -43,8 +48,8 @@ import { type Coworker, } from './session-create.js'; import { validateZellijAdoptTarget } from './zellij-adopt-discovery.js'; -import type { BackendType } from '../adapters/backend/types.js'; -import type { CliTurnPayload, CodexAppAdditionalContextEntry, CodexAppTurnInput, LarkAttachment, LarkMention, ScheduledTask, SubstituteTrigger } from '../types.js'; +import type { BackendType, SessionProbe } from '../adapters/backend/types.js'; +import type { CliTurnPayload, CodexAppAdditionalContextEntry, CodexAppTurnInput, LarkAttachment, LarkMention, ScheduledTask, Session, SubstituteTrigger } from '../types.js'; import { addCodexAppContext } from '../utils/codex-app-context.js'; import type { MessageResource } from '../im/lark/message-parser.js'; import type { ResolvedSender } from '../im/lark/identity-cache.js'; @@ -1155,7 +1160,7 @@ export function rememberLastCliInput( /** * Whether daemon restore should eagerly re-fork a worker to re-attach a - * surviving backing pane. True for every persistent backend (tmux/herdr/zellij); + * surviving backing pane. True for every persistent backend (tmux/herdr/zellij/zmx); * the pty backend has nothing to re-attach to, so it stays lazy. * * Eager re-attach is what makes a session actually come back after a restart — @@ -1202,7 +1207,18 @@ export async function staggeredRecoveryFork( export async function restoreActiveSessions(activeSessions: Map<string, DaemonSession>): Promise<void> { const sessions = sessionStore.listSessions(); - const active = sessions.filter(s => s.status === 'active'); + const restorePriority = (session: Session): number => { + if (session.adoptedFrom || session.cliId || session.lastCliInput || session.backendType) return 2; + if (session.queued) return 1; + return 0; // disposable daemon-command scratch + }; + // Deterministic winner policy for corrupt/legacy same-anchor duplicates: + // real CLI/adopt rows first, queued intent second, command scratches last. + // Registration itself is CAS, so a fresh runtime occupant always wins over + // every startup candidate regardless of this disk ordering. + const active = sessions + .filter(s => s.status === 'active') + .sort((a, b) => restorePriority(b) - restorePriority(a)); if (active.length === 0) { logger.info('No active sessions to restore'); @@ -1210,11 +1226,33 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe } // Kill any stale CLI processes from previous daemon run - killStalePids(active); + // Dispatcher/IPC are already live while restore runs. Pass the runtime map + // so the stale-PID sweep cannot kill a fresh worker that won the startup + // race for the same logical session. The full snapshot still reaches backend + // cleanup, preserving its backing-session name as active. + killStalePids(active, activeSessions); logger.info(`Registering ${active.length} active session(s) (no CLI spawn until new messages arrive)...`); + const runtimeWinnerFor = (sessionId: string, candidate?: DaemonSession): DaemonSession | undefined => + [...activeSessions.values()].find(ds => ds !== candidate && ds.session.sessionId === sessionId); + // Persistent recovery below must only inspect rows registered by THIS + // restore pass. The dispatcher/IPC are already live and may add a fresh + // runtime session while one of the CAS registrations awaits; sweeping the + // whole live Map would then mistake that new session for a startup snapshot + // row and could close it while its backing pane is still being created. + const restoredByThisInvocation: DaemonSession[] = []; + const stillOwnsRestoreRegistration = (ds: DaemonSession): boolean => + ds.session.status === 'active' + && activeSessions.get(activeSessionKey(ds)) === ds; for (const session of active) { + // Dispatcher/IPC may create and register this exact persisted row while + // startup restore is running. The runtime object is authoritative; never + // rebuild or later close it as a collision loser. + if (runtimeWinnerFor(session.sessionId)) { + logger.debug(`[${session.sessionId.substring(0, 8)}] Already registered by live runtime during restore; skipping snapshot row`); + continue; + } // Restored sessions persisted before the scope field was added default to // 'thread' — that matches the legacy thread-only behaviour. const scope: 'thread' | 'chat' = session.scope === 'chat' ? 'chat' : 'thread'; @@ -1299,9 +1337,21 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe // Same-key collision guard: if a prior iteration already set an entry // at this key (legitimately possible if disk holds two active sessions // resolving to the same chat-scope key — e.g. a leaked scratch + - // relayed real session from a prior buggy run), close the loser - // rather than silently overwriting it. - await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds); + // relayed real session from a prior buggy run), reject and close the + // incoming loser rather than silently overwriting the runtime winner. + const adoptKey = activeSessionKey(ds); + const adoptRegistered = await setActiveSessionSafe(activeSessions, adoptKey, ds); + const adoptCurrent = activeSessions.get(adoptKey); + if (runtimeWinnerFor(session.sessionId, ds)) { + logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won adopt restore registration`); + continue; + } + if (!adoptRegistered || session.status !== 'active' || adoptCurrent !== ds) { + logger.warn(`[${session.sessionId.substring(0, 8)}] Adopt restore was cancelled while resolving a routing collision`); + await closeSession(session.sessionId); + continue; + } + restoredByThisInvocation.push(ds); announceSessionRow(ds); forkAdoptWorker(ds, { restoredFromMetadata: true }); logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adoptTargetLabel(adopted)}, scope: ${scope})`); @@ -1344,7 +1394,19 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe }; const anchor = sessionAnchorId(ds); messageQueue.ensureQueue(anchor); - await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds); + const queuedKey = activeSessionKey(ds); + const queuedRegistered = await setActiveSessionSafe(activeSessions, queuedKey, ds); + const queuedCurrent = activeSessions.get(queuedKey); + if (runtimeWinnerFor(session.sessionId, ds)) { + logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won queued restore registration`); + continue; + } + if (!queuedRegistered || session.status !== 'active' || queuedCurrent !== ds) { + logger.warn(`[${session.sessionId.substring(0, 8)}] Queued restore was cancelled while resolving a routing collision`); + await closeSession(session.sessionId); + continue; + } + restoredByThisInvocation.push(ds); // 重启后把待办池卡片重新广播给 dashboard,否则会从看板消失(#277 同款修复, // 我这条 queued 分支提前 continue 绕过了下面的 announceSessionRow,要自己补)。 announceSessionRow(ds); @@ -1420,7 +1482,19 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe messageQueue.ensureQueue(anchor); if (ds.usageLimit) restoreUsageLimitRuntimeState(ds); // Same-key collision guard — see adopt-branch comment above. - await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds); + const restoreKey = activeSessionKey(ds); + const restored = await setActiveSessionSafe(activeSessions, restoreKey, ds); + const restoreCurrent = activeSessions.get(restoreKey); + if (runtimeWinnerFor(session.sessionId, ds)) { + logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won restore registration`); + continue; + } + if (!restored || session.status !== 'active' || restoreCurrent !== ds) { + logger.warn(`[${session.sessionId.substring(0, 8)}] Restore was cancelled while resolving a routing collision`); + await closeSession(session.sessionId); + continue; + } + restoredByThisInvocation.push(ds); announceSessionRow(ds); if (session.initialUserTurnPending) { @@ -1440,6 +1514,51 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe // actual re-fork is deferred into `toReattach` and staggered below so a box // with dozens of surviving sessions doesn't spike on restart. const toReattach: DaemonSession[] = []; + const restoreCandidates: Array<{ + ds: DaemonSession; + backendType: PersistentBackendType; + backendTarget: PersistentBackendTarget; + backendName: string; + }> = []; + const namesByBackend = new Map<PersistentBackendType, Set<string>>(); + for (const ds of restoredByThisInvocation) { + // A later restore CAS awaited after this row was registered. During that + // yield the user may have closed/resumed/replaced it; never carry the stale + // object into a session-id based close that could hit its fresh successor. + // A real message may also have synchronously forked this exact ds while the + // registration Promise yielded. Its backing pane can still be starting, so + // a missing probe is not zombie evidence and restore must leave it alone. + if (!stillOwnsRestoreRegistration(ds) || ds.worker) continue; + // External /adopt sessions use their discovered source target rather than + // Botmux's deterministic managed backing name. They were already restored + // through the adopt path above and must not enter the managed probe batch. + if (ds.adoptedFrom) continue; + // Queued(待办池)会话从没起过 CLI,没有任何后端会话——别去探它,否则 tmux 后端 + // 会把「找不到 backing」误判成僵尸而关掉它。 + if (ds.session.queued) continue; + const backendType = getSessionPersistentBackendType(ds); + if (!backendType || !shouldAutoForkOnRestore(backendType)) continue; + // Honour the worker-selected target (Herdr may own an agent inside a shared + // host session) rather than assuming the deterministic whole-session name. + const backendTarget = persistentBackendTargetForSession(ds)!; + const backendName = backendTarget.backendType === 'herdr' && backendTarget.agentName + ? `${backendTarget.sessionName}/${backendTarget.agentName}` + : backendTarget.sessionName; + restoreCandidates.push({ ds, backendType, backendTarget, backendName }); + // Only session-name-addressable targets can be answered from a batch + // snapshot; agent-scoped Herdr rows fall back to their per-target probe. + if (backendTarget.backendType === 'herdr' && backendTarget.agentName) continue; + const names = namesByBackend.get(backendType) ?? new Set<string>(); + names.add(backendTarget.sessionName); + namesByBackend.set(backendType, names); + } + // ZMX/Zellij can classify every requested name from one control-plane list. + // This is both a consistent restore snapshot and avoids an O(N²) ZMX restart + // when each per-row probe would otherwise scan every per-session daemon. + const probeSnapshots = new Map<PersistentBackendType, ReadonlyMap<string, SessionProbe>>(); + for (const [backendType, names] of namesByBackend) { + probeSnapshots.set(backendType, probePersistentSessions(backendType, names)); + } // Server-liveness is sampled ONCE per backend type (cached): a single // `tmux list-sessions` answers for all of that backend's sessions, and a // consistent snapshot avoids a mid-loop race where an early lazy fork could @@ -1450,24 +1569,17 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe if (s === undefined) { s = probePersistentBackendServer(bt); serverStateCache.set(bt, s); } return s; }; - for (const [, ds] of activeSessions) { - // Queued(待办池)会话从没起过 CLI,没有任何后端会话——别去探它,否则 tmux 后端 - // 会把「找不到 backing」误判成僵尸而关掉它。 - if (ds.session.queued) continue; - // External /adopt sessions were already validated and re-forked above - // against their real tmux/zellij target. They never own Botmux's - // deterministic bmx-<sessionId> backing session, so probing that name here - // would immediately zombie-close the session we just restored. - if (ds.adoptedFrom) continue; - const backendType = getSessionPersistentBackendType(ds); - if (!backendType) continue; - if (!shouldAutoForkOnRestore(backendType)) continue; - - const backendTarget = persistentBackendTargetForSession(ds)!; - const backendName = backendTarget.backendType === 'herdr' && backendTarget.agentName - ? `${backendTarget.sessionName}/${backendTarget.agentName}` - : backendTarget.sessionName; - const probe = probePersistentBackendTarget(backendTarget); + for (const { ds, backendType, backendTarget, backendName } of restoreCandidates) { + // An earlier candidate's mismatch close can await document cleanup, so + // revalidate exact ownership and worker state for every row before any + // destructive action. A message can wake a later candidate during that + // await, while its persistent backing is still being created. + if (!stillOwnsRestoreRegistration(ds) || ds.worker) continue; + // Agent-scoped Herdr targets are not addressable by session name, so they + // never joined the batch and keep the per-target probe. + const probe = backendTarget.backendType === 'herdr' && backendTarget.agentName + ? probePersistentBackendTarget(backendTarget) + : probeSnapshots.get(backendType)?.get(backendTarget.sessionName) ?? 'unknown'; if (probe === 'missing') { const tag = ds.session.sessionId.substring(0, 8); // Intentionally cold-resume-suspended (idle-worker sweeper killed the @@ -1479,6 +1591,14 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe logger.info(`[${tag}] ${backendType} session was cap-suspended — keeping active for lazy cold-resume`); continue; } + // ZMX is not a shared multiplexer server: every session owns an + // independent daemon. Another live ZMX session says nothing about this + // one, so "missing" cannot safely drive a destructive zombie close. + // Preserve the transcript-backed row and cold-resume on the next message. + if (backendType === 'zmx') { + logger.warn(`[${tag}] zmx backing session "${backendName}" is missing — keeping active for lazy resume`); + continue; + } // 'missing' is ambiguous: it means EITHER this one pane is gone while the // server runs (a true solo zombie) OR the whole multiplexer server is down // (e.g. machine reboot) and every pane vanished at once. Only the former is @@ -1601,12 +1721,13 @@ export async function ensureTerminalWorkerPort(ds: DaemonSession): Promise<numbe * manual resume could resurrect a stale member epoch * - 'deferred_unmaterialized' — a silent fresh-topic run finished without * publishing, so it has no conversation to resume + * - 'resume_cancelled' — a concurrent close won while resume was committing */ export async function resumeSession( sessionId: string, activeSessions: Map<string, DaemonSession>, ): Promise<{ ok: true; ds: DaemonSession } -| { ok: false; error: 'not_found' | 'not_closed' | 'anchor_occupied' | 'adopt_unsupported' | 'vc_receiver_managed' | 'deferred_unmaterialized'; activeSessionId?: string }> { +| { ok: false; error: 'not_found' | 'not_closed' | 'anchor_occupied' | 'adopt_unsupported' | 'vc_receiver_managed' | 'deferred_unmaterialized' | 'resume_cancelled'; activeSessionId?: string }> { const session = sessionStore.getSession(sessionId); if (!session) return { ok: false, error: 'not_found' }; if (session.status !== 'closed') return { ok: false, error: 'not_closed' }; @@ -1659,10 +1780,14 @@ export async function resumeSession( // throwaway command container, so clobbering it would lose real intent. const existing = activeSessions.get(key); if (existing) { - if (isRelayableRealSession(existing) || existing.pendingRepo) { + if (!isDisposableCommandScratch(existing)) { return { ok: false, error: 'anchor_occupied', activeSessionId: existing.session.sessionId }; } await closeSession(existing.session.sessionId); + const replacement = activeSessions.get(key); + if (replacement) { + return { ok: false, error: 'anchor_occupied', activeSessionId: replacement.session.sessionId }; + } } // Belt-and-suspenders: also scan persisted sessions for any *other* active @@ -1697,13 +1822,23 @@ export async function resumeSession( && (s.scope === 'chat' ? 'chat' : 'thread') === scope && storedSessionAnchorId(s) === anchor, ); - const realConflict = conflicts.find(s => !!s.cliId || !!s.lastCliInput); + const realConflict = conflicts.find(s => + !!s.cliId || !!s.lastCliInput || !!s.backendType || !!s.adoptedFrom || s.queued === true, + ); if (realConflict) { return { ok: false, error: 'anchor_occupied', activeSessionId: realConflict.sessionId }; } for (const scratch of conflicts) { await closeSession(scratch.sessionId); } + const replacementAfterDiskCleanup = activeSessions.get(key); + if (replacementAfterDiskCleanup) { + return { + ok: false, + error: 'anchor_occupied', + activeSessionId: replacementAfterDiskCleanup.session.sessionId, + }; + } // Reactivate in store — clear closedAt so dashboard rows don't keep showing // the stale close timestamp on the now-active session. @@ -1743,9 +1878,17 @@ export async function resumeSession( messageQueue.ensureQueue(anchor); // setActiveSessionSafe over a bare Map.set: the scratch-eviction above - // should already have freed `key`, but if any occupant remains it closes - // it rather than silently orphaning it (consistent with restore/transfer). - await setActiveSessionSafe(activeSessions, key, ds); + // should already have freed `key`, but its compare-and-set gate lets any + // concurrent runtime occupant win rather than silently orphaning it. + const registered = await setActiveSessionSafe(activeSessions, key, ds); + if (!registered || session.status !== 'active' || activeSessions.get(key) !== ds) { + const occupant = activeSessions.get(key); + if (session.status === 'active') await closeSession(sessionId); + if (occupant && occupant !== ds) { + return { ok: false, error: 'anchor_occupied', activeSessionId: occupant.session.sessionId }; + } + return { ok: false, error: 'resume_cancelled' }; + } logger.info(`Resumed session ${sessionId.substring(0, 8)} (scope: ${scope}, anchor: ${anchor.substring(0, 12)})`); return { ok: true, ds }; } @@ -2083,7 +2226,10 @@ export async function executeScheduledTask( } ensureSessionWhiteboard(ds); const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId }); - activeSessions.set(sessionKey(anchor, larkAppId), ds); + if (!setActiveSessionIfActive(activeSessions, sessionKey(anchor, larkAppId), ds)) { + await closeSession(session.sessionId); + return; + } rememberLastCliInput(ds, task.prompt, prompt); if (silent) armSilentScheduledTurn(ds, scheduledTurnId); try { @@ -2330,7 +2476,10 @@ export async function spawnDashboardSession( pendingCodexAppMessageContext: codexAppMessageContext, pendingAttachments: args.attachments, }; - activeSessions.set(sessionKey(anchor, larkAppId), ds); + if (!setActiveSessionIfActive(activeSessions, sessionKey(anchor, larkAppId), ds)) { + await closeSession(session.sessionId); + return { ok: false, error: 'session_exists' }; + } if (column === 'backlog') { // Parked:不起 CLI。手动广播 session.spawned,让 dashboard 立刻显示待办池卡片 diff --git a/src/core/session-title.ts b/src/core/session-title.ts index 4c7d952f1..aabc6bea1 100644 --- a/src/core/session-title.ts +++ b/src/core/session-title.ts @@ -3,8 +3,10 @@ import * as sessionStore from '../services/session-store.js'; import { dashboardEventBus } from './dashboard-events.js'; import { normalizeSessionTitle } from './session-board.js'; +export type SessionTitleSource = 'initial' | 'user' | 'agent' | 'cli' | 'dashboard' | 'system'; + export type SessionTitleUpdateResult = - | { ok: true; title: string } + | { ok: true; title: string; updatedAt: string; source: SessionTitleSource } | { ok: false; error: 'bad_title' }; const BOTMUX_LARK_TITLE_PREFIX = '[BotMux·Lark]'; @@ -78,19 +80,43 @@ export function buildBotmuxLarkNativeSessionTitle( return `${BOTMUX_LARK_TITLE_PREFIX} ${displayContent}`; } +export function normalizeSessionTitleSource(value: unknown, fallback: SessionTitleSource): SessionTitleSource { + if ( + value === 'initial' || + value === 'user' || + value === 'agent' || + value === 'cli' || + value === 'dashboard' || + value === 'system' + ) { + return value; + } + return fallback; +} + /** Persist a display-title change and keep dashboard subscribers in sync. */ -export function updateSessionTitle(session: Session, rawTitle: unknown): SessionTitleUpdateResult { +export function updateSessionTitle( + session: Session, + rawTitle: unknown, + source: SessionTitleSource, +): SessionTitleUpdateResult { const title = normalizeSessionTitle(rawTitle); if (!title) return { ok: false, error: 'bad_title' }; + const updatedAt = new Date().toISOString(); session.title = title; session.nativeSessionTitle = title; session.nativeSessionTitleUserDefined = true; session.nativeSessionTitleAwaitingContent = undefined; + session.titleUpdatedAt = updatedAt; + session.titleSource = source; sessionStore.updateSession(session); dashboardEventBus.publish({ type: 'session.update', - body: { sessionId: session.sessionId, patch: { title } }, + body: { + sessionId: session.sessionId, + patch: { title, titleUpdatedAt: updatedAt, titleSource: source }, + }, }); - return { ok: true, title }; + return { ok: true, title, updatedAt, source }; } diff --git a/src/core/startup-commands.ts b/src/core/startup-commands.ts index 22dbdf531..5c34b72cf 100644 --- a/src/core/startup-commands.ts +++ b/src/core/startup-commands.ts @@ -61,7 +61,7 @@ export function normalizeStartupCommandList(arr: unknown): string[] { /** * Whether a spawn should (re-)run startupCommands. They run on a genuinely fresh - * CLI process; a reattach to a LIVE persistent (tmux/zellij/herdr) pane — e.g. a + * CLI process; a reattach to a LIVE persistent (tmux/zellij/herdr/zmx) pane — e.g. a * daemon restart recovering an existing session — is the SAME CLI with its * effort/model/context already established, so re-typing `/effort` (idempotent) * or `/clear`,`/compact` (NOT idempotent) would corrupt it. Skip on reattach. diff --git a/src/core/trigger-session.ts b/src/core/trigger-session.ts index b306d36c6..0d8fbb6e6 100644 --- a/src/core/trigger-session.ts +++ b/src/core/trigger-session.ts @@ -671,7 +671,15 @@ export async function triggerSessionTurn( // Register right before the fork branches (no await between here and forkWorker) // so a concurrent inbound message can't observe this session worker-less and // race a duplicate re-fork — the set-and-fork atomicity the original path had. - deps.activeSessions.set(sessionKey(anchor, larkAppId), newDs); + if (!setActiveSessionIfActive(deps.activeSessions, sessionKey(anchor, larkAppId), newDs)) { + await closeSession(session.sessionId); + return { + ok: false, + triggerId, + errorCode: 'trigger_failed', + error: 'session was closed while the trigger was being prepared', + }; + } rememberInput(newDs, prompt, promptInput); if (req.options?.waitForFinalOutput) { diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 70631f38d..70597199c 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -36,8 +36,9 @@ import { replyToDocComment, chunkCommentText, unsubscribeDocFile, removeCommentR import { listDocSubscriptionsForSession, removeDocSubscription } from '../services/doc-subs-store.js'; import { TmuxBackend } from '../adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../adapters/backend/herdr-backend.js'; +import { ZmxBackend } from '../adapters/backend/zmx-backend.js'; import { sandboxEnabled } from '../adapters/backend/sandbox.js'; -import { isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, killPersistentBackendTarget, probePersistentBackendTarget, managedTargetsForCliChange, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js'; +import { isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, managedTargetsForCliChange, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js'; import { getBot, getAllBots, loadBotConfigs, resolveBrandLabel } from '../bot-registry.js'; import { RestartCoordinator, type RestartObserver } from './restart-coordinator.js'; import { runtimeBuildIdentity } from '../utils/runtime-build-id.js'; @@ -309,6 +310,19 @@ export function isRelayableRealSession(ds: DaemonSession): boolean { return false; } +/** A worker-less row that never represented a CLI and carries no deferred + * user intent. Only this narrow class is safe to evict as command scaffolding. */ +export function isDisposableCommandScratch(ds: DaemonSession): boolean { + return !ds.worker + && !ds.pendingRepo + && ds.pendingPrompt === undefined + && ds.pendingRawInput === undefined + && !ds.adoptedFrom + && !ds.session.adoptedFrom + && !ds.session.queued + && !isRelayableRealSession(ds); +} + // Per-bot opt-out: when true, botmux never posts/patches the live streaming // session card. Read fresh from the in-memory registry so a dashboard toggle // takes effect without a daemon restart. The `/card` command can override it @@ -1344,7 +1358,7 @@ export function killWorker(ds: DaemonSession): void { invalidateStuckWarning(ds, 'killWorker'); if (!ds.worker || ds.worker.killed) { // No live worker to receive {type:'close'}, so its destroySession() — which - // tears down the persistent backing session (tmux/herdr/zellij) — never + // tears down the persistent backing session (tmux/herdr/zellij/zmx) — never // fires. Those sessions survive a worker exit BY DESIGN (idle-suspend / // lazy-restore keep the CLI alive for later resume), so /close on such a // session would leave an orphaned CLI running in tmux that still replies. @@ -1381,7 +1395,7 @@ export function shouldDestroyPaneBeforeRestart( } /** - * Destroy a still-alive persistent backing pane (tmux/herdr/zellij) before a + * Destroy a still-alive persistent backing pane (tmux/herdr/zellij/zmx) before a * worker-less restart forks a fresh worker. Without this, a session that lost * its worker but kept its pane (the normal post-daemon-restart state) would let * spawnCli REATTACH the surviving CLI instead of relaunching it — the CLI is @@ -1486,7 +1500,7 @@ export function __testOnly_resetRestartCoordinator(): void { } /** - * Tear down a persistent backing session (tmux/herdr/zellij) directly from the + * Tear down a persistent backing session (tmux/herdr/zellij/zmx) directly from the * daemon when there is no live worker to do it via the 'close' IPC. The session * name is deterministic from the session UUID, and each killSession() is a no-op * if the session is already gone. @@ -1624,6 +1638,93 @@ function armWorkerKillBackstop(w: ChildProcess, label: string, sigtermMs: number // ─── Idempotent session close (dashboard IPC) ─────────────────────────────── +type DocCommentReplyTarget = { + fileToken: string; + fileType: string; + commentId: string; + replyToOpenId?: string; + replyToName?: string; + replyId?: string; + reactionId?: string; +}; + +function persistedDocCommentTarget( + session: Session, + turnId: string, +): DocCommentReplyTarget | undefined { + return session.docCommentTargets?.[turnId]; +} + +/** Resolve the daemon-local target first, but fill cleanup identifiers from + * the persisted per-turn route. After a daemon restart only the latter exists. */ +function resolveDocCommentTarget( + ds: DaemonSession, + turnId: string, +): DocCommentReplyTarget | undefined { + const memory = ds.docCommentTurns?.get(turnId); + const persisted = persistedDocCommentTarget(ds.session, turnId); + if (!memory) return persisted; + if (!persisted) return memory; + return { + ...persisted, + ...memory, + replyId: memory.replyId ?? persisted.replyId, + reactionId: memory.reactionId ?? persisted.reactionId, + }; +} + +/** Consume one successfully-delivered document turn from both runtime and + * persisted state. Persistence is best-effort: a reply that already landed + * must never be retried (and duplicated) merely because local cleanup failed. */ +function consumeDocCommentTurn(ds: DaemonSession, turnId: string): void { + if (ds.docCommentTurns) { + ds.docCommentTurns.delete(turnId); + if (ds.docCommentTurns.size === 0) ds.docCommentTurns = undefined; + } + const targets = ds.session.docCommentTargets; + if (targets?.[turnId]) { + delete targets[turnId]; + if (Object.keys(targets).length === 0) ds.session.docCommentTargets = undefined; + try { sessionStore.updateSession(ds.session); } catch { /* best-effort */ } + } +} + +function collectDocCommentReactionTargets( + ds: DaemonSession | undefined, + stored: Session | undefined, +): DocCommentReplyTarget[] { + const unique = new Map<string, DocCommentReplyTarget>(); + const add = (target: DocCommentReplyTarget): void => { + if (!target.replyId || !target.reactionId) return; + const key = `${target.fileToken}\0${target.commentId}\0${target.replyId}\0${target.reactionId}`; + unique.set(key, target); + }; + if (ds?.docCommentTurns) { + for (const target of ds.docCommentTurns.values()) add(target); + } + for (const target of Object.values(ds?.session.docCommentTargets ?? {})) add(target); + if (stored && stored !== ds?.session) { + for (const target of Object.values(stored.docCommentTargets ?? {})) add(target); + } + return [...unique.values()]; +} + +function clearAllDocCommentTurnState(ds: DaemonSession | undefined, stored: Session | undefined): void { + if (ds) { + ds.docCommentTurns = undefined; + ds.session.docCommentTargets = undefined; + } + if (stored && stored !== ds?.session) stored.docCommentTargets = undefined; +} + +function sessionAnchorForStoredRow(session: Session): string { + return session.scope === 'chat' ? session.chatId : session.rootMessageId; +} + +function isLegacyApiDocSubscription(managedBy: 'subscribe-lark-doc' | 'watch-comment' | undefined): boolean { + return managedBy === undefined || managedBy === 'subscribe-lark-doc'; +} + /** * Idempotent close: kill worker if alive, mark Session status='closed' + closedAt, * publish session.exited (if a live worker was killed) and session.update @@ -1634,12 +1735,28 @@ function armWorkerKillBackstop(w: ChildProcess, label: string, sigtermMs: number */ export async function closeSession( sessionId: string, -): Promise<{ ok: true; alreadyClosed: boolean }> { +): Promise<{ ok: true; alreadyClosed: boolean; known: boolean }> { const ds = findActiveBySessionId(sessionId); let killedLive = false; // 会话关闭即可回收其崩溃重启计数;否则每个曾崩溃过的 session 会在 daemon // 生命周期内永久占位(restartCounts 此前无任何 delete)。 restartCounts.delete(sessionId); + // Snapshot ownership + transition state before mutating the live object: + // sessionStore commonly holds the very same Session reference as `ds`. + const stored = sessionStore.getOwnedSession(sessionId); + const known = !!ds || !!stored; + const wasOpen = !!stored && stored.status !== 'closed'; + const storedHadDocCommentTargets = Object.keys(stored?.docCommentTargets ?? {}).length > 0; + const docReactionTargets = collectDocCommentReactionTargets(ds, stored); + // Per-turn comment routes are transient capabilities. Clear them from both + // live and owner-scoped persisted objects inside the synchronous close + // critical section, before any best-effort Lark cleanup can yield. + clearAllDocCommentTurnState(ds, stored); + // An idempotent re-close has no status transition for closeSession() to + // persist, but stale per-turn capabilities must still be removed on disk. + if (stored && !wasOpen && storedHadDocCommentTargets) { + sessionStore.updateSession(stored); + } if (ds) { // Usage ledger: flush the final delta before the worker goes away (a // crash/limited turn may never have reached an idle edge). @@ -1709,16 +1826,101 @@ export async function closeSession( } } + // All authoritative map/status/store/event state above transitions + // synchronously, before the first await. Lark reaction/unsubscribe cleanup is + // best-effort and can be slow; it must not leave a resurrection window. + const cleanupAppId = ds?.larkAppId ?? stored?.larkAppId; + if (cleanupAppId) { + for (const target of docReactionTargets) { + try { + await removeCommentReaction( + cleanupAppId, + { fileToken: target.fileToken, fileType: target.fileType }, + target.commentId, + target.replyId!, + target.reactionId!, + ); + } catch (err: any) { + logger.debug( + `[doc-comment] close cleanup could not remove reaction ${target.reactionId}: ${err?.message ?? err}`, + ); + } + } + + const anchor = ds ? sessionAnchorId(ds) : stored ? sessionAnchorForStoredRow(stored) : undefined; + let subs: ReturnType<typeof listDocSubscriptionsForSession> = []; + try { + if (anchor) { + subs = listDocSubscriptionsForSession(config.session.dataDir, cleanupAppId, anchor); + } + } catch (err: any) { + logger.warn(`[doc-comment] failed to list bindings on close for ${sessionId.slice(0, 8)}: ${err?.message ?? err}`); + } + for (const sub of subs) { + try { + // `/subscribe-lark-doc` (and pre-managedBy legacy rows) owns a + // per-file remote subscription. `/watch-comment` is app-level event / + // poller state and must only remove its local routing binding. + if (isLegacyApiDocSubscription(sub.managedBy)) { + await unsubscribeDocFile(cleanupAppId, { fileToken: sub.fileToken, fileType: sub.fileType }); + } + } catch (err: any) { + logger.warn( + `[doc-comment] remote unsubscribe failed for ${sub.fileToken.slice(0, 12)} on close: ${err?.message ?? err}`, + ); + } finally { + // Local ownership must always be released, even when one remote API + // call fails; each item is isolated so later bindings still clean up. + try { + removeDocSubscription(config.session.dataDir, cleanupAppId, sub.fileToken); + } catch (err: any) { + logger.warn( + `[doc-comment] local binding removal failed for ${sub.fileToken.slice(0, 12)} on close: ${err?.message ?? err}`, + ); + } + } + } + if (subs.length) logger.info(`[doc-comment] session ${sessionId.slice(0, 8)} closed → removed ${subs.length} doc binding(s)`); + } + // alreadyClosed = nothing happened on either path. const alreadyClosed = !killedLive && !wasOpen; - return { ok: true, alreadyClosed }; + return { ok: true, alreadyClosed, known }; } /** - * Set an entry on an active-sessions Map, but if the key is already occupied - * by a DIFFERENT DaemonSession, close that occupant first. Replaces bare - * `activeSessions.set(key, ds)` at sites where a silent overwrite would leak - * the prior entry's worker + leave its store row stuck in `status='active'`. + * Close can arrive through daemon IPC before startup restore has registered the + * persisted row. In that window there is no DaemonSession for killWorker(), but + * a stamped persistent backing may still be running. Tear down only backends + * whose ownership is explicit; never touch adopted user panes, queued rows, or + * legacy rows whose backend is unknown. + */ +export function destroyUnregisteredPersistentBacking( + session: Session, + kill: typeof killPersistentSession = killPersistentSession, +): boolean { + if (session.adoptedFrom || session.queued) return false; + const backendType = session.backendType; + if (!isSuspendableBackendType(backendType)) return false; + const backendName = persistentSessionName(backendType, session.sessionId); + try { + kill(backendType, backendName); + logger.info(`[${session.sessionId.substring(0, 8)}] Closed unregistered ${backendType} backing ${backendName}`); + return true; + } catch (err) { + logger.warn( + `[${session.sessionId.substring(0, 8)}] Failed to close unregistered ${backendType} backing ${backendName}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } +} + +/** + * Compare-and-set an entry on an active-sessions Map. A different current + * occupant always wins; callers must roll back the rejected incoming row. + * Replaces bare `activeSessions.set(key, ds)` at sites where a silent overwrite + * would leak the prior entry's worker + leave its store row stuck active. * * The Map is passed explicitly so callers operate on the same instance they * already hold (restoreActiveSessions takes the daemon's Map as a parameter; @@ -1726,28 +1928,78 @@ export async function closeSession( * both refer to the same object — the daemon registers its Map at boot — but * decoupling avoids module-state assumptions in tests. * - * Canonical collision case: restoreActiveSessions at daemon boot iterating - * two on-disk active sessions that resolve to the same chat-scope key (e.g. - * a /relay command's scratch session + the real session that was transferred - * into the same chat by a prior daemon run). Without this helper the later - * iterated entry silently wins, the earlier one becomes a ghost-active. - * - * Setting the same `ds` at its own key is a no-op (no close). + * Registration is compare-and-set: a different current occupant always wins. + * The caller owns rollback of its rejected incoming row. This is deliberately + * non-destructive because the occupant may be a fresh live session created + * while an older async restore/create continuation was in flight. */ -export async function setActiveSessionSafe( +function removeInactiveRegistration( map: Map<string, DaemonSession>, key: string, ds: DaemonSession, -): Promise<void> { - const prev = map.get(key); - if (prev && prev !== ds) { +): boolean { + if (ds.session.status === 'active') return false; + // Only remove our exact stale object. A newer session may already own the + // same routing key and must never be evicted by this continuation. + if (map.get(key) === ds) map.delete(key); + logger.warn( + `[${tag(ds)}] Refusing to register an inactive session ` + + `(status=${ds.session.status})`, + ); + return true; +} + +/** + * Synchronous registration gate for creation paths that previously used a + * bare Map.set(). A daemon close can update the shared Session object while + * the creator is awaiting Lark/project metadata; never publish that now-closed + * row back into the live routing map when the continuation resumes. + */ +export function setActiveSessionIfActive( + map: Map<string, DaemonSession>, + key: string, + ds: DaemonSession, +): boolean { + if (removeInactiveRegistration(map, key, ds)) return false; + const current = map.get(key); + if (current && current !== ds) { logger.warn( - `[setActiveSessionSafe] key already occupied by ${prev.session.sessionId.substring(0, 8)} ` + - `(worker=${prev.worker ? 'live' : 'null'}); closing it before set`, + `[${tag(ds)}] Refusing to overwrite active routing occupant ` + + `${current.session.sessionId.substring(0, 8)}`, ); - await closeSession(prev.session.sessionId); + return false; } map.set(key, ds); + return true; +} + +export async function setActiveSessionSafe( + map: Map<string, DaemonSession>, + key: string, + ds: DaemonSession, +): Promise<boolean> { + return setActiveSessionIfActive(map, key, ds); +} + +/** + * Roll back a freshly-created row rejected by the registration CAS, then read + * the routing winner *after* that asynchronous rollback finishes. + * + * Message handlers use this to hand an already-deduped inbound event to a + * concurrent HTTP/dashboard/restore winner instead of silently dropping it. + * The post-await lookup matters: close cleanup must never return a stale object + * that another continuation replaced while the rollback was in flight. + */ +export async function rollbackRejectedSessionAndGetWinner( + map: Map<string, DaemonSession>, + key: string, + rejected: DaemonSession, + rollback: (sessionId: string) => Promise<unknown> = closeSession, +): Promise<DaemonSession | undefined> { + await rollback(rejected.session.sessionId); + const winner = map.get(key); + if (!winner || winner === rejected || winner.session.status !== 'active') return undefined; + return winner; } // ─── Session transfer (cross-chat relay) ──────────────────────────────────── @@ -1838,6 +2090,31 @@ export async function transferSession( const sourceAnchor = sessionAnchorId(ds); const targetAnchor = targetScope === 'chat' ? targetChatId : targetRootMessageId; if (targetAnchor === sourceAnchor) return { ok: false, error: 'same_anchor' }; + const sourceKey = sessionKey(sourceAnchor, ds.larkAppId); + const targetKey = sessionKey(targetAnchor, ds.larkAppId); + const validateSourceIdentity = (): { ok: false; error: string } | undefined => { + if ( + ds.session.status !== 'active' + || activeSessionsRegistry?.get(sourceKey) !== ds + ) { + return { ok: false, error: 'session_not_active' }; + } + return undefined; + }; + const validateAfterAwait = (): { ok: false; error: string } | undefined => { + const sourceError = validateSourceIdentity(); + if (sourceError) return sourceError; + const targetOccupant = activeSessionsRegistry?.get(targetKey); + if (targetOccupant && targetOccupant !== ds) { + return { ok: false, error: 'target_chat_has_session' }; + } + return undefined; + }; + // The initial target scan below intentionally permits worker-less command + // scratches and closes them. At entry only validate source ownership; after + // those awaited closes the strict validator requires the target to be empty. + const initialStateError = validateSourceIdentity(); + if (initialStateError) return initialStateError; // pendingRepo: the user created a session via M0 but hasn't picked a repo // yet, so worker is null and the CLI has never run. Relaying produces an @@ -1892,7 +2169,7 @@ export async function transferSession( if (existing === ds) continue; if (existing.larkAppId !== ds.larkAppId) continue; if (sessionAnchorId(existing) !== targetAnchor) continue; - if (!existing.worker) { + if (isDisposableCommandScratch(existing)) { scratchesToClose.push(existing.session.sessionId); continue; } @@ -1902,6 +2179,8 @@ export async function transferSession( for (const sid of scratchesToClose) { await closeSession(sid); } + const postScratchStateError = validateAfterAwait(); + if (postScratchStateError) return postScratchStateError; const fkw = opts?.forkWorkerImpl ?? forkWorker; const kw = opts?.killWorkerImpl ?? killWorker; @@ -1933,6 +2212,8 @@ export async function transferSession( } catch (err) { logger.warn(`[${tagPrefix}] freeze source-chat card failed: ${err instanceof Error ? err.message : err}`); } + const postFreezeStateError = validateAfterAwait(); + if (postFreezeStateError) return postFreezeStateError; } // Detach worker — TmuxBackend.kill() does NOT destroy the tmux session, so @@ -1971,7 +2252,9 @@ export async function transferSession( const newAnchor = sessionAnchorId(ds); if (activeSessionsRegistry) { - await setActiveSessionSafe(activeSessionsRegistry, sessionKey(newAnchor, ds.larkAppId), ds); + if (!setActiveSessionIfActive(activeSessionsRegistry, sessionKey(newAnchor, ds.larkAppId), ds)) { + return { ok: false, error: 'session_not_active' }; + } } dashboardEventBus.publish({ @@ -2009,6 +2292,20 @@ function resolvesToHome(p: string): boolean { catch { return p === homedir(); } } +function canForkRegisteredSession(ds: DaemonSession): boolean { + const key = activeSessionKey(ds); + const registered = activeSessionsRegistry ? activeSessionsRegistry.get(key) : ds; + if (ds.session.status === 'active' && registered === ds) return true; + if (activeSessionsRegistry && ds.session.status !== 'active' && registered === ds) { + activeSessionsRegistry.delete(key); + } + logger.warn( + `[${tag(ds)}] Refusing to fork a closed or superseded session ` + + `(status=${ds.session.status}, registered=${registered === ds})`, + ); + return false; +} + function codexAppInputForSession( ds: DaemonSession, input: CodexAppTurnInput | undefined, @@ -2098,6 +2395,7 @@ export function forkWorker( logger.info(`[${tag(ds)}] worker spawn deferred during device credential activation`); return; } + if (!canForkRegisteredSession(ds)) return; const cb = requireCallbacks(); const bot = getBot(ds.larkAppId); const botCfg = bot.config; @@ -3324,6 +3622,13 @@ function setupWorkerHandlers( logger.info(`[${t}] Managed/silent turn — TUI prompt kept in dashboard/audit only`); break; } + // Document-native sessions have no Lark chat/thread destination. Keep + // the dashboard/lifecycle attention state above, but never send a card + // to their internal `doc:<token>` routing anchor. + if (isDocNativeSession(ds)) { + logger.info(`[${t}] Doc-native session — suppressing TUI prompt card`); + break; + } try { const cardJson = buildTuiPromptCard( sessionAnchorId(ds), @@ -4186,7 +4491,7 @@ function deliverFinalOutput( try { // 文档评论入口分流:本轮若来自飞书文档评论(/watch-comment / /subscribe-lark-doc),把正文 // 发表为文档评论(而非飞书卡片),状态卡/占位卡仍留在飞书会话起点。 - const docTurn = managedReceiver ? undefined : ds.docCommentTurns?.get(msg.turnId); + const docTurn = managedReceiver ? undefined : resolveDocCommentTarget(ds, msg.turnId); if (docTurn) { // 嵌套回复到用户那条评论 thread(已挂在其下,无需再 ↪ 前缀)。这是兜底路径 // (模型没显式 botmux send),默认 @ 回原评论人,仅首块加。 @@ -4194,23 +4499,39 @@ function deliverFinalOutput( for (let i = 0; i < chunks.length; i++) { await replyToDocComment(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, chunks[i], i === 0 ? docTurn.replyToOpenId : undefined); } - // 清理 "Typing" reaction(bot 已回复完毕)。 + // The user-visible reply is committed. Consume the route and dedupe + // marker BEFORE best-effort reaction cleanup: a missing/expired + // reaction must never retry and duplicate the document comment. + consumeDocCommentTurn(ds, msg.turnId); + ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg); if (docTurn.reactionId && docTurn.replyId) { - await removeCommentReaction(ds.larkAppId, - { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, - docTurn.commentId, docTurn.replyId, docTurn.reactionId); - } - ds.docCommentTurns?.delete(msg.turnId); - // 同步清理磁盘上的 per-turn 落点,避免 session 文件堆积。 - if (ds.session.docCommentTargets && ds.session.docCommentTargets[msg.turnId]) { - delete ds.session.docCommentTargets[msg.turnId]; - try { sessionStore.updateSession(ds.session); } catch { /* best-effort */ } + try { + await removeCommentReaction(ds.larkAppId, + { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, + docTurn.commentId, docTurn.replyId, docTurn.reactionId); + } catch (err: any) { + logger.debug( + `[doc-comment] failed to remove completed reaction ${docTurn.reactionId}: ${err?.message ?? err}`, + ); + } } - ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg); logger.info(`[${t}] doc-comment final_output → posted ${chunks.length} comment(s) on file=${docTurn.fileToken.slice(0, 12)} (turn ${msg.turnId.substring(0, 8)})`); return; } + // A doc-native session has a virtual `doc:<token>` chat anchor, not a + // Feishu chat/thread. If its per-turn target is missing or corrupt there + // is no safe fallback destination; suppress instead of trying to post an + // interactive card to the virtual id. + if (isDocNativeSession(ds)) { + ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg); + logger.error( + `[${t}] Suppressed doc-native final_output without comment target ` + + `(turn ${msg.turnId.substring(0, 8)})`, + ); + return; + } + // Wrap the model's reply in the same card chrome `botmux send` uses // (schema 2.0 + footer with botmux link + 发送给 owner) so a turn // delivered via this fallback path looks identical in the Lark thread @@ -4539,6 +4860,7 @@ export function adoptSandboxBlocked( } export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata?: boolean; prompt?: string; turnId?: string }): void { + if (!canForkRegisteredSession(ds)) return; const cb = requireCallbacks(); const workerPath = join(__dirname, '..', 'worker.js'); const t = tag(ds); @@ -4914,8 +5236,19 @@ export function reapOrphanWorkers(opts: { // ─── Kill stale PIDs ──────────────────────────────────────────────────────── -export function killStalePids(activeSessions_: Session[]): void { +export function killStalePids( + activeSessions_: Session[], + runtimeSessions?: ReadonlyMap<string, DaemonSession>, +): void { + // Startup restore runs concurrently with the already-live dispatcher. A + // message may create/register a fresh worker for a snapshot row before this + // stale-process sweep starts. Treat the runtime registry as authoritative: + // never signal the PID of any logical session that is already registered. + const runtimeSessionIds = new Set( + runtimeSessions ? [...runtimeSessions.values()].map(ds => ds.session.sessionId) : [], + ); for (const session of activeSessions_) { + if (runtimeSessionIds.has(session.sessionId)) continue; if (!session.pid) continue; try { // Check if process exists (signal 0 doesn't kill, just checks) @@ -4934,24 +5267,42 @@ export function killStalePids(activeSessions_: Session[]): void { cleanupPersistentBackendSessions('tmux', activeSessions_); cleanupPersistentBackendSessions('herdr', activeSessions_); + cleanupPersistentBackendSessions('zmx', activeSessions_); } -function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr', activeSessions_: Session[]): void { +function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr' | 'zmx', activeSessions_: Session[]): void { + const storedSessions = sessionStore.listSessions(); const anyBackend = getAllBots().some(b => (b.config.backendType ?? config.daemon.backendType) === backendType) - || config.daemon.backendType === backendType; + || config.daemon.backendType === backendType + || activeSessions_.some(s => s.backendType === backendType) + || storedSessions.some(s => s.backendType === backendType); if (!anyBackend) return; - const backend = backendType === 'tmux' ? TmuxBackend : HerdrBackend; + const backend = backendType === 'tmux' ? TmuxBackend : backendType === 'zmx' ? ZmxBackend : HerdrBackend; const multiBot = getAllBots().length > 1; const cliIdFile = join(config.session.dataDir, backendType === 'tmux' ? 'last-cli-id' : `last-cli-id-${backendType}`); let lastCliId: string | undefined; try { lastCliId = readFileSync(cliIdFile, 'utf-8').trim(); } catch { /* first run */ } const currentCliId = config.daemon.cliId; + const belongsToBackend = (session: Session) => + session.backendType === backendType || + (session.backendType === undefined && backendType === 'tmux'); + const activeNames = new Set( + activeSessions_.filter(belongsToBackend).map(s => backend.sessionName(s.sessionId)), + ); + const ownedNames = new Set([ + ...storedSessions.filter(belongsToBackend).map(s => backend.sessionName(s.sessionId)), + ...activeNames, + ]); if (!multiBot && lastCliId && lastCliId !== currentCliId) { logger.info(`CLI_ID changed (${lastCliId} → ${currentCliId}), killing all ${backendType} sessions`); // Legacy per-topic hosts are still enumerable by bmx-* name. for (const name of backend.listBotmuxSessions()) { + // ZMX_DIR is a user-wide namespace and bmx-* is deterministic, not an + // ownership credential. Another checkout/data root can legitimately own + // a bmx-* daemon, so never kill a name absent from this bot's store/map. + if (backendType === 'zmx' && !ownedNames.has(name)) continue; backend.killSession(name); } // Machine-wide Herdr agents are not separate bmx-* sessions. Tear down @@ -4962,12 +5313,6 @@ function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr', activeS killPersistentBackendTarget(target); } } else { - const activeNames = new Set( - activeSessions_.map(s => backend.sessionName(s.sessionId)), - ); - const ownedNames = new Set( - sessionStore.listSessions().map(s => backend.sessionName(s.sessionId)), - ); for (const name of backend.listBotmuxSessions()) { if (ownedNames.has(name) && !activeNames.has(name)) { logger.info(`Killing orphaned ${backendType} session: ${name}`); @@ -4975,6 +5320,7 @@ function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr', activeS } } for (const session of activeSessions_) { + if (!belongsToBackend(session)) continue; const sessionCliId = session.cliId; if (!sessionCliId || !session.larkAppId) continue; let botCliId: CliId | undefined; diff --git a/src/daemon.ts b/src/daemon.ts index 59745574a..d5a19c72f 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -94,7 +94,7 @@ import { scrubTmuxServerGlobalEnv } from './setup/ensure-tmux.js'; import { entryNeedsContactResolve } from './setup/bot-config-editor.js'; import { invalidWorkingDirs } from './utils/working-dir.js'; import { validateWorkingDir } from './core/working-dir.js'; -import type { DaemonToWorker, LarkMessage } from './types.js'; +import type { DaemonToWorker, LarkAttachment, LarkMessage } from './types.js'; export type { DaemonSession } from './core/types.js'; import type { DaemonSession } from './core/types.js'; import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId } from './core/types.js'; @@ -122,6 +122,8 @@ import { CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, + setActiveSessionIfActive, + rollbackRejectedSessionAndGetWinner, ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, @@ -184,6 +186,7 @@ import { sweepOrphanSandboxes } from './adapters/backend/sandbox.js'; import { TmuxBackend } from './adapters/backend/tmux-backend.js'; import { HerdrBackend } from './adapters/backend/herdr-backend.js'; import { ZellijBackend } from './adapters/backend/zellij-backend.js'; +import { ZmxBackend } from './adapters/backend/zmx-backend.js'; import { sweepIdleWorkers, DEFAULT_MAX_LIVE_WORKERS } from './core/idle-worker-sweeper.js'; import { getSessionPersistentBackendType, @@ -508,7 +511,7 @@ const VC_MEETING_DELIVERY_LEASE_SCAN_MS = 60_000; const VC_MEETING_RUNTIME_EXPIRY_ACK_TIMEOUT_MS = 3_000; const VC_MEETING_RUNTIME_EXPIRY_TEARDOWN_MS = 8_000; const VC_MEETING_RUNTIME_EXPIRY_REPROBE_MS = 5_000; -const VC_MEETING_PERSISTENT_BACKENDS = ['tmux', 'herdr', 'zellij'] as const; +const VC_MEETING_PERSISTENT_BACKENDS = ['tmux', 'herdr', 'zellij', 'zmx'] as const; type VcMeetingDeliveryScope = { receiverSessionId: string; @@ -598,7 +601,8 @@ function isVcMeetingBootRecoveryBlocked(request: VcMeetingDeliveryRequest): bool function vcMeetingPersistentBackendAvailable(backendType: PersistentBackendType): boolean { if (backendType === 'tmux') return TmuxBackend.isAvailable(); if (backendType === 'herdr') return HerdrBackend.isAvailable(); - return ZellijBackend.isAvailable(); + if (backendType === 'zellij') return ZellijBackend.isAvailable(); + return ZmxBackend.isAvailable(); } let testOnlyVcMeetingBootBackingMissing: @@ -621,7 +625,8 @@ function vcMeetingBootBackingMissing(sessionId: string, destroy: boolean): boole const persistedBackend = liveBackend ?? persistedSession?.backendType; if (persistedBackend === 'pty') return true; const backendTypes: readonly PersistentBackendType[] = - persistedBackend === 'tmux' || persistedBackend === 'herdr' || persistedBackend === 'zellij' + persistedBackend === 'tmux' || persistedBackend === 'herdr' + || persistedBackend === 'zellij' || persistedBackend === 'zmx' ? [persistedBackend] : VC_MEETING_PERSISTENT_BACKENDS.filter(vcMeetingPersistentBackendAvailable); let allMissing = true; @@ -866,7 +871,7 @@ function createVcMeetingRuntimeLeaseRecovery(deps: VcMeetingRuntimeLeaseRecovery : persistentScope === 'unknown' // An unavailable client binary cannot own/reach a live mux session on // this host. Excluding it avoids permanent unknown fences on machines - // that only install one of the three supported backends. + // that only install one of the supported persistent backends. ? VC_MEETING_PERSISTENT_BACKENDS.filter(deps.backendAvailable) : [persistentScope]; let allMissing = true; @@ -1146,7 +1151,8 @@ const vcMeetingRuntimeLeaseRecovery = createVcMeetingRuntimeLeaseRecovery({ resolveMissingPersistentScope(sessionId) { const persisted = sessionStore.getSession(sessionId); const backendType = persisted?.backendType; - if (backendType === 'tmux' || backendType === 'herdr' || backendType === 'zellij') { + if (backendType === 'tmux' || backendType === 'herdr' + || backendType === 'zellij' || backendType === 'zmx') { return backendType; } return backendType === 'pty' ? 'none' : 'unknown'; @@ -1166,6 +1172,190 @@ const vcMeetingRuntimeLeaseRecovery = createVcMeetingRuntimeLeaseRecovery({ error: message => logger.error(message), }); +/** + * Identity captured before a document-comment path crosses an async boundary. + * A DaemonSession object can outlive its routing ownership: close, relay, repo + * switch, or another creator may replace either the map occupant or its Session + * object while an awaited Lark lookup is in flight. Both identities and the + * original routing key therefore form one generation. + */ +interface RoutingGeneration { + key: string; + ds: DaemonSession; + session: Session; +} + +function captureRoutingGeneration(ds: DaemonSession): RoutingGeneration { + return { + key: sessionKey(sessionAnchorId(ds), ds.larkAppId), + ds, + session: ds.session, + }; +} + +function isCurrentRoutingGeneration(generation: RoutingGeneration): boolean { + const { key, ds, session } = generation; + return session.status === 'active' + && ds.session === session + && sessionKey(sessionAnchorId(ds), ds.larkAppId) === key + && activeSessions.get(key) === ds; +} + +function ensureCurrentRoutingGeneration(generation: RoutingGeneration, phase: string): void { + if (isCurrentRoutingGeneration(generation)) return; + throw new Error(`session routing generation changed during ${phase}`); +} + +type DocSessionRollback = typeof rollbackRejectedSessionAndGetWinner; + +/** Register one freshly-created doc-native session without overwriting a + * concurrent winner. The rejected row is closed before the winner is returned, + * so its persisted record cannot survive as a ghost-active session. */ +async function registerDocSessionCandidate( + key: string, + candidate: DaemonSession, + rollback: DocSessionRollback = rollbackRejectedSessionAndGetWinner, +): Promise<DaemonSession | null> { + if (setActiveSessionIfActive(activeSessions, key, candidate)) return candidate; + return await rollback(activeSessions, key, candidate) ?? null; +} + +interface DocBindingPersistenceDeps { + dataDir?: string; + read?: typeof getDocSubscription; + write?: typeof putDocSubscription; +} + +function sameDocBindingRoute(a: DocSubscription, b: DocSubscription): boolean { + return a.sessionAnchor === b.sessionAnchor + && a.sessionId === b.sessionId + && a.scope === b.scope + && a.chatId === b.chatId; +} + +/** + * Move one subscription onto the authoritative route owned by `selected`. + * + * The store is re-read immediately before the synchronous write so cursor and + * metadata updates made from another event are retained. A newer explicit + * rebind is never overwritten: the only route change accepted since `sub` was + * read is the same selected route (the normal concurrent auto-create winner + * case). Mutating the caller's snapshot keeps the current comment turn on the + * same route as the persisted subscription. + */ +function persistDocBindingToSession( + sub: DocSubscription, + larkAppId: string, + selected: DaemonSession, + deps: DocBindingPersistenceDeps = {}, +): void { + const dataDir = deps.dataDir ?? config.session.dataDir; + const read = deps.read ?? getDocSubscription; + const write = deps.write ?? putDocSubscription; + const current = read(dataDir, larkAppId, sub.fileToken); + if (!current) { + throw new Error(`document subscription was removed during routing: ${sub.fileToken.slice(0, 12)}`); + } + + const selectedRoute: Pick<DocSubscription, 'sessionAnchor' | 'sessionId' | 'scope' | 'chatId'> = { + sessionAnchor: sessionAnchorId(selected), + sessionId: selected.session.sessionId, + scope: selected.scope, + chatId: selected.chatId, + }; + const currentTargetsSelected = current.sessionAnchor === selectedRoute.sessionAnchor + && current.sessionId === selectedRoute.sessionId + && current.scope === selectedRoute.scope + && current.chatId === selectedRoute.chatId; + if (!sameDocBindingRoute(current, sub) && !currentTargetsSelected) { + throw new Error(`document subscription was rebound during routing: ${sub.fileToken.slice(0, 12)}`); + } + + const rebound: DocSubscription = { ...current, ...selectedRoute }; + write(dataDir, larkAppId, rebound); + Object.assign(sub, rebound); +} + +type PersistDocBinding = typeof persistDocBindingToSession; +type RollbackDocCandidate = (sessionId: string) => Promise<unknown>; + +/** Persist the route selected by registration. A candidate that won the CAS + * is now daemon-owned, so a failed subscription write must close only that + * exact occupant. A rejected candidate must never close the concurrent + * winner whose route it received from registerDocSessionCandidate(). */ +async function persistSelectedDocBinding( + routingKey: string, + sub: DocSubscription, + larkAppId: string, + selected: DaemonSession, + candidate: DaemonSession, + persist: PersistDocBinding = persistDocBindingToSession, + rollback: RollbackDocCandidate = closeSessionHelper, +): Promise<void> { + try { + persist(sub, larkAppId, selected); + } catch (err) { + if (selected === candidate && activeSessions.get(routingKey) === candidate) { + try { + await rollback(candidate.session.sessionId); + } catch (rollbackErr) { + logger.error( + `[doc-comment] failed to roll back auto-created session ` + + `${candidate.session.sessionId.slice(0, 8)} after binding error: ` + + `${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`, + ); + } finally { + // closeSessionHelper removes this synchronously in production. Keep a + // final identity guard for injected/test rollback failures so no newer + // route owner can be evicted. + if (activeSessions.get(routingKey) === candidate) activeSessions.delete(routingKey); + candidate.session.status = 'closed'; + candidate.session.closedAt ??= new Date().toISOString(); + } + } + throw err; + } +} + +function ownsCurrentRoute(ds: DaemonSession, larkAppId: string): boolean { + return ds.larkAppId === larkAppId + && ds.session.status === 'active' + && activeSessions.get(sessionKey(sessionAnchorId(ds), larkAppId)) === ds; +} + +/** Resolve a binding without ever handing a stale anchor's replacement the + * comment. If the subscribed session was relayed, find it by stable sessionId + * and lazily migrate the subscription to its new authoritative route. */ +function resolveBoundDocSession( + sub: DocSubscription, + larkAppId: string, + persist: PersistDocBinding = persistDocBindingToSession, +): DaemonSession | undefined { + const direct = activeSessions.get(sessionKey(sub.sessionAnchor, larkAppId)); + if ( + direct + && ownsCurrentRoute(direct, larkAppId) + && (!sub.sessionId || direct.session.sessionId === sub.sessionId) + ) { + return direct; + } + + if (!sub.sessionId) return undefined; + const relocated = [...new Set(activeSessions.values())].find(candidate => + candidate.session.sessionId === sub.sessionId + && ownsCurrentRoute(candidate, larkAppId)); + if (!relocated) return undefined; + persist(sub, larkAppId, relocated); + return relocated; +} + +export const __testOnly_captureRoutingGeneration = captureRoutingGeneration; +export const __testOnly_isCurrentRoutingGeneration = isCurrentRoutingGeneration; +export const __testOnly_registerDocSessionCandidate = registerDocSessionCandidate; +export const __testOnly_persistDocBindingToSession = persistDocBindingToSession; +export const __testOnly_persistSelectedDocBinding = persistSelectedDocBinding; +export const __testOnly_resolveBoundDocSession = resolveBoundDocSession; + type VcMeetingDaemonSession = { larkAppId: string; state: VcMeetingSessionState; @@ -3744,6 +3934,8 @@ function isSessionlessCommandInvocation(cmd: string, content: string): boolean { } async function prewarmDocCommentSession(ds: DaemonSession, sub: DocSubscription): Promise<void> { + const generation = captureRoutingGeneration(ds); + ensureCurrentRoutingGeneration(generation, 'prewarm:start'); const bot = getBot(ds.larkAppId); const botCfg = bot.config; const loc = localeForBot(ds.larkAppId); @@ -3757,6 +3949,7 @@ async function prewarmDocCommentSession(ds: DaemonSession, sub: DocSubscription) const title = `[Doc Watch] ${sub.fileToken.slice(0, 12)}`; const turnId = `doc-watch-${Date.now()}-${sub.fileToken.slice(0, 8)}`; const sender = sub.ownerOpenId ? await resolveSender(ds.larkAppId, sub.ownerOpenId, 'user') : undefined; + ensureCurrentRoutingGeneration(generation, 'prewarm:sender'); beginNewTurn(ds, title); ds.lastMessageAt = Date.now(); @@ -14870,6 +15063,66 @@ function isInitialSessionPassthrough(larkAppId: string, cmd: string): boolean { return resolveAdapterDefaultPassthroughCommands(larkAppId).includes(cmd); } +/** Preserve the established mid-session passthrough semantics when a cold-start + * scratch loses its registration race to a concurrently-created real session. */ +function deliverPassthroughToExistingSession( + ds: DaemonSession, + cmd: string, + commandContent: string, + anchor: string, + larkAppId: string, + turn: { + messageId: string; + replyRootId?: string; + senderOpenId?: string; + senderIsBot: boolean; + substitute: boolean; + }, +): void { + if (ds.worker && !ds.worker.killed) { + // Passthrough commands bypass the normal message-forwarding block, so bind + // the accepted Lark turn before the worker rotates its marker at the PTY + // write boundary. This helper also covers a cold-start registration race. + ds.session.quoteTargetId = turn.messageId; + ds.session.quoteTargetSenderOpenId = turn.senderOpenId; + ds.session.quoteTargetSenderIsBot = turn.senderIsBot; + const substituteReplyMode = turn.substitute + ? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread') + : 'thread'; + beginReplyTargetTurn(ds, turn.replyRootId, turn.messageId, new Date().toISOString(), { + quoteOnly: substituteReplyMode === 'quote', + substitute: turn.substitute, + }); + if (turn.senderOpenId && ds.session.lastCallerOpenId !== turn.senderOpenId) { + ds.session.lastCallerOpenId = turn.senderOpenId; + } + sessionStore.updateSession(ds.session); + // Compatibility note (empty-start opening): a raw passthrough is a + // LITERAL CLI command — its contract is that the CLI sees exactly the + // bytes the user typed, never a botmux XML envelope. So it deliberately + // does NOT consume `Session.initialUserTurnPending`: wrapping `/model` + // in `<user_message>` would break the literal contract, and silently + // clearing the marker would lose the opening for the next real turn. + // `/model` on an empty-started session therefore stays literal and the + // FOLLOWING business message still opens as a new topic. + beginNewTurn(ds, commandContent); + ds.worker.send({ + type: 'raw_input', + content: commandContent, + turnId: turn.messageId, + } as DaemonToWorker); + markSessionActivity(ds); + logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`); + return; + } + void sessionReply( + anchor, + tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), + 'text', + larkAppId, + ); +} + async function startInitialPassthroughSession(args: { larkAppId: string; chatId: string; @@ -14879,8 +15132,10 @@ async function startInitialPassthroughSession(args: { messageId: string; replyRootId?: string; parsed: LarkMessage; + cmd: string; commandContent: string; senderOpenId?: string; + substitute: boolean; /** Bot-locked union_id (quota gate's teamBot leg — bot senders only). */ senderUnionId?: string; /** Raw sender union_id (quota gate's teamMember leg — may be a human). */ @@ -14895,7 +15150,7 @@ async function startInitialPassthroughSession(args: { }): Promise<void> { const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, - parsed, commandContent, senderOpenId, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, + parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, } = args; if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType)) { return; @@ -14965,7 +15220,31 @@ async function startInitialPassthroughSession(args: { } beginReplyTargetTurn(ds, replyRootId, messageId); sessionStore.updateSession(ds.session); - activeSessions.set(sessionKey(anchor, larkAppId), ds); + const creationKey = sessionKey(anchor, larkAppId); + if (!setActiveSessionIfActive(activeSessions, creationKey, ds)) { + const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds); + if (winner) { + logger.info( + `[${session.sessionId.substring(0, 8)}] Initial passthrough registration lost to ` + + `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`, + ); + deliverPassthroughToExistingSession(winner, cmd, commandContent, anchor, larkAppId, { + messageId, + replyRootId, + senderOpenId, + senderIsBot: parsed.senderType === 'app' || parsed.senderType === 'bot', + substitute, + }); + } else { + void sessionReply( + anchor, + tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), + 'text', + larkAppId, + ); + } + return; + } if (pinnedWorkingDir && autoWt) { if (await replyInvalidWorkingDirs(anchor, larkAppId, ds)) return; @@ -15229,8 +15508,10 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { messageId, replyRootId, parsed, + cmd, commandContent, senderOpenId, + substitute: !!substituteTrigger, senderUnionId: teamTrustUnionId, memberUnionId: senderUnionId, // 原始 union(人腿),不锁 bot // New-topic senders are humans here (mirrors the normal new-topic @@ -15315,7 +15596,7 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir }; } sessionStore.updateSession(session); - activeSessions.set(sessionKey(anchor, larkAppId), { + const commandDs: DaemonSession = { session, worker: null, workerPort: null, @@ -15330,7 +15611,16 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { hasHistory: false, ownerOpenId: senderOpenId, ...cmdPending, - }); + }; + const commandKey = sessionKey(anchor, larkAppId); + if (!setActiveSessionIfActive(activeSessions, commandKey, commandDs)) { + const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, commandDs); + if (!winner) return; + logger.info( + `[${session.sessionId.substring(0, 8)}] Daemon-command registration lost to ` + + `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`, + ); + } // Pass mention-stripped content so /command argument parsing works. await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId); return; @@ -15470,7 +15760,30 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { : 'thread'; beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger }); sessionStore.updateSession(ds.session); - activeSessions.set(sessionKey(anchor, larkAppId), ds); + const creationKey = sessionKey(anchor, larkAppId); + if (!setActiveSessionIfActive(activeSessions, creationKey, ds)) { + const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds); + if (winner) { + logger.info( + `[${session.sessionId.substring(0, 8)}] New-topic registration lost to ` + + `${winner.session.sessionId.substring(0, 8)}; handing message to routing winner`, + ); + await handleThreadReply( + data, + { ...ctx, scope, anchor }, + { + parsed, + resources, + attachments, + quotaChecked: true, + queueAlreadyAppended: true, + senderResolved: true, + sender: newTopicSender, + }, + ); + } + return; + } // Auto-worktree: session is registered PENDING; build the worktree off the // critical path, then commitRepoSelection pins it + forks (folding in any @@ -15704,7 +16017,10 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString()); sessionStore.updateSession(ds.session); } - activeSessions.set(dsKey, ds); + if (!setActiveSessionIfActive(activeSessions, dsKey, ds)) { + await closeSessionHelper(session.sessionId); + return; + } // Register the anchor so a later duplicate bot.added for this chat is deduped // even in 话题群 (where dsKey is the seed id, not chatId). groupJoinAnchorByChat.set(chatLiveKey, dsKey); @@ -15796,18 +16112,40 @@ function lookupForeignBotName(senderOpenId: string, larkAppId: string): string { return 'Bot'; } -async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> { +/** + * Work already completed by handleNewTopic (or an earlier auto-create pass) + * before its registration CAS lost to a concurrent session creator. Reusing + * it avoids a second quota/download/queue side effect when the same persisted- + * dedupe event is handed to the routing winner. + */ +interface PreparedThreadReply { + parsed: LarkMessage; + resources: MessageResource[]; + attachments: LarkAttachment[]; + quotaChecked: true; + queueAlreadyAppended: true; + senderResolved: true; + sender: ResolvedSender | undefined; +} + +async function handleThreadReply( + data: any, + ctx: RoutingContext, + prepared?: PreparedThreadReply, +): Promise<void> { const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId, replyRootId, substituteTrigger } = ctx; - await resolveNonsupportMessage(data, larkAppId); - const { parsed, resources } = parseEventMessage(data); + if (!prepared) await resolveNonsupportMessage(data, larkAppId); + const parsedResult = prepared ?? parseEventMessage(data); + const parsed = parsedResult.parsed; + const resources = parsedResult.resources; // Expand merge_forward: fetch sub-messages and collect their resources - if (parsed.msgType === 'merge_forward') { + if (!prepared && parsed.msgType === 'merge_forward') { const { extraResources } = await expandMergeForward(larkAppId, parsed.messageId, parsed); resources.push(...extraResources); } - learnFromMentions(larkAppId, parsed.mentions); + if (!prepared) learnFromMentions(larkAppId, parsed.mentions); // Foreign bot @mention prefix: when sender is another botmux bot,把内容包成 // [来自 X 的 @mention]\n<原文> 喂给 worker,让 CLI 知道这是另一个 bot 发的—— @@ -15844,27 +16182,29 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> + parsed.content; let promptContent = initialPromptContent; let rewrittenCodexAppMessageContext: string | undefined; - const existingHookSession = activeSessions.get(sessionKey(anchor, larkAppId)); - emitHookEvent('thread.reply', { - larkAppId, - chatId: ctxChatId, - chatType: ctxChatType, - scope, - anchor, - messageId: parsed.messageId, - rootId: parsed.rootId, - parentId: parsed.parentId, - senderOpenId: senderOpenIdForPrefix, - senderType: parsed.senderType, - msgType: parsed.msgType, - sessionId: existingHookSession?.session.sessionId, - content: parsed.content, - }); - if (isForeignBot) { - logger.info( - `[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` + - `senderType=${parsed.senderType} via=${isBotSenderType ? 'sender_type' : 'cross-ref'}`, - ); + if (!prepared) { + const existingHookSession = activeSessions.get(sessionKey(anchor, larkAppId)); + emitHookEvent('thread.reply', { + larkAppId, + chatId: ctxChatId, + chatType: ctxChatType, + scope, + anchor, + messageId: parsed.messageId, + rootId: parsed.rootId, + parentId: parsed.parentId, + senderOpenId: senderOpenIdForPrefix, + senderType: parsed.senderType, + msgType: parsed.msgType, + sessionId: existingHookSession?.session.sessionId, + content: parsed.content, + }); + if (isForeignBot) { + logger.info( + `[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` + + `senderType=${parsed.senderType} via=${isBotSenderType ? 'sender_type' : 'cross-ref'}`, + ); + } } // resolveSender is deferred until we know the message actually needs prompt @@ -15872,8 +16212,8 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> // anchor" all return early; routing them through resolveSender first would // tack the 800ms budget onto paths that never see the sender tag. Use the // helper below at every actual injection point. - let threadSenderCached: import('./im/lark/identity-cache.js').ResolvedSender | undefined; - let threadSenderResolved = false; + let threadSenderCached: ResolvedSender | undefined = prepared?.sender; + let threadSenderResolved = prepared?.senderResolved ?? false; const getThreadSender = async (): Promise<typeof threadSenderCached> => { if (threadSenderResolved) return threadSenderCached; threadSenderResolved = true; @@ -16027,8 +16367,10 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> messageId: parsed.messageId, replyRootId, parsed, + cmd, commandContent, senderOpenId: threadSenderOpenId, + substitute: !!substituteTrigger, senderUnionId: threadTeamTrustUnionId, memberUnionId: threadSenderUnionId, // 原始 union(人腿),不锁 bot // Bot-started cold starts get no human owner (mirrors the auto-create @@ -16047,47 +16389,16 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> // 拉起)。TODO(后续产品决策):是否把 CLI passthrough 也纳入 canOperate, // 收紧到与 daemon 命令同档;这会同时改变真人 oncall 成员的现有行为,应单独评估。 const ds = existingDs; - if (ds?.worker && !ds.worker.killed) { - // Passthrough commands bypass the normal message-forwarding block - // below, so bind this accepted Lark turn here before the worker rotates - // its marker at the actual PTY write boundary. - ds.session.quoteTargetId = parsed.messageId; - ds.session.quoteTargetSenderOpenId = threadSenderOpenId; - ds.session.quoteTargetSenderIsBot = isForeignBot; - const substituteReplyMode = substituteTrigger - ? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread') - : 'thread'; - beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { - quoteOnly: substituteReplyMode === 'quote', + if (ds) { + deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, larkAppId, { + messageId: parsed.messageId, + replyRootId, + senderOpenId: threadSenderOpenId, + senderIsBot: isForeignBot, substitute: !!substituteTrigger, }); - if (threadSenderOpenId && ds.session.lastCallerOpenId !== threadSenderOpenId) { - ds.session.lastCallerOpenId = threadSenderOpenId; - } - sessionStore.updateSession(ds.session); - // Mark a new turn so the CLI's response to /model, /clear, /compact, etc. - // shows up as a fresh streaming card instead of silently PATCH-ing the - // previous turn's card. - // - // Compatibility note (empty-start opening): a raw passthrough is a - // LITERAL CLI command — its contract is that the CLI sees exactly the - // bytes the user typed, never a botmux XML envelope. So it deliberately - // does NOT consume `Session.initialUserTurnPending`: wrapping `/model` - // in `<user_message>` would break the literal contract, and silently - // clearing the marker would lose the opening for the next real turn. - // `/model` on an empty-started session therefore stays literal and the - // FOLLOWING business message still opens as a new topic. - beginNewTurn(ds, commandContent); - ds.worker.send({ - type: 'raw_input', - content: commandContent, - turnId: parsed.messageId, - } as DaemonToWorker); - markSessionActivity(ds); - logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`); - } else { - sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId); } + else void sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId); return; } if (DAEMON_COMMANDS.has(cmd)) { @@ -16143,7 +16454,7 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir }; } sessionStore.updateSession(session); - activeSessions.set(sessionKey(anchor, larkAppId), { + const threadCommandDs: DaemonSession = { session, worker: null, workerPort: null, @@ -16158,7 +16469,16 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> hasHistory: false, ownerOpenId: threadSenderOpenId, ...cmdPending, - }); + }; + const commandKey = sessionKey(anchor, larkAppId); + if (!setActiveSessionIfActive(activeSessions, commandKey, threadCommandDs)) { + const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, threadCommandDs); + if (!winner) return; + logger.info( + `[${session.sessionId.substring(0, 8)}] Thread daemon-command registration lost to ` + + `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`, + ); + } } // Pass mention-stripped content so /command argument parsing works. // chatId lets session-less handlers (e.g. /group) reach the chat roster. @@ -16223,7 +16543,16 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> } const quotaSenderOpenId = threadSenderOpenId; - if (!await enforceMessageQuotaForCliInput(larkAppId, ctxChatId ?? data?.message?.chat_id, quotaSenderOpenId, parsed.messageId, anchor, threadTeamTrustUnionId, threadSenderUnionId, ctxChatType)) { + if (!prepared?.quotaChecked && !await enforceMessageQuotaForCliInput( + larkAppId, + ctxChatId ?? data?.message?.chat_id, + quotaSenderOpenId, + parsed.messageId, + anchor, + threadTeamTrustUnionId, + threadSenderUnionId, + ctxChatType, + )) { return; } @@ -16237,12 +16566,18 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> // Download attachments const effectiveAppId = ds?.larkAppId ?? larkAppId; - const { attachments, needLogin } = await downloadResources(effectiveAppId, parsed.messageId, resources); - if (attachments.length > 0) { - parsed.attachments = attachments; - } - if (needLogin) { - sessionReply(anchor, tr('daemon.download_failed_need_login', undefined, localeForBot(effectiveAppId)), 'text', effectiveAppId); + let attachments: LarkAttachment[]; + if (prepared) { + attachments = prepared.attachments; + } else { + const downloaded = await downloadResources(effectiveAppId, parsed.messageId, resources); + attachments = downloaded.attachments; + if (attachments.length > 0) { + parsed.attachments = attachments; + } + if (downloaded.needLogin) { + sessionReply(anchor, tr('daemon.download_failed_need_login', undefined, localeForBot(effectiveAppId)), 'text', effectiveAppId); + } } // Update last message time + last caller (used by `botmux send` to address @@ -16374,7 +16709,7 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> // Route to file queue (keyed by anchor: rootMessageId for thread, chatId for chat) messageQueue.ensureQueue(anchor); - messageQueue.appendMessage(anchor, parsed); + if (!prepared?.queueAlreadyAppended) messageQueue.appendMessage(anchor, parsed); if (!ds) { // No active session at this anchor — auto-create. This branch is mostly a @@ -16493,7 +16828,26 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> : 'thread'; beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger }); sessionStore.updateSession(newDs.session); - activeSessions.set(sessionKey(anchor, larkAppId), newDs); + const creationKey = sessionKey(anchor, larkAppId); + if (!setActiveSessionIfActive(activeSessions, creationKey, newDs)) { + const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, newDs); + if (winner) { + logger.info( + `[${session.sessionId.substring(0, 8)}] Reply auto-create registration lost to ` + + `${winner.session.sessionId.substring(0, 8)}; handing message to routing winner`, + ); + await handleThreadReply(data, ctx, { + parsed, + resources, + attachments, + quotaChecked: true, + queueAlreadyAppended: true, + senderResolved: true, + sender: autoCreateSender, + }); + } + return; + } // Auto-worktree: register PENDING, build worktree off-path, commit+fork later. if (pinnedWorkingDir && autoWt) { @@ -16811,11 +17165,21 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> * 2) bot 的 defaultWorkingDir / workingDir 配置 * 3) fallback 到 ~ * - * 创建后立即 fork worker 并把评论内容作为首轮输入。 - * 返回创建好的 DaemonSession(已加入 activeSessions),失败返回 null。 + * 本函数只同步建立 session skeleton 并竞争 routing ownership;不解析评论 + * 作者、不写 per-turn target、也不 fork。胜出的 authoritative + * handleDocComment delivery owner 统一完成这些动作。 + * 返回胜出的 DaemonSession(已加入 activeSessions),失败返回 null。 */ async function autoCreateDocSession(sub: DocSubscription, larkAppId: string, ctx: DocCommentContext): Promise<DaemonSession | null> { const botCfg = getBot(larkAppId).config; + const virtualChatId = `doc:${sub.fileToken}`; + const virtualAnchor = virtualChatId; + const routingKey = sessionKey(virtualAnchor, larkAppId); + const existing = activeSessions.get(routingKey); + if (existing?.session.status === 'active' && ownsCurrentRoute(existing, larkAppId)) { + persistDocBindingToSession(sub, larkAppId, existing); + return existing; + } // 解析 workingDir:订阅时指定的 > bot 配置 defaultWorkingDir > bot 配置 workingDir > ~ const workingDir = sub.workingDir @@ -16823,11 +17187,8 @@ async function autoCreateDocSession(sub: DocSubscription, larkAppId: string, ctx ?? botCfg.workingDir ?? '~'; - const sender = ctx.authorOpenId ? await resolveSender(larkAppId, ctx.authorOpenId, 'user') : undefined; const title = `[Doc] ${sub.fileToken.slice(0, 8)}: ${ctx.text.slice(0, 40)}`; - const virtualChatId = `doc:${sub.fileToken}`; - const virtualAnchor = sub.sessionAnchor; const now = Date.now(); const session = sessionStore.createSession(virtualChatId, virtualAnchor, title, 'group'); @@ -16857,36 +17218,66 @@ async function autoCreateDocSession(sub: DocSubscription, larkAppId: string, ctx currentTurnTitle: ctx.text.substring(0, 50), }; - // 记录本轮回评论落点 - const turnId = ctx.replyId || ctx.commentId; - (ds.docCommentTurns ??= new Map()).set(turnId, { - fileToken: sub.fileToken, - fileType: sub.fileType, - commentId: ctx.commentId, - replyToOpenId: ctx.authorOpenId, - replyToName: sender?.name, - replyId: ctx.replyId, - reactionId: undefined, // 由调用方在加 reaction 后回填 - }); + const selected = await registerDocSessionCandidate(routingKey, ds); + if (!selected) { + logger.warn(`[doc-comment] auto-create registration lost without an active winner file=${sub.fileToken.slice(0, 12)}`); + return null; + } + await persistSelectedDocBinding(routingKey, sub, larkAppId, selected, ds); + if (selected !== ds) { + logger.info( + `[doc-comment] auto-create ${session.sessionId.slice(0, 8)} lost to ` + + `${selected.session.sessionId.slice(0, 8)}; handing comment to routing winner`, + ); + return selected; + } - const docTarget = { - fileToken: sub.fileToken, - fileType: sub.fileType, - commentId: ctx.commentId, - replyToName: sender?.name, - replyToOpenId: ctx.authorOpenId, - turnId, - replyId: ctx.replyId, - }; - (ds.session.docCommentTargets ??= {})[turnId] = docTarget; - try { sessionStore.updateSession(ds.session); } catch { /* best-effort */ } + // 不在这里 forkWorker —— handleDocComment 会统一处理 reaction、per-turn + // 回复落点及 fork/send。这里只建好 session skeleton。 + logger.info(`[doc-comment] auto-created session for file=${sub.fileToken.slice(0, 12)} (wd=${workingDir}, cli=${botCfg.cliId})`); + return selected; +} + +/** + * One authoritative delivery result per document-comment turn. Event delivery + * and the --all poller can observe the same reply concurrently; followers must + * await the owner's real result rather than treating an in-flight claim as a + * success and advancing the poll cursor past a later failure. + */ +const handledDocCommentTurns = new BoundedMap<string, Promise<boolean>>(5_000); - activeSessions.set(sessionKey(virtualAnchor, larkAppId), ds); +async function runClaimedDocCommentTurn( + claimKey: string, + work: () => Promise<void>, + onOwnerFailure?: (error: unknown) => Promise<void>, +): Promise<boolean> { + const existing = handledDocCommentTurns.get(claimKey); + if (existing) return await existing; - // 不在这里 forkWorker —— handleDocComment 会统一处理(它会检查 worker 状态、 - // 加 reaction、设 docCommentTurns、然后 fork 或 send)。这里只建好 session 骨架。 - logger.info(`[doc-comment] auto-created session for file=${sub.fileToken.slice(0, 12)} (wd=${workingDir}, cli=${botCfg.cliId})`); - return ds; + let resolveClaim!: (ok: boolean) => void; + const claim = new Promise<boolean>((resolve) => { resolveClaim = resolve; }); + handledDocCommentTurns.set(claimKey, claim); + + try { + await work(); + resolveClaim(true); + return true; + } catch (err) { + try { + await onOwnerFailure?.(err); + } finally { + if (handledDocCommentTurns.get(claimKey) === claim) { + handledDocCommentTurns.delete(claimKey); + } + resolveClaim(false); + } + throw err; + } +} + +export const __testOnly_runClaimedDocCommentTurn = runClaimedDocCommentTurn; +export function __testOnly_resetDocCommentClaims(): void { + handledDocCommentTurns.clear(); } /** @@ -16904,142 +17295,211 @@ async function handleDocComment(ctx: DocCommentContext): Promise<boolean> { const { larkAppId, sub, commentId, text } = ctx; const turnId = ctx.replyId || commentId; const claimKey = `${larkAppId}:${sub.fileToken}:${turnId}`; - if (handledDocCommentTurns.has(claimKey)) { - logger.info(`[doc-comment] duplicate turn skipped file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}`); - return true; // 已处理过,算成功(让 poller 推进游标) - } - const loc = localeForBot(larkAppId); + const userReplyId = ctx.replyId; + let reactionId: string | undefined; + let deliveryDs: DaemonSession | undefined; + let deliverySession: Session | undefined; + + const targetMatchesThisTurn = (target: { + fileToken: string; + fileType: string; + commentId: string; + replyId?: string; + } | undefined): boolean => !!target + && target.fileToken === sub.fileToken + && target.fileType === sub.fileType + && target.commentId === commentId + && target.replyId === userReplyId; + + const cleanupFailedDelivery = async (): Promise<void> => { + if (deliveryDs) { + const runtimeTarget = deliveryDs.docCommentTurns?.get(turnId); + if (targetMatchesThisTurn(runtimeTarget)) { + deliveryDs.docCommentTurns?.delete(turnId); + } + } - let ds: DaemonSession | undefined | null = activeSessions.get(sessionKey(sub.sessionAnchor, larkAppId)); - if (!ds) { - // 无活跃 session → 自动为该文档创建一个(用虚拟 anchor = doc:{fileToken}) - logger.info(`[doc-comment] no active session for anchor=${sub.sessionAnchor.slice(0, 12)}; auto-creating for file=${sub.fileToken.slice(0, 12)}`); - ds = await autoCreateDocSession(sub, larkAppId, ctx); - if (!ds) { - // auto-create 失败:不设 claim(允许后续重试),返回 false 让 poller 不推进游标 - logger.warn(`[doc-comment] auto-create session failed for file=${sub.fileToken.slice(0, 12)}; will retry comment ${commentId.slice(0, 12)}`); - return false; + let removedPersistedTarget = false; + if (deliverySession) { + const persistedTarget = deliverySession.docCommentTargets?.[turnId]; + if (targetMatchesThisTurn(persistedTarget)) { + delete deliverySession.docCommentTargets?.[turnId]; + removedPersistedTarget = true; + } } - } - // ds 确认有效后才设 claim——auto-create 失败时不占坑,允许重试。 - handledDocCommentTurns.set(claimKey, Date.now()); + if (removedPersistedTarget && deliverySession) { + try { + // Persist only the exact object still owned by this bot store. A stale + // continuation must not reinsert a displaced/cross-file Session row. + if (sessionStore.getOwnedSession(deliverySession.sessionId) === deliverySession) { + sessionStore.updateSession(deliverySession); + } + } catch (persistErr) { + logger.debug( + `[doc-comment] failed to persist aborted target cleanup ` + + `file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}: ` + + `${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, + ); + } + } + + if (reactionId && userReplyId) { + try { + await removeCommentReaction( + larkAppId, + { fileToken: sub.fileToken, fileType: sub.fileType }, + commentId, + userReplyId, + reactionId, + ); + } catch (reactionErr) { + logger.debug( + `[doc-comment] failed to remove Typing after aborted delivery ` + + `file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}: ` + + `${reactionErr instanceof Error ? reactionErr.message : String(reactionErr)}`, + ); + } + } + }; try { - // 给用户的回复加 "Typing" reaction,让评论者知道 bot 正在处理。 - const userReplyId = ctx.replyId; - let reactionId: string | undefined; - if (userReplyId) { - reactionId = await addCommentReaction(larkAppId, - { fileToken: sub.fileToken, fileType: sub.fileType }, - commentId, userReplyId, 'Typing'); - } + const duplicate = handledDocCommentTurns.has(claimKey); + if (duplicate) { + logger.info(`[doc-comment] duplicate turn awaiting owner file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}`); + } + return await runClaimedDocCommentTurn(claimKey, async () => { + const loc = localeForBot(larkAppId); + let ds: DaemonSession | undefined | null = resolveBoundDocSession(sub, larkAppId); + if (!ds) { + // 无活跃 session → 自动为该文档创建一个(用虚拟 anchor = doc:{fileToken}) + logger.info(`[doc-comment] no active session for anchor=${sub.sessionAnchor.slice(0, 12)}; auto-creating for file=${sub.fileToken.slice(0, 12)}`); + ds = await autoCreateDocSession(sub, larkAppId, ctx); + if (!ds) { + throw new Error(`auto-create session failed for ${sub.fileToken.slice(0, 12)}`); + } + } + deliveryDs = ds; + const generation = captureRoutingGeneration(ds); + deliverySession = generation.session; + ensureCurrentRoutingGeneration(generation, 'comment:start'); + + // 给用户的回复加 "Typing" reaction,让评论者知道 bot 正在处理。 + if (userReplyId) { + reactionId = await addCommentReaction(larkAppId, + { fileToken: sub.fileToken, fileType: sub.fileType }, + commentId, userReplyId, 'Typing'); + } + ensureCurrentRoutingGeneration(generation, 'comment:reaction'); + + const sender = ctx.authorOpenId ? await resolveSender(larkAppId, ctx.authorOpenId, 'user') : undefined; + ensureCurrentRoutingGeneration(generation, 'comment:sender'); + const authorName = sender?.name || ctx.authorOpenId?.slice(0, 8) || '?'; + const dsBotCfg = getBot(ds.larkAppId).config; + const promptInput = { + fileToken: sub.fileToken, + fileType: sub.fileType, + question: text, + author: authorName, + selectedText: ctx.selectedText, + priorReplies: ctx.priorReplies?.map(reply => ({ + author: reply.authorOpenId?.slice(0, 12), + text: reply.text, + })), + projectDir: ds.workingDir ?? sub.workingDir, + brand: normalizeBrand(dsBotCfg.brand), + locale: loc, + }; - const sender = ctx.authorOpenId ? await resolveSender(larkAppId, ctx.authorOpenId, 'user') : undefined; - const authorName = sender?.name || ctx.authorOpenId?.slice(0, 8) || '?'; - const dsBotCfg = getBot(ds.larkAppId).config; - const promptInput = { - fileToken: sub.fileToken, - fileType: sub.fileType, - question: text, - author: authorName, - selectedText: ctx.selectedText, - priorReplies: ctx.priorReplies?.map(reply => ({ - author: reply.authorOpenId?.slice(0, 12), - text: reply.text, - })), - projectDir: ds.workingDir ?? sub.workingDir, - brand: normalizeBrand(dsBotCfg.brand), - locale: loc, - }; + // 记录本轮回评论的落点。两条路都要覆盖: + // • ds.docCommentTurns(内存,按 turnId)→ deliverFinalOutput「兜底」分流用 + // • session.docCommentTargets(磁盘,per-turn map)→ `botmux send`「主回复」分流用 + // (botmux send 跑在独立子进程,只能从磁盘读会话态;per-turn 避免并发评论串线) + (ds.docCommentTurns ??= new Map()).set(turnId, { + fileToken: sub.fileToken, + fileType: sub.fileType, + commentId, + replyToOpenId: ctx.authorOpenId, + replyToName: sender?.name, + replyId: userReplyId, + reactionId, + }); + const docTarget = { fileToken: sub.fileToken, fileType: sub.fileType, commentId, replyToName: sender?.name, replyToOpenId: ctx.authorOpenId, turnId, replyId: userReplyId, reactionId }; - // 记录本轮回评论的落点。两条路都要覆盖: - // • ds.docCommentTurns(内存,按 turnId)→ deliverFinalOutput「兜底」分流用 - // • session.docCommentTargets(磁盘,per-turn map)→ `botmux send`「主回复」分流用 - // (botmux send 跑在独立子进程,只能从磁盘读会话态;per-turn 避免并发评论串线) - (ds.docCommentTurns ??= new Map()).set(turnId, { - fileToken: sub.fileToken, - fileType: sub.fileType, - commentId, - replyToOpenId: ctx.authorOpenId, - replyToName: sender?.name, - replyId: userReplyId, - reactionId, - }); - const docTarget = { fileToken: sub.fileToken, fileType: sub.fileType, commentId, replyToName: sender?.name, replyToOpenId: ctx.authorOpenId, turnId, replyId: userReplyId, reactionId }; + const selfBot = getBot(ds.larkAppId); - const selfBot = getBot(ds.larkAppId); + if (ds.worker && !ds.worker.killed) { + const targetWorker = ds.worker; + const isBridge = !!ds.adoptedFrom; + if (!isBridge) ensureSessionWhiteboard(ds); + const { promptContent, cliInput } = buildDocCommentTurnInput({ + ds, + promptInput, + botCliId: dsBotCfg.cliId, + botCliPathOverride: dsBotCfg.cliPathOverride, + botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId }, + sender, + mode: 'live', + }); + beginNewTurn(ds, text); + (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮 + // rememberLastCliInput persists both the exact comment target and the + // structured sidecar before any worker-visible delivery can occur. + rememberLastCliInput(ds, promptContent, cliInput); + await noteTurnReceived(ds, commentId, text, sender, turnId); + ensureCurrentRoutingGeneration(generation, 'comment:live-note'); + if (ds.worker !== targetWorker || targetWorker.killed) { + throw new Error('worker generation changed during comment:live-note'); + } + if (!sendWorkerInput(ds, cliInput, turnId)) { + throw new Error('worker became unavailable during comment:live-send'); + } + logger.info(`[${tag(ds)}] doc-comment turn injected (turn ${turnId.slice(0, 8)})`); + return; + } - if (ds.worker && !ds.worker.killed) { - const isBridge = !!ds.adoptedFrom; - if (!isBridge) ensureSessionWhiteboard(ds); - const { promptContent, cliInput } = buildDocCommentTurnInput({ - ds, - promptInput, - botCliId: dsBotCfg.cliId, - botCliPathOverride: dsBotCfg.cliPathOverride, - botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId }, - sender, - mode: 'live', - }); - beginNewTurn(ds, text); - (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮 - sessionStore.updateSession(ds.session); // 先落盘,botmux send 子进程才读得到落点 - await noteTurnReceived(ds, commentId, text, sender, turnId); - rememberLastCliInput(ds, promptContent, cliInput); - sendWorkerInput(ds, cliInput, turnId); - logger.info(`[${tag(ds)}] doc-comment turn injected (turn ${turnId.slice(0, 8)})`); - } else { - // Worker 挂起 / 已退出 —— resume 重 fork(与 handleThreadReply 同路)。 - logger.info(`[${tag(ds)}] Worker not running for doc-comment, re-forking...`); - if (ds.usageLimitRetryTimer) { clearTimeout(ds.usageLimitRetryTimer); ds.usageLimitRetryTimer = undefined; } - ds.usageLimit = undefined; - ds.currentTurnTitle = text.substring(0, 50); - parkStreamCard(ds); - ds.streamCardId = undefined; - ds.streamCardNonce = undefined; - ds.streamCardPending = true; - ds.currentImageKey = undefined; - persistStreamCardState(ds); - // Skip whiteboard ensure for adopted (bridge) sessions on re-fork — mirrors - // the live-worker branch above (if (!isBridge) ensure…). - if (!ds.adoptedFrom) ensureSessionWhiteboard(ds); - const { promptContent, cliInput: wrappedInput } = buildDocCommentTurnInput({ - ds, - promptInput, - botCliId: dsBotCfg.cliId, - botCliPathOverride: dsBotCfg.cliPathOverride, - botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId }, - sender, - mode: 'refork', - }); - (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮 - await noteTurnReceived(ds, commentId, text, sender, turnId); - rememberLastCliInput(ds, promptContent, wrappedInput); - sessionStore.updateSession(ds.session); - // Same adopt re-fork routing as handleThreadReply: an adopt session whose - // bridge worker exited must come back through forkAdoptWorker (observe + - // bridge), never forkWorker. buildDocCommentTurnInput(mode:'refork') - // delegates to buildReforkCliInput, which already bridge-formats the content - // when ds.adoptedFrom is set, so the doc-comment turn reaches the observed - // pane without a <user_message> wrapper. - if (ds.adoptedFrom) { - forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId }); - } else { - forkWorker(ds, wrappedInput, { resume: ds.hasHistory, turnId }); - } - } - return true; + // Worker 挂起 / 已退出 —— resume 重 fork(与 handleThreadReply 同路)。 + logger.info(`[${tag(ds)}] Worker not running for doc-comment, re-forking...`); + if (ds.usageLimitRetryTimer) { clearTimeout(ds.usageLimitRetryTimer); ds.usageLimitRetryTimer = undefined; } + ds.usageLimit = undefined; + ds.currentTurnTitle = text.substring(0, 50); + parkStreamCard(ds); + ds.streamCardId = undefined; + ds.streamCardNonce = undefined; + ds.streamCardPending = true; + ds.currentImageKey = undefined; + persistStreamCardState(ds); + // Skip whiteboard ensure for adopted (bridge) sessions on re-fork — mirrors + // the live-worker branch above (if (!isBridge) ensure…). + if (!ds.adoptedFrom) ensureSessionWhiteboard(ds); + const { promptContent, cliInput: wrappedInput } = buildDocCommentTurnInput({ + ds, + promptInput, + botCliId: dsBotCfg.cliId, + botCliPathOverride: dsBotCfg.cliPathOverride, + botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId }, + sender, + mode: 'refork', + }); + (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮 + rememberLastCliInput(ds, promptContent, wrappedInput); + await noteTurnReceived(ds, commentId, text, sender, turnId); + ensureCurrentRoutingGeneration(generation, 'comment:refork-note'); + if (ds.worker && !ds.worker.killed) { + throw new Error('worker became active during comment:refork-note'); + } + sessionStore.updateSession(ds.session); + if (ds.adoptedFrom) { + forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId }); + } else { + forkWorker(ds, wrappedInput, { resume: ds.hasHistory, turnId }); + } + }, cleanupFailedDelivery); } catch (err) { - // 投递失败:清理 claim 允许重试,返回 false 让 poller 不推进游标。 - handledDocCommentTurns.delete(claimKey); logger.warn(`[doc-comment] delivery failed, claim released for retry file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)} err=${err instanceof Error ? err.message : String(err)}`); return false; } } -/** 同一条评论可能同时被长连接通知和 --all 轮询看到;daemon 内统一去重。 */ -const handledDocCommentTurns = new BoundedMap<string, number>(5_000); - let docCommentPollRunning = false; /** @@ -17143,7 +17603,7 @@ async function restoreDocSubscriptions(_sessions: Map<string, DaemonSession>): P // • 有 sessionId 且其会话 status==='closed' → 真的关了 → 退订 + 删表 // • 会话不存在(被清理)→ 同上 // • 会话 active / 缺 sessionId(老订阅,无从判定)→ 保留 + 重订阅(保守,不误删) - const stored = sub.sessionId ? sessionStore.getSession(sub.sessionId) : undefined; + const stored = sub.sessionId ? sessionStore.getOwnedSession(sub.sessionId) : undefined; const definitelyClosed = sub.sessionId && (!stored || stored.status === 'closed'); if (definitelyClosed) { if (sub.managedBy !== 'watch-comment') await unsubscribeDocFile(appId, file); @@ -18308,7 +18768,7 @@ export async function startDaemon(botIndex?: number): Promise<void> { // detach-preserve a "herdr" session whose real pane is tmux (freeze-once). // undefined (frozen pty, or unresolvable legacy) → non-persistent → killWorker. if (shutdownBackendDisposition(ds) === 'detach') { - // Persistent backends (tmux / herdr / zellij): just kill the worker process — + // Persistent backends (tmux / herdr / zellij / zmx): just kill the worker process — // the multiplexer session survives for re-attach. The worker's SIGTERM // handler calls backend.kill(), which only DETACHES. Going through // killWorker() instead would send {type:'close'} → destroySession() → diff --git a/src/dashboard.ts b/src/dashboard.ts index b612de4fd..dbe4e5648 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -4508,7 +4508,7 @@ const server = createServer(async (req, res) => { } // PUT /api/bots/:appId/backend-type — proxy to that bot's daemon. Body - // `{ backendType: 'pty'|'tmux'|'herdr'|'zellij'|'' }` ('' / 'auto' clears the override). + // `{ backendType: 'pty'|'tmux'|'herdr'|'zellij'|'zmx'|'' }` ('' / 'auto' clears the override). let mBotBackendType: RegExpMatchArray | null; if (req.method === 'PUT' && (mBotBackendType = url.pathname.match(/^\/api\/bots\/([^/]+)\/backend-type$/))) { const appId = decodeURIComponent(mBotBackendType[1]); diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 767a9ddd1..232ee69d0 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -1670,6 +1670,7 @@ const BACKEND_TYPE_OPTIONS: Array<{ value: string; labelKey: string }> = [ { value: 'tmux', labelKey: 'botDefaults.backendTmux' }, { value: 'herdr', labelKey: 'botDefaults.backendHerdr' }, { value: 'zellij', labelKey: 'botDefaults.backendZellij' }, + { value: 'zmx', labelKey: 'botDefaults.backendZmx' }, { value: 'pty', labelKey: 'botDefaults.backendPty' }, ]; diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 3c0c45d09..cd35c6e94 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1240,9 +1240,9 @@ const zh: DashboardMessages = { 'settings.openTerminalInFeishu': '流式卡片 Web 终端按钮在飞书侧栏打开', 'settings.openTerminalInFeishuHelp': '默认关闭。将 Web Terminal 链接包装为飞书侧栏打开;写权限仍由终端 token 控制。', 'settings.enableLocalCliOpen': '启用本机 CLI 直开', - 'settings.enableLocalCliOpenHelp': '默认关闭,仅 daemon 运行在 macOS 时生效。附加模式支持当前 tmux / Herdr 会话,尽量保持同一路 I/O/历史和飞书连续性;resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', + 'settings.enableLocalCliOpenHelp': '默认关闭,仅 daemon 运行在 macOS 时生效。附加模式支持当前 tmux / Herdr / ZMX 会话,尽量保持同一路 I/O/历史和飞书连续性;resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', 'settings.localCliOpenMode': '本机 CLI 打开模式', - 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr 走精确 attach,adopt 会话只在已有可靠目标时附加;direct resume 适合明确接受飞书连续性风险的本机调试。', + 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr / ZMX 走精确 attach,adopt 会话只在已有可靠目标时附加;direct resume 适合明确接受飞书连续性风险的本机调试。', 'settings.localCliOpenModeAttach': '附加当前会话(推荐)', 'settings.localCliOpenModeResume': '另起 CLI resume', 'settings.sectionExperimental': '实验性配置', @@ -1743,11 +1743,12 @@ const zh: DashboardMessages = { 'botDefaults.readIsolationStaleOn': '(已开启,但当前环境无法强制执行——会话会 fail-close 拒启动,建议关闭以恢复)', 'botDefaults.sectionBackend': '会话后端', 'botDefaults.backendLabel': '后端类型', - 'botDefaults.backendHelp': '该 bot 新会话使用的会话后端;改动实时生效于新会话,运行中的会话仍用其启动时的后端、不受影响。tmux 支持 adopt / Web 终端;herdr 托管持久会话;zellij 实验性;pty 最轻量但不跨 daemon 重启存活。“自动”跟随全局默认。', + 'botDefaults.backendHelp': '该 bot 新会话使用的会话后端;改动实时生效于新会话,运行中的会话仍用其启动时的后端、不受影响。tmux 支持 adopt / Web 终端;herdr / zmx 托管持久会话;zellij 实验性;pty 最轻量但不跨 daemon 重启存活。“自动”跟随全局默认。', 'botDefaults.backendAuto': '自动(跟随全局默认)', 'botDefaults.backendTmux': 'tmux', 'botDefaults.backendHerdr': 'herdr', 'botDefaults.backendZellij': 'zellij', + 'botDefaults.backendZmx': 'zmx', 'botDefaults.backendPty': 'pty', 'botDefaults.backendSaved': '已保存(下个新会话生效)', 'botDefaults.brandSave': '保存签名', @@ -3238,9 +3239,9 @@ const en: DashboardMessages = { 'settings.openTerminalInFeishu': 'Open streaming-card Web terminals in the Feishu sidebar', 'settings.openTerminalInFeishuHelp': 'Off by default. Wraps Web Terminal links so Feishu opens them in the sidebar. Write access is still controlled by the terminal token.', 'settings.enableLocalCliOpen': 'Enable native CLI opening', - 'settings.enableLocalCliOpenHelp': 'Off by default and effective only when the daemon runs on macOS. Attach mode supports current tmux / Herdr sessions and keeps the same I/O/history when possible; resume mode starts a separate CLI resume process and may stop Feishu from following the session.', + 'settings.enableLocalCliOpenHelp': 'Off by default and effective only when the daemon runs on macOS. Attach mode supports current tmux / Herdr / ZMX sessions and keeps the same I/O/history when possible; resume mode starts a separate CLI resume process and may stop Feishu from following the session.', 'settings.localCliOpenMode': 'Native CLI open mode', - 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr use exact attach, adopted sessions attach only when a reliable existing target is known. Direct resume is for local debugging when you accept the Feishu continuity risk.', + 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr / ZMX use exact attach, adopted sessions attach only when a reliable existing target is known. Direct resume is for local debugging when you accept the Feishu continuity risk.', 'settings.localCliOpenModeAttach': 'Attach current session (recommended)', 'settings.localCliOpenModeResume': 'Start CLI resume', 'settings.sectionExperimental': 'Experimental', @@ -3741,11 +3742,12 @@ const en: DashboardMessages = { 'botDefaults.readIsolationStaleOn': "(enabled, but it can't be enforced here — sessions will fail-close; turn it off to recover)", 'botDefaults.sectionBackend': 'Session backend', 'botDefaults.backendLabel': 'Backend type', - 'botDefaults.backendHelp': 'The session backend for this bot\'s new sessions. Changes apply live to new sessions; running sessions keep the backend they started on and are unaffected. tmux supports adopt / Web Terminal; herdr manages persistent sessions; zellij is experimental; pty is lightest but does not survive daemon restarts. "Auto" follows the global default.', + 'botDefaults.backendHelp': 'The session backend for this bot\'s new sessions. Changes apply live to new sessions; running sessions keep the backend they started on and are unaffected. tmux supports adopt / Web Terminal; herdr / zmx manage persistent sessions; zellij is experimental; pty is lightest but does not survive daemon restarts. "Auto" follows the global default.', 'botDefaults.backendAuto': 'Auto (follow global default)', 'botDefaults.backendTmux': 'tmux', 'botDefaults.backendHerdr': 'herdr', 'botDefaults.backendZellij': 'zellij', + 'botDefaults.backendZmx': 'zmx', 'botDefaults.backendPty': 'pty', 'botDefaults.backendSaved': 'Saved (applies to the next new session)', 'botDefaults.brandSave': 'Save Signature', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0d4ef1b61..1776e1961 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -678,6 +678,7 @@ export const messages: Record<string, string> = { 'card.action.resume_anchor_holder': ' (holder: {short})', 'card.action.resume_adopt_unsupported': '⚠️ Adopted sessions cannot be resumed.', 'card.action.resume_deferred_unmaterialized': '⚠️ This silent scheduled run never created a topic. Its hidden session is audit-only and cannot be resumed.', + 'card.action.resume_cancelled': '⚠️ The session was closed while resume was committing; resume was cancelled.', 'card.action.disconnected': '⏏ Disconnected. The original CLI is untouched.', 'card.voice.toast_wait': '🔊 Generating a voice summary, hang tight…', 'card.voice.toast_already': '🔊 A voice summary for this reply is already on the way.', @@ -1021,9 +1022,9 @@ export const messages: Record<string, string> = { 'settings.openTerminalInFeishu': 'Open Web Terminal in Feishu', 'settings.openTerminalInFeishuHelp': 'Terminal links open in the Feishu in-app webview by default.', 'settings.enableLocalCliOpen': 'Enable native CLI opening', - 'settings.enableLocalCliOpenHelp': 'Off by default and available only when the daemon runs on macOS. Attach mode supports current tmux / Herdr sessions and keeps the same I/O/history when possible; resume mode starts a separate CLI resume process and may stop Feishu from following the session.', + 'settings.enableLocalCliOpenHelp': 'Off by default and available only when the daemon runs on macOS. Attach mode supports current tmux / Herdr / ZMX sessions and keeps the same I/O/history when possible; resume mode starts a separate CLI resume process and may stop Feishu from following the session.', 'settings.localCliOpenMode': 'Native CLI open mode', - 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr use exact attach, adopted sessions attach only when a reliable existing target is known. Direct resume is for local debugging when you accept the Feishu continuity risk.', + 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr / ZMX use exact attach, adopted sessions attach only when a reliable existing target is known. Direct resume is for local debugging when you accept the Feishu continuity risk.', 'settings.localCliOpenModeAttach': 'Attach current session (recommended)', 'settings.localCliOpenModeResume': 'Start CLI resume', 'settings.autoUpdate': 'Daily auto-update', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ec2739add..714d4d085 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -681,6 +681,7 @@ export const messages: Record<string, string> = { 'card.action.resume_anchor_holder': '(占用者:{short})', 'card.action.resume_adopt_unsupported': '⚠️ adopt 接管会话不支持 resume。', 'card.action.resume_deferred_unmaterialized': '⚠️ 该静默定时轮次未创建话题,隐藏会话只保留审计记录,无法恢复。', + 'card.action.resume_cancelled': '⚠️ 恢复过程中会话被关闭,本次恢复已取消。', 'card.action.disconnected': '⏏ 已断开,原 CLI 会话不受影响', 'card.voice.toast_wait': '🔊 正在生成语音总结,请耐心等待…', 'card.voice.toast_already': '🔊 这条已经在生成语音啦,请稍候', @@ -1024,9 +1025,9 @@ export const messages: Record<string, string> = { 'settings.openTerminalInFeishu': '飞书内打开 Web 终端', 'settings.openTerminalInFeishuHelp': '终端链接默认在飞书 webview 打开。', 'settings.enableLocalCliOpen': '启用本机 CLI 直开', - 'settings.enableLocalCliOpenHelp': '默认关闭,仅 macOS daemon 可用。附加模式支持当前 tmux / Herdr 会话,尽量保持同一路 I/O/历史和飞书连续性;resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', + 'settings.enableLocalCliOpenHelp': '默认关闭,仅 macOS daemon 可用。附加模式支持当前 tmux / Herdr / ZMX 会话,尽量保持同一路 I/O/历史和飞书连续性;resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', 'settings.localCliOpenMode': '本机 CLI 打开模式', - 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr 走精确 attach,adopt 会话只在已有可靠目标时附加;direct resume 适合明确接受飞书连续性风险的本机调试。', + 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr / ZMX 走精确 attach,adopt 会话只在已有可靠目标时附加;direct resume 适合明确接受飞书连续性风险的本机调试。', 'settings.localCliOpenModeAttach': '附加当前会话(推荐)', 'settings.localCliOpenModeResume': '另起 CLI resume', 'settings.autoUpdate': '每日自动更新', diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index c37170dd3..2ce7cb1cb 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -603,6 +603,7 @@ export async function commitRepoSelection( // the displaced session's stored workingDir (and the closed card), so // `claude --resume` later would reopen the old context in the new repo's // cwd. The new repo is pinned onto the fresh session below instead. + const oldSession = ds.session; const closedCard = buildClosedSessionCard(ds, locTarget); killWorker(ds); @@ -623,7 +624,13 @@ export async function commitRepoSelection( () => sessionReply(rootId, closedCard, 'interactive'), ); - const oldSession = ds.session; + // The close card delivery yields to inbound routing and dashboard IPC. If + // the user closed/replaced this generation during that await, do not mint a + // fresh active row from the stale continuation. + if (!sessionStillActive() || ds.session !== oldSession) { + logger.warn(`[${tag(ds)}] Repo switch cancelled after old-session close; routing generation changed during card delivery`); + return; + } // `rootId` is the routing anchor. For chat-scope sessions it is the // `oc_...` chat id, not the traceable `om_...` message root stored on // Session. Preserve the old identity and explicitly persist scope so card @@ -1401,23 +1408,21 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe // collectRelayPickerEntries already filters scratches at render time, // but a stale picker (rendered before a scratch was created) could // still produce a confirm click; this is the depth defense. - { - const { isRelayableRealSession } = await import('../../core/worker-pool.js'); - if (!isRelayableRealSession(sourceDs)) { - return { toast: { type: 'error', content: t('card.relay.toast_not_started_yet', undefined, loc) } }; - } + const { isDisposableCommandScratch, isRelayableRealSession } = await import('../../core/worker-pool.js'); + if (!isRelayableRealSession(sourceDs)) { + return { toast: { type: 'error', content: t('card.relay.toast_not_started_yet', undefined, loc) } }; } // Pre-flight target-chat conflict check — done BEFORE sendMessage M1 so // a refusal doesn't leave a misleading "已接力" announcement in the // target chat (王皓 caught this in testing). Mirror the same predicate - // transferSession uses, plus the `!!worker` filter that excludes daemon - // command scratch sessions (e.g. the /relay command's own session, - // which shares the bot's larkAppId + chatId but has no worker). + // transferSession uses. Only a narrowly classified daemon-command scratch + // may be ignored; worker-less queued/adopt/deferred/real sessions still own + // the anchor and must block before a misleading M1 is sent. const targetConflict = [...activeSessions.values()].find(c => c !== sourceDs && c.larkAppId === larkAppId && sessionAnchorId(c) === targetAnchor - && !!c.worker + && !isDisposableCommandScratch(c) ); if (targetConflict) { const conflictTitle = targetConflict.session.title || targetConflict.session.sessionId.substring(0, 8); @@ -1853,6 +1858,8 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe await sessionReply(rootId, t('card.action.resume_adopt_unsupported', undefined, locDsResume)); } else if (result.error === 'deferred_unmaterialized') { await sessionReply(rootId, t('card.action.resume_deferred_unmaterialized', undefined, locDsResume)); + } else if (result.error === 'resume_cancelled') { + await sessionReply(rootId, t('card.action.resume_cancelled', undefined, locDsResume)); } } } diff --git a/src/services/backend-availability.ts b/src/services/backend-availability.ts index 3ff28bb1f..5eda9e2bf 100644 --- a/src/services/backend-availability.ts +++ b/src/services/backend-availability.ts @@ -3,6 +3,7 @@ import { detectPlatform } from '../setup/detect-platform.js'; import { ensureHerdr, type HerdrResult } from '../setup/ensure-herdr.js'; import { ensureTmux, type TmuxResult } from '../setup/ensure-tmux.js'; import { probeZellijFunctional } from '../setup/ensure-zellij.js'; +import { probeZmxFunctional } from '../setup/ensure-zmx.js'; export type BackendAvailabilityResult = | { ok: true; backendType: BackendType; version?: string } @@ -12,19 +13,21 @@ export interface BackendAvailabilityDeps { ensureTmux(): Promise<TmuxResult>; ensureHerdr(): Promise<HerdrResult>; probeZellijFunctional(): ReturnType<typeof probeZellijFunctional>; + probeZmxFunctional(): ReturnType<typeof probeZmxFunctional>; } const defaultDeps: BackendAvailabilityDeps = { ensureTmux: () => ensureTmux(detectPlatform()), ensureHerdr, probeZellijFunctional, + probeZmxFunctional, }; /** * Prepare a backend before persisting a dashboard override. This keeps the UI * promise honest: a successful save means the next session can actually start * on that backend. tmux/herdr may install through their existing official - * bootstrap paths; zellij is probe-only and returns an actionable failure. + * bootstrap paths; zellij/zmx are probe-only and return actionable failures. */ export async function ensureBackendAvailable( backendType: BackendType, @@ -47,6 +50,18 @@ export async function ensureBackendAvailable( : { ok: false, backendType, reason: result.reason ?? 'herdr 不可用', manualCommand: result.manualCommand }; } + if (backendType === 'zmx') { + const result = deps.probeZmxFunctional(); + return result.ok + ? { ok: true, backendType, version: result.version } + : { + ok: false, + backendType, + reason: result.reason, + manualCommand: 'macOS: brew install neurosnap/tap/zmx;Linux: 安装 ZMX 官方 release binary(>= 0.6.0)', + }; + } + const result = deps.probeZellijFunctional(); return result.ok ? { ok: true, backendType, version: result.version } diff --git a/src/services/backend-type-store.ts b/src/services/backend-type-store.ts index 7129f29c1..f4a04b326 100644 --- a/src/services/backend-type-store.ts +++ b/src/services/backend-type-store.ts @@ -19,7 +19,7 @@ import { getBot } from '../bot-registry.js'; import { logger } from '../utils/logger.js'; /** Backends an operator may pick from in the dashboard. */ -export const EDITABLE_BACKEND_TYPES: readonly BackendType[] = ['pty', 'tmux', 'herdr', 'zellij']; +export const EDITABLE_BACKEND_TYPES: readonly BackendType[] = ['pty', 'tmux', 'herdr', 'zellij', 'zmx']; export function isEditableBackendType(v: unknown): v is BackendType { return typeof v === 'string' && (EDITABLE_BACKEND_TYPES as readonly string[]).includes(v); diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index e59d4be84..be51dac79 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -88,7 +88,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'canTalkDaemonCommands', configKey: 'canTalkDaemonCommands', kind: 'stringList', effect: 'immediate', clearable: true, parseList: parseCanTalkDaemonCommandsInput, hint: '把列出的 daemon 命令权限从 canOperate(仅管理员)降到 canTalk(对话放行即可用),如 /status /help;仅认 daemon 命令,透传命令无效;unset 回全部仅管理员' }, { key: 'startupCommands', configKey: 'startupCommands', kind: 'stringList', effect: 'next-session', clearable: true, parseList: parseStartupCommandsInput, hint: '开会话后、首条消息前自动发给 CLI 的命令(逗号/换行分隔,可带参数,如 /effort ultracode);unset 回不发' }, { key: 'env', configKey: 'env', kind: 'json', effect: 'next-session', clearable: true, hint: 'per-bot 环境变量 JSON(如 {"ANTHROPIC_BASE_URL":"…","ANTHROPIC_AUTH_TOKEN":"…"} 让本 bot 走 GLM/第三方服务商,或设 HTTPS_PROXY);注入到本 bot 的 CLI 进程,下个会话生效;值不显示(脱敏);unset 清除' }, - { key: 'backendType', configKey: 'backendType', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['pty', 'tmux', 'herdr', 'zellij', 'riff'], hint: '会话后端类型:pty=本地 PTY 子进程(默认)|tmux=tmux 会话|herdr=herdr 终端复用|zellij=zellij 多路复用|riff=远程 riff agent 服务;选 riff 时需配置 riff 字段;unset 回 pty' }, + { key: 'backendType', configKey: 'backendType', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['pty', 'tmux', 'herdr', 'zellij', 'zmx', 'riff'], hint: '会话后端类型:pty=本地 PTY 子进程(默认)|tmux=tmux 会话|herdr=herdr 终端复用|zellij=zellij 多路复用|zmx=ZMX 持久会话|riff=远程 riff agent 服务;选 riff 时需配置 riff 字段;unset 回 pty' }, { key: 'riff', configKey: 'riff', kind: 'json', effect: 'next-session', clearable: true, hint: 'riff 后端配置 JSON(baseUrl/agent/model/jwt 等),仅 backendType=riff 时生效;unset 清除' }, ]; diff --git a/src/services/local-cli-opener.ts b/src/services/local-cli-opener.ts index 5419f6e6a..95a3c211e 100644 --- a/src/services/local-cli-opener.ts +++ b/src/services/local-cli-opener.ts @@ -6,7 +6,7 @@ import type { CliAdapter, CliId } from '../adapters/cli/types.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { localTerminalCapable } from '../core/local-terminal-opener.js'; import type { DaemonSession } from '../core/types.js'; -import { getSessionPersistentBackendType, persistentBackendTargetForSession } from '../core/persistent-backend.js'; +import { getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName } from '../core/persistent-backend.js'; import { readGlobalConfig, type LocalCliOpenMode } from '../global-config.js'; export const LOCAL_CLI_IDS = [ @@ -216,6 +216,24 @@ function buildManagedAttachCommand(ds: DaemonSession): LocalCliOpenResult { } return { ok: true, command: `herdr session attach ${shellQuote(target!.sessionName)}` }; } + if (backendType === 'zmx') { + const name = persistentSessionName('zmx', ds.session.sessionId); + const socketEnv = ['ZMX_DIR', 'XDG_RUNTIME_DIR', 'TMPDIR'] + .flatMap((key) => process.env[key] ? [`export ${key}=${shellQuote(process.env[key]!)}`] : []); + const prelude = [ + 'unset ZMX_SESSION ZMX_SESSION_PREFIX', + ...socketEnv, + ].join('; '); + const quotedName = shellQuote(name); + return { + ok: true, + command: + `${prelude}; ` + + `if zmx list --short 2>/dev/null | grep -Fqx -- ${quotedName}; then ` + + `exec zmx attach ${quotedName} /bin/sh -c 'exit 75'; ` + + `else printf '%s\\n' 'ZMX session ${name} is no longer available.' >&2; exit 1; fi`, + }; + } return fail('unsupported_backend', 'This session backend does not provide a safe local attach command.'); } diff --git a/src/services/session-store.ts b/src/services/session-store.ts index e70c649b8..5e5e30c27 100644 --- a/src/services/session-store.ts +++ b/src/services/session-store.ts @@ -190,6 +190,16 @@ export function getSession(sessionId: string): Session | undefined { return sessions.get(sessionId) ?? findInOtherFiles(sessionId); } +/** + * Return a row only when it belongs to this process's currently-initialised + * bot store. Mutating daemon endpoints must use this instead of getSession(), + * whose cross-file fallback is intentionally read-only discovery. + */ +export function getOwnedSession(sessionId: string): Session | undefined { + load(); + return sessions.get(sessionId); +} + /** * Search all session files for a session not found in the current file. * diff --git a/src/setup/bot-config-editor.ts b/src/setup/bot-config-editor.ts index e579c886e..be9333dc2 100644 --- a/src/setup/bot-config-editor.ts +++ b/src/setup/bot-config-editor.ts @@ -461,8 +461,8 @@ export function applyBotConfigEdits<T extends Record<string, any>>( if (backendType === '-') { delete out.backendType; } else if (backendType) { - if (backendType !== 'pty' && backendType !== 'tmux' && backendType !== 'herdr' && backendType !== 'zellij' && backendType !== 'riff') { - throw new Error(`backendType must be "pty", "tmux", "herdr", "zellij", or "riff": ${backendType}`); + if (backendType !== 'pty' && backendType !== 'tmux' && backendType !== 'herdr' && backendType !== 'zellij' && backendType !== 'zmx' && backendType !== 'riff') { + throw new Error(`backendType must be "pty", "tmux", "herdr", "zellij", "zmx", or "riff": ${backendType}`); } out.backendType = backendType; } diff --git a/src/setup/ensure-zmx.ts b/src/setup/ensure-zmx.ts new file mode 100644 index 000000000..9f6cc6ea7 --- /dev/null +++ b/src/setup/ensure-zmx.ts @@ -0,0 +1,103 @@ +/** + * zmx availability probe + env hygiene. + * + * zmx is an OPT-IN backend (BACKEND_TYPE=zmx). botmux does not auto-install it + * during daemon bootstrap; the worker probes it at spawn time and hard-gates + * the session if the binary/socket surface is not usable. + */ +import { execFileSync } from 'node:child_process'; +import { homedir } from 'node:os'; + +const ZMX_PATH_EXTRAS = [ + `${homedir()}/.local/share/mise/shims`, + `${homedir()}/.local/bin`, + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + '/usr/local/sbin', + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', +]; + +function withZmxSearchPath(pathValue: string | undefined): string { + const seen = new Set<string>(); + const merged: string[] = []; + for (const part of [...(pathValue?.split(':') ?? []), ...ZMX_PATH_EXTRAS]) { + if (!part || seen.has(part)) continue; + seen.add(part); + merged.push(part); + } + return merged.join(':'); +} + +/** + * Strip zmx session identity vars inherited from a parent zmx attach. + * + * ZMX_DIR is intentionally preserved: it is the user's explicit socket-dir + * selection. ZMX_SESSION_PREFIX is stripped so botmux's deterministic bmx-* + * names stay literal and dashboard/API probes can match them. + */ +export function zmxEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const { ZMX_SESSION: _session, ZMX_SESSION_PREFIX: _prefix, ...rest } = env; + return { + ...rest, + PATH: withZmxSearchPath(rest.PATH), + }; +} + +export function probeZmxFunctional(): { ok: true; version: string } | { ok: false; reason: string } { + let version: string; + try { + version = execFileSync('zmx', ['version'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 3000, + env: zmxEnv(), + }).trim(); + } catch { + return { ok: false, reason: 'zmx 二进制不在 PATH 上' }; + } + + const parsedVersion = parseZmxVersion(version); + if (!parsedVersion) { + return { ok: false, reason: `无法解析 zmx 版本:${version.split('\n')[0] || '(empty)'}` }; + } + if (compareVersion(parsedVersion, [0, 6, 0]) < 0) { + return { + ok: false, + reason: `zmx >= 0.6.0 才受支持(当前 ${parsedVersion.join('.')})`, + }; + } + + try { + execFileSync('zmx', ['list'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env: zmxEnv(), + }); + } catch (err: any) { + const stderr = err?.stderr?.toString?.().trim?.() || ''; + return { ok: false, reason: stderr || 'zmx list 失败' }; + } + + return { ok: true, version }; +} + +export function parseZmxVersion(output: string): [number, number, number] | null { + const match = output.match(/(?:^|\n)zmx\s+(\d+)\.(\d+)\.(\d+)(?:\s|$)/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareVersion( + left: [number, number, number], + right: [number, number, number], +): number { + for (let i = 0; i < 3; i++) { + if (left[i] !== right[i]) return left[i]! - right[i]!; + } + return 0; +} diff --git a/src/setup/index.ts b/src/setup/index.ts index b11ff3b22..aee122663 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -39,7 +39,7 @@ export { botmuxFontDir } from './ensure-fonts.js'; * is deterministic and implies there is no tmux server — hence no surviving * session to protect — so refusing to start loses nothing. * - at least one bot actually wants the tmux backend (`anyBotWantsTmux`). A - * box whose bots all run pty/herdr/zellij doesn't need tmux at all. + * box whose bots all run pty/herdr/zellij/zmx doesn't need tmux at all. * - the operator hasn't opted into the PTY escape hatch (`BACKEND_TYPE=pty`). * * Crucially we do NOT hard-fail when the binary IS present but its functional diff --git a/src/setup/setup-args.ts b/src/setup/setup-args.ts index dbd09b7e4..3ca8da5f1 100644 --- a/src/setup/setup-args.ts +++ b/src/setup/setup-args.ts @@ -120,7 +120,7 @@ export const SETUP_CLI_USAGE = `botmux setup — 脚本化(非 TUI)用法 --cli-path <path> CLI 可执行文件路径覆盖 --wrapper-cli <prefix> 通用启动前缀(如 "aiden x claude"),覆盖 --cli 推导值 --model <m> CLI 模型名 - --backend <b> 会话后端 pty | tmux | herdr | zellij + --backend <b> 会话后端 pty | tmux | herdr | zellij | zmx traex + herdr 插件安装需在 Dashboard Settings 中显式开启并填写可信 source/ref --working-dir <dirs> 仓库选择卡片的扫描根目录(逗号分隔多个) --default-working-dir <d> 固定默认目录:新话题直接在此目录启动、不弹仓库 diff --git a/src/types.ts b/src/types.ts index ea8f739f3..53e02ac95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -162,6 +162,10 @@ export interface Session { nativeSessionTitleUserDefined?: boolean; /** 首轮只有机器人 mention 时,等待第一条有效正文生成原生标题。 */ nativeSessionTitleAwaitingContent?: boolean; + /** Last explicit title update. Undefined means the title is the legacy initial fallback. */ + titleUpdatedAt?: string; + /** Informational origin label for UI/debugging, not a trusted audit identity. */ + titleSource?: 'initial' | 'user' | 'agent' | 'cli' | 'dashboard' | 'system'; status: 'active' | 'closed'; /** Dashboard 看板视图的手动放置:列 id(backlog/todo/in_progress/in_review/done)。 * 未设置时前端按运行状态推导默认列;一旦用户拖拽过就以此为准。 */ @@ -340,7 +344,7 @@ export interface Session { cliSessionId?: string; /** * Set true when the idle-worker sweeper suspends this session over the per-bot - * live cap: the worker AND the backing tmux/herdr/zellij session (+ CLI) were + * live cap: the worker AND the backing tmux/herdr/zellij/zmx session (+ CLI) were * intentionally killed to reclaim memory, but the session stays `active` and * cold-resumes from its on-disk transcript on the next message. Distinguishes * this deliberate state from a real zombie (pane gone while the server runs): @@ -367,7 +371,7 @@ export interface Session { */ agentFrozen?: boolean; /** - * Session backend resolved AT SPAWN TIME (tmux/herdr/zellij/pty). Stamped on + * Session backend resolved AT SPAWN TIME (tmux/herdr/zellij/zmx/pty). Stamped on * fork so restore can resolve the backend authoritatively from the session * itself instead of re-deriving it from the live daemon default — which * changed when PTY stopped being an automatic fallback (default is now always diff --git a/src/utils/pending-input-queue.ts b/src/utils/pending-input-queue.ts index 574767381..daf5438f3 100644 --- a/src/utils/pending-input-queue.ts +++ b/src/utils/pending-input-queue.ts @@ -12,6 +12,24 @@ export interface PendingCliInput { codexAppInput?: CodexAppTurnInput; } +/** + * Run a synchronous CLI/backend reset without losing inputs that have not yet + * been dequeued for a PTY write. The reset path intentionally clears the live + * queue; restoring the snapshot afterwards keeps those messages distinct from + * InflightInputTracker carry-over, which spawnCli prepends ahead of them. + */ +export function resetPreservingPendingCliInputs( + pending: PendingCliInput[], + reset: () => void, +): void { + const queued = pending.splice(0); + try { + reset(); + } finally { + pending.unshift(...queued); + } +} + export function mergeQueuedCliInput( pending: PendingCliInput[], next: PendingCliInput, diff --git a/src/worker.ts b/src/worker.ts index 911909e8b..93b74bd3c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -191,9 +191,16 @@ import { TmuxBackend } from './adapters/backend/tmux-backend.js'; import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js'; import { ZellijBackend, ZELLIJ_CONFIG_KDL } from './adapters/backend/zellij-backend.js'; import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.js'; +import { ZmxBackend } from './adapters/backend/zmx-backend.js'; import { zellijEnv } from './setup/ensure-zellij.js'; import { isObserveBackend, type ObserveBackend } from './adapters/backend/types.js'; -import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js'; +import { + backendGateUserMessage, + backendSandboxCompatibilityError, + backendSandboxCompatibilityUserMessage, + decideBackendGate, + selectSessionBackend, +} from './adapters/backend/session-backend-selector.js'; import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js'; import { deriveRiffReposFromDirs, @@ -215,8 +222,9 @@ import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js'; -import type { BackendType, SessionBackend } from './adapters/backend/types.js'; +import type { BackendType, SessionBackend, SessionProbe } from './adapters/backend/types.js'; import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js'; +import { probeZmxFunctional } from './setup/ensure-zmx.js'; import { tmuxRestartJitterMs } from './core/tmux-recovery.js'; import { IdleDetector } from './utils/idle-detector.js'; import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js'; @@ -6536,16 +6544,19 @@ async function spawnCli( // backendType trust-but-verify + HARD GATE (PTY 退役): an explicit per-bot // config (or BACKEND_TYPE env override) bypasses config.ts's default, so the // worker re-probes the requested persistent backend here. A requested - // tmux/herdr/zellij backend that isn't functional NO LONGER silently + // tmux/herdr/zellij/zmx backend that isn't functional NO LONGER silently // degrades to raw PTY — that silent fallback was the root of the "secretly // running on PTY, then hitting all of PTY's problems" bug class. Instead we // refuse to spawn and post an actionable card (user_notify) telling the user // to install the backend, or to explicitly opt into PTY with BACKEND_TYPE=pty. // - // Existing botmux sessions stay authoritative over the disposable "can we - // create a new server?" probe: a live session reattaches regardless of a - // transient probe failure (PR#249), so it's exempt from the gate. + // Existing botmux sessions stay authoritative over a separate capability + // probe: a live session reattaches regardless of a transient probe failure + // (PR#249), so it is exempt from the gate. tmux/zellij use disposable + // sessions; ZMX validates its version plus full-list control plane; Herdr + // uses a non-destructive version check. let effectiveBackend = cfg.backendType; + let resolvedZmxSessionProbe: SessionProbe | undefined; { let available = true; let reason = ''; @@ -6566,6 +6577,19 @@ async function spawnCli( available = ZellijBackend.isAvailable(); reason = 'zellij 功能性探针失败(需 zellij >= 0.44)'; } + } else if (effectiveBackend === 'zmx') { + // The full list is tri-state: per-session `err=` rows and malformed + // output are inconclusive, never permission to create a duplicate CLI. + resolvedZmxSessionProbe = ZmxBackend.probeSession(ZmxBackend.sessionName(cfg.sessionId)); + hasExistingSession = resolvedZmxSessionProbe === 'exists'; + if (resolvedZmxSessionProbe === 'unknown') { + available = false; + reason = 'zmx 会话列表探针结果不确定'; + } else if (!hasExistingSession) { + const probe = probeZmxFunctional(); + available = probe.ok; + if (!probe.ok) reason = probe.reason; + } } else if (effectiveBackend === 'herdr') { // herdr's isAvailable() is a cheap, non-destructive `herdr --version` // (not a disposable session probe), so it has no PR#249 false-negative @@ -6585,7 +6609,6 @@ async function spawnCli( } } effectiveBackendType = effectiveBackend; - // For riff (remote HTTP backend), merge botmux session context env + per-bot // env into the riff backend config so the remote sandbox has everything the // agent needs (e.g. `botmux send` routing). The sandbox installs botmux via @@ -6750,6 +6773,11 @@ async function spawnCli( // safely provide. Fresh tasks use distinct agents in one machine-wide host. reuseRecordedHerdrTarget: !sandboxRequested && !hasMcpRuntimeEntries, + // ZMX reattach vs fresh is frozen here from the probe taken above; the + // backend refuses to silently turn a fresh launch into an attach. + hasExistingSession: effectiveBackend === 'zmx' + ? resolvedZmxSessionProbe === 'exists' + : undefined, }); let selectedBackend = selectBackend(); isTmuxMode = selectedBackend.isTmuxMode; @@ -6826,7 +6854,7 @@ async function spawnCli( } } // Predict reattach vs fresh BEFORE the resume pre-flight. On a persistent - // backend (tmux/herdr/zellij) a daemon restart finds the CLI process still + // backend (tmux/herdr/zellij/zmx) a daemon restart finds the CLI process still // alive in its pane, so the backend will `attach` to the live process and // IGNORE the bin/args — there is no spawn, and the live process still holds // the full in-memory conversation. In that case the resume-vs-fresh question @@ -6847,9 +6875,18 @@ async function spawnCli( // still the isolated process). This lets isolated bots use tmux/zellij/herdr. if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') { const persistentTarget = selectedBackend.persistentBackendTarget; - const paneLive = persistentTarget - ? probePersistentBackendTarget(persistentTarget) === 'exists' - : false; + // An unverifiable pane must never be treated as absent: falling through to + // "no pane" would cold-spawn a second CLI beside a live unisolated one. + const paneProbe = persistentTarget + ? probePersistentBackendTarget(persistentTarget) + : 'missing'; + if (paneProbe === 'unknown') { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not verify existing ${effectiveBackendType} pane`, + ); + } + const paneLive = paneProbe === 'exists'; if (paneLive) { let marker: string | null = null; marker = readRegularHostFileNoFollow( @@ -8014,7 +8051,7 @@ async function spawnCli( getChildPid: () => backend?.getChildPid?.(), applyRealPid: (realPid) => { log(`wrapperCli "${cfg.wrapperCli}": resolved real CLI pid ${realPid} under launcher ${launcherPid} (cliId=${targetCliId}); rewiring session discovery + bridge`); - (backend as TmuxBackend | PtyBackend | ZellijBackend).cliPid = realPid; + (backend as TmuxBackend | PtyBackend | ZellijBackend | ZmxBackend).cliPid = realPid; // Per-tick maybeFollowSessionRotationViaPid (bridge 1s poller) reads the // module-level bridgeCliPid and re-points to the real CLI's jsonl. bridgeCliPid = realPid; @@ -8035,8 +8072,8 @@ async function spawnCli( // claudeJsonlPath above is still the initial guess; the resolver corrects // it on first write when Claude was started with `--resume`. if (cliPid && (claudeDataDir || cfg.cliId === 'grok')) { - (backend as TmuxBackend | PtyBackend | ZellijBackend).cliPid = cliPid; - (backend as TmuxBackend | PtyBackend | ZellijBackend).cliCwd = cfg.workingDir; + (backend as TmuxBackend | PtyBackend | ZellijBackend | ZmxBackend).cliPid = cliPid; + (backend as TmuxBackend | PtyBackend | ZellijBackend | ZmxBackend).cliCwd = cfg.workingDir; } // Async pid fallback: tmux/pty resolve the CLI pid synchronously above, but @@ -8065,8 +8102,8 @@ async function spawnCli( } } if (claudeDataDir || cfg.cliId === 'grok') { - (backend as TmuxBackend | PtyBackend | ZellijBackend).cliPid = pid; - (backend as TmuxBackend | PtyBackend | ZellijBackend).cliCwd = cfg.workingDir; + (backend as TmuxBackend | PtyBackend | ZellijBackend | ZmxBackend).cliPid = pid; + (backend as TmuxBackend | PtyBackend | ZellijBackend | ZmxBackend).cliCwd = cfg.workingDir; } // wrapperCli under a late-pid backend (zellij): `pid` here is still the // LAUNCHER. Kick the descendant resolver so the bridge gets the real CLI @@ -8187,7 +8224,7 @@ async function spawnCli( // we hold the first prompt so a cjadk-style startup selector's ❯ can't eat it. // shouldArmReadyGate() excludes adopt (pre-existing pane, no fresh hook) AND // persistent-backend reattach (daemon restart re-attaches an already-running - // tmux/zellij/herdr Claude WITHOUT re-running its bin/args → no new + // tmux/zellij/herdr/zmx Claude WITHOUT re-running its bin/args → no new // SessionStart hook → arming would hold the first post-recovery message until // the timeout). // @@ -9196,6 +9233,38 @@ var term=new Terminal({ fontSize:14,fontFamily:"'JetBrains Mono','Fira Code',monospace", cursorBlink:!isTouch,scrollback:50000,allowProposedApi:true }); +// ZMX's attach transport has one headless terminal in the worker that answers +// DA/DSR/CPR/mode/status queries even when no browser exists. A normal xterm.js +// tab would answer the same queries again (N tabs => N extra replies), so for +// this backend only, consume reply-producing query families in every browser. +// They are zero-width control requests; returning true suppresses xterm's +// built-in response without changing rendered output. Other backends keep the +// default browser responder because they do not own one server-side. +var _serverTerminalResponder=${effectiveBackendType === 'zmx'}; +if(_serverTerminalResponder&&term.parser){ + var _queryParam0=function(p){ + var v=p&&p.length?p[0]:0; + return Array.isArray(v)?(v[0]||0):(v||0); + }; + term.parser.registerCsiHandler({final:'c'},function(p){return _queryParam0(p)<=0}); + term.parser.registerCsiHandler({prefix:'>',final:'c'},function(p){return _queryParam0(p)<=0}); + term.parser.registerCsiHandler({final:'n'},function(p){var v=_queryParam0(p);return v===5||v===6}); + term.parser.registerCsiHandler({prefix:'?',final:'n'},function(p){return _queryParam0(p)===6}); + term.parser.registerCsiHandler({intermediates:'$',final:'p'},function(){return true}); + term.parser.registerCsiHandler({prefix:'?',intermediates:'$',final:'p'},function(){return true}); + term.parser.registerCsiHandler({final:'t'},function(p){return _queryParam0(p)===18}); + term.parser.registerDcsHandler({intermediates:'$',final:'q'},function(){return true}); + var _oscColorQuery=function(ident,data){ + if(ident!==4)return data.split(';').indexOf('?')!==-1; + var parts=data.split(';'); + if(parts.length<2||parts.length%2!==0)return false; + for(var i=0;i<parts.length;i+=2){if(/^\d+$/.test(parts[i])&&parts[i+1]==='?')return true;} + return false; + }; + [4,10,11,12].forEach(function(ident){ + term.parser.registerOscHandler(ident,function(data){return _oscColorQuery(ident,data)}); + }); +} var fit=new FitAddon.FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon.WebLinksAddon()); @@ -10666,7 +10735,7 @@ process.on('message', async (raw: unknown) => { // cold-resumes a FRESH CLI on the next message — bmx-<sid> is absent.) destroyCrashDiagnosticTerminal('suspend'); // Free the CLI's memory, not just the worker's: destroySession kills the - // backing tmux/herdr/zellij session AND the CLI process inside it (kill() + // backing tmux/herdr/zellij/zmx session AND the CLI process inside it (kill() // would only detach the pty viewer and leave the CLI running in the // background — defeating the whole point of a session cap, since the CLI // is the memory hog). On the next message the session cold-resumes via diff --git a/test/backend-availability.test.ts b/test/backend-availability.test.ts index cdb1d7b71..d80fc65e2 100644 --- a/test/backend-availability.test.ts +++ b/test/backend-availability.test.ts @@ -6,6 +6,7 @@ function deps(overrides: Partial<BackendAvailabilityDeps> = {}): BackendAvailabi ensureTmux: vi.fn(async () => ({ installed: true, version: 'tmux 3.5', freshInstall: false, binaryPresent: true })), ensureHerdr: vi.fn(async () => ({ installed: true, version: 'herdr 0.7.3', freshInstall: false })), probeZellijFunctional: vi.fn(() => ({ ok: true, version: 'zellij 0.44.1' })), + probeZmxFunctional: vi.fn(() => ({ ok: true, version: 'zmx 0.6.0' })), ...overrides, }; } @@ -17,6 +18,7 @@ describe('ensureBackendAvailable', () => { expect(d.ensureTmux).not.toHaveBeenCalled(); expect(d.ensureHerdr).not.toHaveBeenCalled(); expect(d.probeZellijFunctional).not.toHaveBeenCalled(); + expect(d.probeZmxFunctional).not.toHaveBeenCalled(); }); it('prepares tmux and herdr before a dashboard save is allowed', async () => { @@ -55,4 +57,18 @@ describe('ensureBackendAvailable', () => { manualCommand: '请安装 zellij >= 0.44.0 后重试', }); }); + + it('probes zmx independently instead of falling through to the zellij probe', async () => { + const d = deps({ + probeZmxFunctional: vi.fn(() => ({ ok: false, reason: 'zmx 0.5.9 过旧' })), + }); + await expect(ensureBackendAvailable('zmx', d)).resolves.toEqual({ + ok: false, + backendType: 'zmx', + reason: 'zmx 0.5.9 过旧', + manualCommand: 'macOS: brew install neurosnap/tap/zmx;Linux: 安装 ZMX 官方 release binary(>= 0.6.0)', + }); + expect(d.probeZmxFunctional).toHaveBeenCalledTimes(1); + expect(d.probeZellijFunctional).not.toHaveBeenCalled(); + }); }); diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 602fb2529..fd7b8dfcc 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { decideBackendGate, backendGateUserMessage, + backendSandboxCompatibilityUserMessage, } from '../src/adapters/backend/session-backend-selector.js'; +const workerSource = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + describe('decideBackendGate (PTY 退役 hard gate)', () => { it('always spawns when PTY is explicitly requested (escape hatch), even if "unavailable"', () => { expect( @@ -28,9 +33,16 @@ describe('decideBackendGate (PTY 退役 hard gate)', () => { ).toEqual({ action: 'spawn' }); }); - it('gates herdr / zellij when unavailable instead of degrading to PTY', () => { + it('gates herdr / zellij / zmx when unavailable instead of degrading to PTY', () => { expect(decideBackendGate({ requested: 'herdr', available: false, hasExistingSession: false }).action).toBe('gate'); expect(decideBackendGate({ requested: 'zellij', available: false, hasExistingSession: false }).action).toBe('gate'); + expect(decideBackendGate({ requested: 'zmx', available: false, hasExistingSession: false }).action).toBe('gate'); + }); + + it('reattaches a live zmx session despite a transient probe failure', () => { + expect( + decideBackendGate({ requested: 'zmx', available: false, hasExistingSession: true }), + ).toEqual({ action: 'spawn' }); }); }); @@ -42,4 +54,42 @@ describe('backendGateUserMessage', () => { expect(msg).toContain('brew install tmux'); expect(msg).toContain('BACKEND_TYPE=pty'); }); + + it('includes the supported ZMX version and install path', () => { + const msg = backendGateUserMessage('zmx', 'zmx 二进制不在 PATH 上'); + expect(msg).toContain('zmx >= 0.6.0'); + expect(msg).toContain('neurosnap/tap/zmx'); + }); +}); + +describe('ZMX filesystem-isolation gate', () => { + it('posts an actionable user notification before failing closed', () => { + const msg = backendSandboxCompatibilityUserMessage( + 'backend "zmx" does not support file/read isolation', + ); + expect(msg).toContain('ZMX'); + expect(msg).toContain('拒绝启动'); + expect(msg).toContain('tmux'); + expect(msg).toContain('pty'); + expect(msg).toContain('sandbox'); + expect(msg).toContain('readIsolation'); + }); + + it('gates on effective isolation and sends user_notify before throwing', () => { + const start = workerSource.indexOf('const explicitLegacyReadIso ='); + const end = workerSource.indexOf('const readIsolationGate =', start); + const gate = workerSource.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + // A bare legacy readIsolation flag is a no-op on Linux. Passing the raw + // cfg value here would reject ZMX even though no boundary is enforced. + expect(gate).toContain('effectiveReadIsolationRequested: explicitLegacyReadIso'); + expect(gate).not.toContain('effectiveReadIsolationRequested: cfg.readIsolation'); + + const notify = gate.indexOf("type: 'user_notify'"); + const failure = gate.indexOf('throw new Error'); + expect(notify).toBeGreaterThan(-1); + expect(failure).toBeGreaterThan(notify); + }); }); diff --git a/test/backend-type-store.test.ts b/test/backend-type-store.test.ts index fd7fdfc83..e5ea4c807 100644 --- a/test/backend-type-store.test.ts +++ b/test/backend-type-store.test.ts @@ -40,11 +40,12 @@ describe('backend-type store', () => { return { registry, store }; } - it('isEditableBackendType accepts the four backends and rejects junk', async () => { + it('isEditableBackendType accepts all five backends and rejects junk', async () => { const { store } = await freshModules(); - for (const v of ['pty', 'tmux', 'herdr', 'zellij']) expect(store.isEditableBackendType(v)).toBe(true); + for (const v of ['pty', 'tmux', 'herdr', 'zellij', 'zmx']) expect(store.isEditableBackendType(v)).toBe(true); for (const v of ['auto', '', 'foo', null, 3, undefined]) expect(store.isEditableBackendType(v)).toBe(false); expect(store.EDITABLE_BACKEND_TYPES).toContain('herdr'); + expect(store.EDITABLE_BACKEND_TYPES).toContain('zmx'); }); it('sets a per-bot backend override, round-tripping to disk and registry', async () => { diff --git a/test/bot-config-editor.test.ts b/test/bot-config-editor.test.ts index 6984141be..1af16bdc0 100644 --- a/test/bot-config-editor.test.ts +++ b/test/bot-config-editor.test.ts @@ -277,6 +277,13 @@ describe('applyBotConfigEdits', () => { expect(edited.allowedUsers).toEqual(['13011112222', '+14155550123', 'on_x']); }); + it('accepts zmx as a backendType', () => { + const edited = applyBotConfigEdits({ + larkAppId: 'app', larkAppSecret: 'secret', cliId: 'claude-code', + }, { backendType: 'zmx' }); + expect(edited.backendType).toBe('zmx'); + }); + it('keeps fields unchanged on empty input and clears optional fields with dash', () => { const updated = applyBotConfigEdits({ larkAppId: 'app', diff --git a/test/bridge-final-output-retry.test.ts b/test/bridge-final-output-retry.test.ts index 9e556abba..1b15de4bb 100644 --- a/test/bridge-final-output-retry.test.ts +++ b/test/bridge-final-output-retry.test.ts @@ -14,6 +14,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const updateMessageMock = vi.fn(async () => {}); const addReactionMock = vi.fn(async () => 'reaction_id'); +const replyToDocCommentMock = vi.fn(async () => {}); +const removeCommentReactionMock = vi.fn(async () => {}); +const updateSessionMock = vi.fn(); vi.mock('../src/im/lark/client.js', () => ({ updateMessage: (...args: any[]) => updateMessageMock(...args), addReaction: (...args: any[]) => addReactionMock(...args), @@ -26,6 +29,13 @@ vi.mock('../src/im/lark/client.js', () => ({ }, })); +vi.mock('../src/im/lark/doc-comment.js', () => ({ + replyToDocComment: (...args: any[]) => replyToDocCommentMock(...args), + chunkCommentText: vi.fn((content: string) => [content]), + unsubscribeDocFile: vi.fn(async () => {}), + removeCommentReaction: (...args: any[]) => removeCommentReactionMock(...args), +})); + vi.mock('../src/im/lark/card-builder.js', () => ({ buildStreamingCard: vi.fn(() => '{}'), buildSessionCard: vi.fn(() => '{}'), @@ -57,7 +67,7 @@ vi.mock('../src/config.js', () => ({ vi.mock('../src/services/session-store.js', () => ({ closeSession: vi.fn(), - updateSession: vi.fn(), + updateSession: (...args: any[]) => updateSessionMock(...args), createSession: vi.fn(), updateSessionPid: vi.fn(), })); @@ -1240,6 +1250,101 @@ describe('Bridge final_output delivery (P2 retry)', () => { })).toEqual([]); }); + it('recovers a doc-comment destination from persisted per-turn state after restart', async () => { + const sessionReply = vi.fn(async () => 'om_reply'); + initWorkerPool({ + sessionReply, + getSessionWorkingDir: () => '/tmp', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + + const ds = makeDs(); + ds.scope = 'chat'; + ds.chatId = 'doc:doc-token'; + ds.session.scope = 'chat'; + ds.session.chatId = 'doc:doc-token'; + ds.session.docCommentTargets = { + 'turn-1': { + fileToken: 'doc-token', + fileType: 'docx', + commentId: 'comment-1', + turnId: 'turn-1', + replyId: 'reply-1', + reactionId: 'reaction-1', + }, + }; + + const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any; + __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0); + await vi.advanceTimersByTimeAsync(10); + + expect(replyToDocCommentMock).toHaveBeenCalledTimes(1); + expect(sessionReply).not.toHaveBeenCalled(); + expect(ds.session.docCommentTargets).toBeUndefined(); + expect(removeCommentReactionMock).toHaveBeenCalledTimes(1); + expect(ds.lastBridgeEmittedUuid).toBe(SCOPED_DEDUPE_KEY); + }); + + it('consumes doc-comment turn state after posting even when reaction cleanup fails', async () => { + const sessionReply = vi.fn(async () => 'om_reply'); + initWorkerPool({ + sessionReply, + getSessionWorkingDir: () => '/tmp', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + removeCommentReactionMock.mockRejectedValueOnce(new Error('reaction already gone')); + + const ds = makeDs(); + const target = { + fileToken: 'doc-token', + fileType: 'docx', + commentId: 'comment-1', + replyId: 'reply-1', + reactionId: 'reaction-1', + }; + ds.docCommentTurns = new Map([['turn-1', target]]); + ds.session.docCommentTargets = { + 'turn-1': { ...target, turnId: 'turn-1' }, + }; + + const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any; + __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0); + await vi.advanceTimersByTimeAsync(20_000); + + expect(replyToDocCommentMock).toHaveBeenCalledTimes(1); + expect(sessionReply).not.toHaveBeenCalled(); + expect(ds.docCommentTurns).toBeUndefined(); + expect(ds.session.docCommentTargets).toBeUndefined(); + expect(updateSessionMock).toHaveBeenCalledWith(ds.session); + expect(ds.lastBridgeEmittedUuid).toBe(SCOPED_DEDUPE_KEY); + }); + + it('never posts a fallback card for a doc-native session without a safe comment target', async () => { + const sessionReply = vi.fn(async () => 'om_reply'); + initWorkerPool({ + sessionReply, + getSessionWorkingDir: () => '/tmp', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + + const ds = makeDs(); + ds.scope = 'chat'; + ds.chatId = 'doc:doc-token'; + ds.session.scope = 'chat'; + ds.session.chatId = 'doc:doc-token'; + + const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any; + __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0); + await vi.advanceTimersByTimeAsync(10); + + expect(replyToDocCommentMock).not.toHaveBeenCalled(); + expect(sessionReply).not.toHaveBeenCalled(); + expect(ds.lastBridgeEmittedUuid).toBe(SCOPED_DEDUPE_KEY); + }); + it('retries on transient failure and commits after success', async () => { const sessionReply = vi .fn() diff --git a/test/card-handler-relay-pickup.test.ts b/test/card-handler-relay-pickup.test.ts index ee4895ccc..541570b60 100644 --- a/test/card-handler-relay-pickup.test.ts +++ b/test/card-handler-relay-pickup.test.ts @@ -413,6 +413,38 @@ describe('relay_confirm button click', () => { expect(r?.toast?.type).toBe('success'); }); + it.each([ + ['persisted real session', (ds: DaemonSession) => { ds.session.cliId = 'claude-code'; }], + ['queued session', (ds: DaemonSession) => { ds.session.queued = true; }], + ['adopt session', (ds: DaemonSession) => { + ds.adoptedFrom = { source: 'tmux', tmuxTarget: 'ext:0.0', originalCliPid: 42, cwd: '/tmp' } as any; + }], + ['pending repository choice', (ds: DaemonSession) => { ds.pendingRepo = { prompt: 'pick' } as any; }], + ['deferred prompt', (ds: DaemonSession) => { ds.pendingPrompt = 'continue'; }], + ['deferred raw input', (ds: DaemonSession) => { ds.pendingRawInput = 'continue'; }], + ])('pre-flight blocks a worker-less %s before sending M1', async (_label, protect) => { + const sourceDs = makeDs(); + const targetDs = makeDsInChat({ + sessionId: 'sess-protected-target', + chatId: 'oc_target', + scope: 'chat', + worker: null, + title: 'protected target', + }); + protect(targetDs); + const map = new Map<string, DaemonSession>([ + [sessionKey('om_source_root', LARK_APP_ID), sourceDs], + [sessionKey('oc_target', LARK_APP_ID), targetDs], + ]); + + const r = await handleCardAction(actionData({ sessionId: 'sess-source-1' }), deps(map), LARK_APP_ID); + + expect(r).toBeUndefined(); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(sendMessageMock.mock.calls[0]?.[2]).toContain('protected target'); + expect(transferSessionMock).not.toHaveBeenCalled(); + }); + it('cleans up the orphan M1 when transferSession fails after the M1 was sent (race fallback)', async () => { const ds = makeDs(); const map = new Map<string, DaemonSession>(); diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index c6a862bdf..25530afb7 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -1832,18 +1832,20 @@ describe('handleCommand', () => { expect(sessionStore.updateSession).not.toHaveBeenCalled(); }); - it('updates the session title without restarting the worker', async () => { + it('updates and flattens title metadata without restarting the worker', async () => { const ds = makeDaemonSession(); const deps = makeDeps(ds); - await handleCommand('/rename', ROOT_ID, makeLarkMessage('/rename ZMX 后端集成推进 '), deps, LARK_APP_ID); + await handleCommand('/rename', ROOT_ID, makeLarkMessage('/rename ZMX 后端集成推进\n阶段二 '), deps, LARK_APP_ID); - expect(ds.session.title).toBe('ZMX 后端集成推进'); + expect(ds.session.title).toBe('ZMX 后端集成推进 阶段二'); + expect(ds.session.titleSource).toBe('user'); + expect(ds.session.titleUpdatedAt).toEqual(expect.any(String)); expect(killWorker).not.toHaveBeenCalled(); expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); const replyContent = (deps.sessionReply as ReturnType<typeof vi.fn>).mock.calls[0][1] as string; expect(replyContent).toContain('会话标题已更新'); - expect(replyContent).toContain('ZMX 后端集成推进'); + expect(replyContent).toContain('ZMX 后端集成推进 阶段二'); expect(replyContent).toContain('Agent 当前未运行'); }); diff --git a/test/dashboard-attention-signals.test.ts b/test/dashboard-attention-signals.test.ts index 956179f31..13eceb4dd 100644 --- a/test/dashboard-attention-signals.test.ts +++ b/test/dashboard-attention-signals.test.ts @@ -8,7 +8,7 @@ // no-ops for non-pending sessions import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { readFileSync } from 'fs'; -import { composeRowFromActive } from '../src/core/dashboard-rows.js'; +import { composeRowFromActive, composeRowFromClosed } from '../src/core/dashboard-rows.js'; import { announcePendingRepoSession, announceSessionRow, @@ -141,6 +141,34 @@ describe('attention signals', () => { ]); }); + it('composeRowFromActive exposes backend metadata for external session bridges', () => { + const zmx = makeDs(); + zmx.session.backendType = 'zmx'; + zmx.session.titleUpdatedAt = '2026-07-13T10:00:00.000Z'; + zmx.session.titleSource = 'agent'; + expect(composeRowFromActive(zmx)).toMatchObject({ + backendType: 'zmx', + backendSessionName: 'bmx-sess-1', + titleUpdatedAt: '2026-07-13T10:00:00.000Z', + titleSource: 'agent', + }); + + const adopted = makeDs(); + adopted.session.backendType = 'zmx'; + adopted.session.adoptedFrom = { source: 'tmux', tmuxTarget: 'user:1.0', cwd: '/repo' }; + expect(composeRowFromActive(adopted).backendSessionName).toBeUndefined(); + + const closed = { ...zmx.session, status: 'closed' as const, closedAt: '2026-07-13T11:00:00.000Z' }; + expect(composeRowFromClosed(closed)).toMatchObject({ + status: 'closed', + backendType: 'zmx', + backendSessionName: 'bmx-sess-1', + }); + + const legacy = { ...closed, backendType: undefined }; + expect(composeRowFromClosed(legacy).backendSessionName).toBeUndefined(); + }); + it('publishAttentionPatch emits session.update derived from session state', () => { const seen = collectEvents(); publishAttentionPatch(makeDs({ tuiPromptCardId: 'om_card' })); @@ -218,10 +246,13 @@ describe('attention signals', () => { const src = readFileSync(new URL('../src/daemon.ts', import.meta.url), 'utf-8'); const start = src.indexOf('async function handleThreadReply('); expect(start).toBeGreaterThanOrEqual(0); - // 20000:窗口需罩住函数头到最后一个拦截点 (findPendingAskByAnchor) 的全部 - // 源码——passthrough 冷启动等合法插入会把后续 marker 往后推,窗口太紧会误报。 - // 语义断言不变:clear 在所有拦截点之前。 - const region = src.slice(start, start + 20000); + const end = src.indexOf('async function autoCreateDocSession(', start); + expect(end).toBeGreaterThan(start); + // Bound the source-order assertion by the next top-level handler instead + // of a character count. Legitimate additions to handleThreadReply (for + // example CAS handoff paths) must not make this regression test silently + // inspect only the first part of the function. + const region = src.slice(start, end); const clearIdx = region.indexOf('clearAgentAttentionForHumanInbound();'); expect(clearIdx).toBeGreaterThanOrEqual(0); for (const marker of [ diff --git a/test/dashboard-create-session.test.ts b/test/dashboard-create-session.test.ts index f9e3e4784..7efe48ace 100644 --- a/test/dashboard-create-session.test.ts +++ b/test/dashboard-create-session.test.ts @@ -53,9 +53,15 @@ vi.mock('../src/core/worker-pool.js', () => ({ killStalePids: vi.fn(), getCurrentCliVersion: vi.fn(() => 'test-cli-v1'), restoreUsageLimitRuntimeState: vi.fn(), - setActiveSessionSafe: vi.fn(async (map: Map<string, any>, k: string, ds: any) => { map.set(k, ds); }), + setActiveSessionIfActive: vi.fn((map: Map<string, any>, k: string, ds: any) => { + if (map.has(k) && map.get(k) !== ds) return false; + map.set(k, ds); + return true; + }), + setActiveSessionSafe: vi.fn(async (map: Map<string, any>, k: string, ds: any) => { map.set(k, ds); return true; }), getActiveSessionsRegistry: vi.fn(() => null), isRelayableRealSession: vi.fn(() => false), + isDisposableCommandScratch: vi.fn(() => true), closeSession: vi.fn(), })); diff --git a/test/doc-comment-daemon-concurrency.test.ts b/test/doc-comment-daemon-concurrency.test.ts new file mode 100644 index 000000000..47a979eaf --- /dev/null +++ b/test/doc-comment-daemon-concurrency.test.ts @@ -0,0 +1,455 @@ +/** + * Regression coverage for document-comment session routing races. + * + * Most of the delivery pipeline intentionally remains private to daemon.ts, so + * the tests combine behavioural coverage of the small routing primitives with + * source-order guards at the private integration points. This mirrors the + * established daemon closure coverage in initial-passthrough-ownership.test.ts. + */ +import { readFileSync } from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonSession } from '../src/core/types.js'; + +vi.mock('@larksuiteoapi/node-sdk', () => { + class FakeClient { constructor(public opts: Record<string, unknown>) {} } + class FakeWSClient { start() {} } + class FakeEventDispatcher { register() {} } + return { + Client: FakeClient, + WSClient: FakeWSClient, + EventDispatcher: FakeEventDispatcher, + LoggerLevel: { info: 2 }, + }; +}); + +const src = readFileSync(new URL('../src/daemon.ts', import.meta.url), 'utf-8'); + +function asyncFnRegion(name: string, nextName: string): string { + const start = src.indexOf(`async function ${name}(`); + const end = src.indexOf(`async function ${nextName}(`, start + 1); + expect(start, `${name} not found in daemon.ts`).toBeGreaterThanOrEqual(0); + expect(end, `${nextName} not found after ${name}`).toBeGreaterThan(start); + return src.slice(start, end); +} + +function makeDs(sessionId: string, rootMessageId = 'om_doc_root'): DaemonSession { + return { + session: { + sessionId, + chatId: 'oc_doc_chat', + rootMessageId, + scope: 'thread', + title: sessionId, + status: 'active', + createdAt: new Date(0).toISOString(), + larkAppId: 'app-doc', + }, + worker: null, + workerPort: null, + workerToken: null, + larkAppId: 'app-doc', + chatId: 'oc_doc_chat', + chatType: 'group', + scope: 'thread', + spawnedAt: 0, + cliVersion: 'test', + lastMessageAt: 0, + hasHistory: false, + } as DaemonSession; +} + +function makeDocDs(sessionId: string, fileToken = 'doccnFILE'): DaemonSession { + const anchor = `doc:${fileToken}`; + return { + session: { + sessionId, + chatId: anchor, + rootMessageId: anchor, + scope: 'chat', + title: sessionId, + status: 'active', + createdAt: new Date(0).toISOString(), + larkAppId: 'app-doc', + }, + worker: null, + workerPort: null, + workerToken: null, + larkAppId: 'app-doc', + chatId: anchor, + chatType: 'group', + scope: 'chat', + spawnedAt: 0, + cliVersion: 'test', + lastMessageAt: 0, + hasHistory: false, + } as DaemonSession; +} + +describe('document-comment routing generation primitives', () => { + beforeEach(async () => { + const daemon = await import('../src/daemon.js'); + daemon.__testOnly_activeSessions.clear(); + daemon.__testOnly_resetDocCommentClaims(); + }); + + it('accepts only the exact map occupant, Session object, route, and active status', async () => { + const daemon = await import('../src/daemon.js'); + const ds = makeDs('candidate'); + const key = 'om_doc_root::app-doc'; + daemon.__testOnly_activeSessions.set(key, ds); + const generation = daemon.__testOnly_captureRoutingGeneration(ds); + + expect(daemon.__testOnly_isCurrentRoutingGeneration(generation)).toBe(true); + + daemon.__testOnly_activeSessions.set(key, makeDs('replacement')); + expect(daemon.__testOnly_isCurrentRoutingGeneration(generation)).toBe(false); + + daemon.__testOnly_activeSessions.set(key, ds); + ds.session.status = 'closed'; + expect(daemon.__testOnly_isCurrentRoutingGeneration(generation)).toBe(false); + + ds.session.status = 'active'; + const originalSession = ds.session; + ds.session = { ...originalSession }; + expect(daemon.__testOnly_isCurrentRoutingGeneration(generation)).toBe(false); + + ds.session = originalSession; + ds.session.rootMessageId = 'om_moved'; + expect(daemon.__testOnly_isCurrentRoutingGeneration(generation)).toBe(false); + }); + + it('keeps a concurrent winner and returns it after rolling back the rejected candidate', async () => { + const daemon = await import('../src/daemon.js'); + const winner = makeDs('winner'); + const candidate = makeDs('candidate'); + const key = 'om_doc_root::app-doc'; + daemon.__testOnly_activeSessions.set(key, winner); + const rollback = vi.fn(async ( + _map: Map<string, DaemonSession>, + rollbackKey: string, + rejected: DaemonSession, + ) => { + expect(rollbackKey).toBe(key); + expect(rejected).toBe(candidate); + rejected.session.status = 'closed'; + return winner; + }); + + const selected = await daemon.__testOnly_registerDocSessionCandidate(key, candidate, rollback); + + expect(selected).toBe(winner); + expect(daemon.__testOnly_activeSessions.get(key)).toBe(winner); + expect(candidate.session.status).toBe('closed'); + expect(rollback).toHaveBeenCalledOnce(); + }); + + it('registers the candidate without rollback when the route is free', async () => { + const daemon = await import('../src/daemon.js'); + const candidate = makeDs('candidate'); + const key = 'om_doc_root::app-doc'; + const rollback = vi.fn(); + + const selected = await daemon.__testOnly_registerDocSessionCandidate(key, candidate, rollback); + + expect(selected).toBe(candidate); + expect(daemon.__testOnly_activeSessions.get(key)).toBe(candidate); + expect(rollback).not.toHaveBeenCalled(); + }); + + it('persists and mutates an auto-created binding to the selected doc-native route', async () => { + const daemon = await import('../src/daemon.js'); + const selected = makeDocDs('winner'); + const sub = { + fileToken: 'doccnFILE', + fileType: 'docx', + sessionAnchor: 'om_old_root', + sessionId: 'old-session', + scope: 'thread' as const, + chatId: 'oc_old_chat', + commentTriggerMode: 'all' as const, + pollCursorAt: 42, + createdAt: 1, + }; + const current = { ...sub, pollCursorReplyId: 'reply-newer' }; + const read = vi.fn(() => current); + const write = vi.fn(() => ({})); + + daemon.__testOnly_persistDocBindingToSession(sub, 'app-doc', selected, { + dataDir: '/tmp/doc-routing-test', + read, + write, + }); + + const canonical = expect.objectContaining({ + sessionAnchor: 'doc:doccnFILE', + sessionId: 'winner', + scope: 'chat', + chatId: 'doc:doccnFILE', + pollCursorAt: 42, + pollCursorReplyId: 'reply-newer', + }); + expect(write).toHaveBeenCalledWith('/tmp/doc-routing-test', 'app-doc', canonical); + expect(sub).toEqual(canonical); + }); + + it('accepts the CAS winner binding without clobbering newer subscription fields', async () => { + const daemon = await import('../src/daemon.js'); + const selected = makeDocDs('winner'); + const sub = { + fileToken: 'doccnFILE', + fileType: 'docx', + sessionAnchor: 'om_old_root', + sessionId: 'old-session', + scope: 'thread' as const, + chatId: 'oc_old_chat', + commentTriggerMode: 'all' as const, + createdAt: 1, + }; + const current = { + ...sub, + sessionAnchor: 'doc:doccnFILE', + sessionId: 'winner', + scope: 'chat' as const, + chatId: 'doc:doccnFILE', + pollCursorAt: 99, + }; + const write = vi.fn(() => ({})); + + expect(() => daemon.__testOnly_persistDocBindingToSession(sub, 'app-doc', selected, { + dataDir: '/tmp/doc-routing-test', + read: () => current, + write, + })).not.toThrow(); + + expect(write).toHaveBeenCalledWith( + '/tmp/doc-routing-test', + 'app-doc', + expect.objectContaining({ sessionId: 'winner', pollCursorAt: 99 }), + ); + expect(sub).toMatchObject({ sessionAnchor: 'doc:doccnFILE', sessionId: 'winner', pollCursorAt: 99 }); + }); + + it('refuses to overwrite a newer explicit subscription rebind', async () => { + const daemon = await import('../src/daemon.js'); + const selected = makeDocDs('auto-created'); + const sub = { + fileToken: 'doccnFILE', + fileType: 'docx', + sessionAnchor: 'om_old_root', + sessionId: 'old-session', + scope: 'thread' as const, + chatId: 'oc_old_chat', + commentTriggerMode: 'all' as const, + createdAt: 1, + }; + const current = { + ...sub, + sessionAnchor: 'om_new_root', + sessionId: 'explicit-new-session', + chatId: 'oc_new_chat', + createdAt: 2, + }; + const write = vi.fn(() => ({})); + + expect(() => daemon.__testOnly_persistDocBindingToSession(sub, 'app-doc', selected, { + dataDir: '/tmp/doc-routing-test', + read: () => current, + write, + })).toThrow(/rebound during routing/); + + expect(write).not.toHaveBeenCalled(); + expect(sub).toMatchObject({ sessionAnchor: 'om_old_root', sessionId: 'old-session' }); + }); + + it('follows a relayed subscribed session by sessionId instead of a replacement at the stale anchor', async () => { + const daemon = await import('../src/daemon.js'); + const replacement = makeDs('replacement-at-old-anchor'); + const relayed = makeDs('subscribed-session', 'om_new_root'); + daemon.__testOnly_activeSessions.set('om_doc_root::app-doc', replacement); + daemon.__testOnly_activeSessions.set('om_new_root::app-doc', relayed); + const sub = { + fileToken: 'doccnFILE', + fileType: 'docx', + sessionAnchor: 'om_doc_root', + sessionId: 'subscribed-session', + scope: 'thread' as const, + chatId: 'oc_doc_chat', + commentTriggerMode: 'all' as const, + createdAt: 1, + }; + const persist = vi.fn(); + + const selected = daemon.__testOnly_resolveBoundDocSession(sub, 'app-doc', persist); + + expect(selected).toBe(relayed); + expect(persist).toHaveBeenCalledWith(sub, 'app-doc', relayed); + expect(selected).not.toBe(replacement); + }); + + it('rejects a different session that reused a stale subscription anchor', async () => { + const daemon = await import('../src/daemon.js'); + const replacement = makeDs('replacement-at-old-anchor'); + daemon.__testOnly_activeSessions.set('om_doc_root::app-doc', replacement); + const sub = { + fileToken: 'doccnFILE', + fileType: 'docx', + sessionAnchor: 'om_doc_root', + sessionId: 'subscribed-session', + scope: 'thread' as const, + chatId: 'oc_doc_chat', + commentTriggerMode: 'all' as const, + createdAt: 1, + }; + const persist = vi.fn(); + + expect(daemon.__testOnly_resolveBoundDocSession(sub, 'app-doc', persist)).toBeUndefined(); + expect(persist).not.toHaveBeenCalled(); + }); + + it('rolls back only its own newly-registered candidate when binding persistence fails', async () => { + const daemon = await import('../src/daemon.js'); + const candidate = makeDocDs('candidate'); + const key = 'doc:doccnFILE::app-doc'; + daemon.__testOnly_activeSessions.set(key, candidate); + const persistError = new Error('disk write failed'); + const rollback = vi.fn(async () => { + if (daemon.__testOnly_activeSessions.get(key) === candidate) { + daemon.__testOnly_activeSessions.delete(key); + candidate.session.status = 'closed'; + } + }); + + await expect(daemon.__testOnly_persistSelectedDocBinding( + key, + {} as never, + 'app-doc', + candidate, + candidate, + () => { throw persistError; }, + rollback, + )).rejects.toBe(persistError); + + expect(rollback).toHaveBeenCalledWith('candidate'); + expect(candidate.session.status).toBe('closed'); + expect(daemon.__testOnly_activeSessions.has(key)).toBe(false); + }); + + it('does not close another CAS winner when binding persistence fails', async () => { + const daemon = await import('../src/daemon.js'); + const winner = makeDocDs('winner'); + const rejected = makeDocDs('rejected'); + const key = 'doc:doccnFILE::app-doc'; + daemon.__testOnly_activeSessions.set(key, winner); + const persistError = new Error('disk write failed'); + const rollback = vi.fn(async () => {}); + + await expect(daemon.__testOnly_persistSelectedDocBinding( + key, + {} as never, + 'app-doc', + winner, + rejected, + () => { throw persistError; }, + rollback, + )).rejects.toBe(persistError); + + expect(rollback).not.toHaveBeenCalled(); + expect(daemon.__testOnly_activeSessions.get(key)).toBe(winner); + expect(winner.session.status).toBe('active'); + }); + + it('makes a concurrent follower await the owner failure instead of reporting success', async () => { + const daemon = await import('../src/daemon.js'); + let failOwner!: () => void; + const ownerGate = new Promise<void>((_resolve, reject) => { + failOwner = () => reject(new Error('delivery failed')); + }); + const ownerCleanup = vi.fn(async () => {}); + const owner = daemon.__testOnly_runClaimedDocCommentTurn( + 'app-doc:file:reply-1', + async () => { await ownerGate; }, + ownerCleanup, + ); + const ownerSettled = owner.catch((err: unknown) => err); + const followerWork = vi.fn(async () => {}); + const follower = daemon.__testOnly_runClaimedDocCommentTurn( + 'app-doc:file:reply-1', + followerWork, + ); + + failOwner(); + + expect(await ownerSettled).toEqual(expect.objectContaining({ message: 'delivery failed' })); + expect(await follower).toBe(false); + expect(followerWork).not.toHaveBeenCalled(); + expect(ownerCleanup).toHaveBeenCalledOnce(); + + const retryWork = vi.fn(async () => {}); + await expect(daemon.__testOnly_runClaimedDocCommentTurn('app-doc:file:reply-1', retryWork)).resolves.toBe(true); + expect(retryWork).toHaveBeenCalledOnce(); + }); +}); + +describe('document-comment routing integration', () => { + it('registers auto-created sessions through CAS and hands loser comments to the winner', () => { + const fullRegion = asyncFnRegion('autoCreateDocSession', 'handleDocComment'); + const region = fullRegion.slice(0, fullRegion.indexOf('const handledDocCommentTurns')); + expect(region).not.toContain('activeSessions.set('); + expect(region).not.toContain('resolveSender('); + expect(region).not.toContain('docCommentTurns'); + expect(region).not.toContain('docCommentTargets'); + expect(region).toContain('const virtualAnchor = virtualChatId;'); + expect(region).toContain('registerDocSessionCandidate(routingKey, ds)'); + expect(region).toContain('persistSelectedDocBinding(routingKey, sub, larkAppId, selected, ds)'); + expect(region).toContain('return selected;'); + }); + + it('revalidates prewarm after sender resolution before mutating or dispatching', () => { + const region = asyncFnRegion('prewarmDocCommentSession', 'autoCreateDocSession'); + expect(region).toMatch(/const generation = captureRoutingGeneration\(ds\);[\s\S]*await resolveSender[\s\S]*ensureCurrentRoutingGeneration\(generation, 'prewarm:sender'\)/); + const guard = region.indexOf("ensureCurrentRoutingGeneration(generation, 'prewarm:sender')"); + expect(guard).toBeGreaterThanOrEqual(0); + expect(region.indexOf('beginNewTurn(ds, title)')).toBeGreaterThan(guard); + expect(region.indexOf('sendWorkerInput(ds, cliInput', guard)).toBeGreaterThan(guard); + expect(region.indexOf('forkWorker(ds, wrappedInput', guard)).toBeGreaterThan(guard); + }); + + it('revalidates comment delivery after every key await and before send/fork', () => { + const region = asyncFnRegion('handleDocComment', 'pollWatchedDocComments'); + expect(region).toContain('resolveBoundDocSession(sub, larkAppId)'); + expect(region).toMatch(/await addCommentReaction[\s\S]*ensureCurrentRoutingGeneration\(generation, 'comment:reaction'\)/); + expect(region).toMatch(/await resolveSender[\s\S]*ensureCurrentRoutingGeneration\(generation, 'comment:sender'\)/); + expect(region).toMatch(/await noteTurnReceived[\s\S]*ensureCurrentRoutingGeneration\(generation, 'comment:live-note'\)[\s\S]*sendWorkerInput/); + expect(region).toMatch(/await noteTurnReceived[\s\S]*ensureCurrentRoutingGeneration\(generation, 'comment:refork-note'\)[\s\S]*forkWorker/); + }); + + it('rolls back only this turn target and an already-landed Typing reaction before releasing the claim', () => { + const region = asyncFnRegion('handleDocComment', 'pollWatchedDocComments'); + expect(region).toContain('targetMatchesThisTurn(runtimeTarget)'); + expect(region).toContain('deliveryDs.docCommentTurns?.delete(turnId)'); + expect(region).toContain('targetMatchesThisTurn(persistedTarget)'); + expect(region).toContain('delete deliverySession.docCommentTargets?.[turnId]'); + expect(region).toContain('sessionStore.getOwnedSession(deliverySession.sessionId) === deliverySession'); + expect(region).toMatch(/if \(reactionId && userReplyId\)[\s\S]*await removeCommentReaction/); + expect(region).toMatch(/\(ds\.session\.docCommentTargets \?\?= \{\}\)\[turnId\] = docTarget/); + expect(region).toContain('}, cleanupFailedDelivery)'); + }); + + it('shares an in-flight Promise result and deletes a failed claim only after cleanup', () => { + const region = asyncFnRegion('runClaimedDocCommentTurn', 'handleDocComment'); + expect(src).toContain('new BoundedMap<string, Promise<boolean>>'); + expect(region).toContain('if (existing) return await existing;'); + const cleanup = region.indexOf('await onOwnerFailure?.(err)'); + const deletion = region.indexOf('handledDocCommentTurns.delete(claimKey)', cleanup); + const settle = region.indexOf('resolveClaim(false)', deletion); + expect(cleanup).toBeGreaterThanOrEqual(0); + expect(deletion).toBeGreaterThan(cleanup); + expect(settle).toBeGreaterThan(deletion); + }); + + it('uses only the current bot-owned session store during subscription restore', () => { + const region = asyncFnRegion('restoreDocSubscriptions', 'startDaemon'); + expect(region).toContain('sessionStore.getOwnedSession(sub.sessionId)'); + expect(region).not.toContain('sessionStore.getSession(sub.sessionId)'); + }); +}); diff --git a/test/initial-passthrough-ownership.test.ts b/test/initial-passthrough-ownership.test.ts index 29462bb2b..337c218d2 100644 --- a/test/initial-passthrough-ownership.test.ts +++ b/test/initial-passthrough-ownership.test.ts @@ -51,6 +51,11 @@ describe('startInitialPassthroughSession ownership', () => { expect(region).toContain('session.ownerUnionId = ownerUnionId;'); expect(region).toContain('session.creatorOpenId = creatorOpenId;'); }); + + it('hands a registration loser to the post-rollback winner with live passthrough semantics', () => { + expect(region).toContain('rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds)'); + expect(region).toContain('deliverPassthroughToExistingSession(winner, cmd, commandContent, anchor, larkAppId)'); + }); }); describe('startInitialPassthroughSession call sites', () => { @@ -66,8 +71,37 @@ describe('startInitialPassthroughSession call sites', () => { expect(calls.length).toBeGreaterThanOrEqual(2); for (const call of calls) { const body = call.slice(0, call.indexOf('});')); + expect(body).toContain('cmd,'); expect(body).toContain('ownerOpenId:'); expect(body).toContain('creatorOpenId:'); } }); }); + +describe('registration loser command handoff', () => { + it('routes new-topic and thread daemon commands through the current winner', () => { + expect(src).toContain( + 'rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, commandDs)', + ); + expect(src).toContain( + 'rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, threadCommandDs)', + ); + expect(src).toContain( + 'await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId)', + ); + expect(src).toContain( + 'await handleCommand(cmd, anchor, cmdMessage, commandDeps, larkAppId)', + ); + }); + + it('prepared message handoff skips duplicate resolve, identity-learning, and hook effects', () => { + const start = src.indexOf('async function handleThreadReply('); + const end = src.indexOf('/**\n * 文档评论入口', start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const region = src.slice(start, end); + expect(region).toContain('if (!prepared) await resolveNonsupportMessage(data, larkAppId);'); + expect(region).toContain('if (!prepared) learnFromMentions(larkAppId, parsed.mentions);'); + expect(region).toMatch(/if \(!prepared\) \{[\s\S]*emitHookEvent\('thread\.reply'/); + }); +}); diff --git a/test/kill-worker-orphaned-backend.test.ts b/test/kill-worker-orphaned-backend.test.ts index fbdcf124e..5804cf0a8 100644 --- a/test/kill-worker-orphaned-backend.test.ts +++ b/test/kill-worker-orphaned-backend.test.ts @@ -2,7 +2,7 @@ * Unit tests for killWorker's orphaned-backing-session teardown (worker-pool.ts). * * Bug: clicking 「关闭会话」/close does not kill the CLI running in tmux when the - * session has no live worker. A persistent backend (tmux/herdr/zellij) keeps its + * session has no live worker. A persistent backend (tmux/herdr/zellij/zmx) keeps its * backing session + CLI alive across a worker exit BY DESIGN (idle-suspend and * lazy-restore resume into it later). killWorker used to early-return when * `ds.worker` was null, so the 'close' IPC — and the worker-side destroySession() @@ -18,11 +18,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { DaemonSession } from '../src/core/types.js'; -const { tmuxKill, herdrKill, herdrKillAgent, zellijKill, getBotMock } = vi.hoisted(() => ({ +const { tmuxKill, herdrKill, herdrKillAgent, zellijKill, zmxKill, getBotMock } = vi.hoisted(() => ({ tmuxKill: vi.fn(), herdrKill: vi.fn(), herdrKillAgent: vi.fn(), zellijKill: vi.fn(), + zmxKill: vi.fn(), getBotMock: vi.fn(() => ({ resolvedAllowedUsers: [], config: {} })), })); @@ -39,6 +40,13 @@ vi.mock('../src/adapters/backend/herdr-backend.js', () => ({ vi.mock('../src/adapters/backend/zellij-backend.js', () => ({ ZellijBackend: { sessionName: (id: string) => `bmx-${id.slice(0, 8)}`, killSession: zellijKill }, })); +vi.mock('../src/adapters/backend/zmx-backend.js', () => ({ + ZmxBackend: { + sessionName: (id: string) => `bmx-${id.slice(0, 8)}`, + killSession: zmxKill, + listBotmuxSessions: vi.fn(() => []), + }, +})); vi.mock('../src/bot-registry.js', () => ({ getBot: getBotMock, @@ -128,11 +136,18 @@ describe('killWorker — orphaned backing session teardown (no live worker)', () expect(tmuxKill).not.toHaveBeenCalled(); }); + it('destroys the zmx backing session', () => { + killWorker(ds({}, { backendType: 'zmx' })); + expect(zmxKill).toHaveBeenCalledWith(EXPECTED_NAME); + expect(tmuxKill).not.toHaveBeenCalled(); + }); + it('does nothing for a non-persistent pty backend', () => { killWorker(ds({}, { backendType: 'pty' })); expect(tmuxKill).not.toHaveBeenCalled(); expect(herdrKill).not.toHaveBeenCalled(); expect(zellijKill).not.toHaveBeenCalled(); + expect(zmxKill).not.toHaveBeenCalled(); }); it('SKIPS adopt sessions (initConfig.adoptMode) — never kills the user\'s own pane', () => { diff --git a/test/local-cli-opener.test.ts b/test/local-cli-opener.test.ts index 89f5c7788..46d619f10 100644 --- a/test/local-cli-opener.test.ts +++ b/test/local-cli-opener.test.ts @@ -161,6 +161,25 @@ describe('local-cli-opener', () => { }); }); + it('attach mode opens a managed ZMX session with an existing-only guard', () => { + vi.stubEnv('ZMX_DIR', '/tmp/zmx socket'); + vi.stubEnv('ZMX_SESSION', 'outer'); + const adapterFactory = vi.fn(() => ({ buildResumeCommand: () => 'codex resume should-not-run' })); + const result = buildLocalCliOpenCommand(ds({ + session: { ...ds().session, sessionId: 'abcdef123456', backendType: 'zmx', cliSessionId: undefined }, + }), { mode: 'attach', adapterFactory }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.command).toContain('unset ZMX_SESSION ZMX_SESSION_PREFIX'); + expect(result.command).toContain("export ZMX_DIR='/tmp/zmx socket'"); + expect(result.command).toContain("grep -Fqx -- 'bmx-abcdef12'"); + expect(result.command).toContain("exec zmx attach 'bmx-abcdef12' /bin/sh -c 'exit 75'"); + } + expect(adapterFactory).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + it('attach mode opens adopted Herdr by exact scoped terminal id when available', () => { const result = buildLocalCliOpenCommand(ds({ adoptedFrom: { source: 'herdr', herdrSessionName: 'dev', herdrTerminalId: 'terminal_1', cwd: '/repo' }, diff --git a/test/persistent-backend-type.test.ts b/test/persistent-backend-type.test.ts index 7535a95df..a4fde73ca 100644 --- a/test/persistent-backend-type.test.ts +++ b/test/persistent-backend-type.test.ts @@ -18,11 +18,13 @@ vi.mock('../src/bot-registry.js', () => ({ getBot: vi.fn(() => ({ config: { backendType: bot.backendType } })), })); +import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; import { getSessionPersistentBackendType, killPersistentBackendTarget, managedTargetsForCliChange, probePersistentBackendTarget, + probePersistentSessions, resolvePersistentBackendTarget, resolvePairedSpawnBackendType, resolveSpawnBackendType, @@ -45,6 +47,11 @@ describe('getSessionPersistentBackendType', () => { expect(getSessionPersistentBackendType(ds({ initBackend: 'tmux', sessionBackend: 'zellij' }))).toBe('tmux'); }); + it('keeps the running persistent backend authoritative after bot config hot-switches to PTY', () => { + bot.backendType = 'pty'; + expect(getSessionPersistentBackendType(ds({ initBackend: 'zmx', sessionBackend: 'zmx' }))).toBe('zmx'); + }); + it('falls back to the backend stamped on the persisted session', () => { expect(getSessionPersistentBackendType(ds({ sessionBackend: 'zellij' }))).toBe('zellij'); }); @@ -163,3 +170,37 @@ describe('shutdownBackendDisposition (shutdown freeze-once)', () => { expect(shutdownBackendDisposition(ds({ sessionBackend: 'pty' }))).toBe('close'); }); }); + +describe('probePersistentSessions', () => { + it('classifies all ZMX names from one full-list snapshot', () => { + const probe = vi.spyOn(ZmxBackend, 'probeSessions').mockReturnValue({ + ok: true, + sessions: ['bmx-live'], + unhealthySessions: ['bmx-timeout'], + raw: '', + }); + + expect([...probePersistentSessions('zmx', [ + 'bmx-live', + 'bmx-timeout', + 'bmx-missing', + 'bmx-live', + ])]).toEqual([ + ['bmx-live', 'exists'], + ['bmx-timeout', 'unknown'], + ['bmx-missing', 'missing'], + ]); + expect(probe).toHaveBeenCalledTimes(1); + probe.mockRestore(); + }); + + it('keeps every ZMX name unknown when the shared snapshot fails', () => { + const probe = vi.spyOn(ZmxBackend, 'probeSessions').mockReturnValue({ ok: false }); + expect([...probePersistentSessions('zmx', ['bmx-a', 'bmx-b']).values()]).toEqual([ + 'unknown', + 'unknown', + ]); + expect(probe).toHaveBeenCalledTimes(1); + probe.mockRestore(); + }); +}); diff --git a/test/ready-gate.test.ts b/test/ready-gate.test.ts index a35f25a31..2ad671a83 100644 --- a/test/ready-gate.test.ts +++ b/test/ready-gate.test.ts @@ -40,7 +40,7 @@ describe('shouldArmReadyGate', () => { }); it('THE REATTACH REGRESSION: does NOT arm a persistent-backend reattach', () => { - // daemon restart re-attaches an already-running tmux/zellij/herdr Claude + // daemon restart re-attaches an already-running tmux/zellij/herdr/zmx Claude // WITHOUT re-running its bin/args → no new SessionStart hook fires. Arming // would hold the first post-recovery message until the fallback timeout. expect(shouldArmReadyGate({ ...base, willReattachPersistent: true })).toBe(false); diff --git a/test/restore-zombie-close.test.ts b/test/restore-zombie-close.test.ts index 67ac6e7b4..88c403212 100644 --- a/test/restore-zombie-close.test.ts +++ b/test/restore-zombie-close.test.ts @@ -1,5 +1,5 @@ /** - * Restore-time zombie-close decision for persistent backends (tmux/zellij/herdr). + * Restore-time zombie-close decision for persistent backends (tmux/zellij/herdr/zmx). * * On daemon restart, restoreActiveSessions() re-registers every persisted active * session and then, for persistent backends, probes whether the backing @@ -33,6 +33,11 @@ let tempDir: string; // Mutable probe verdict the mocked TmuxBackend returns this test run. const probe = vi.hoisted(() => ({ result: 'exists' as 'exists' | 'missing' | 'unknown' })); +const zmxSnapshot = vi.hoisted(() => ({ + ok: true, + sessions: [] as string[], + unhealthySessions: [] as string[], +})); // Mutable tmux-SERVER liveness the mocked TmuxBackend returns this test run. // Default 'running' so a bare 'missing' is read as a solo zombie (server up). const server = vi.hoisted(() => ({ state: 'running' as 'running' | 'down' | 'unknown' })); @@ -84,6 +89,7 @@ vi.mock('../src/core/worker-pool.js', () => ({ for (const [k, v] of map) { if (v === prev) { map.delete(k); break; } } } map.set(key, ds); + return true; }), isRelayableRealSession: (ds: any) => !!ds?.worker || !!ds?.session?.cliId || !!ds?.session?.lastCliInput, @@ -155,6 +161,22 @@ vi.mock('../src/adapters/backend/herdr-backend.js', () => ({ }, })); +vi.mock('../src/adapters/backend/zmx-backend.js', () => ({ + ZmxBackend: { + sessionName: vi.fn((id: string) => `bmx-${id.slice(0, 8)}`), + probeSession: vi.fn(() => probe.result), + probeSessions: vi.fn(() => zmxSnapshot.ok ? { + ok: true, + sessions: [...zmxSnapshot.sessions], + unhealthySessions: [...zmxSnapshot.unhealthySessions], + raw: '', + } : { ok: false }), + hasSession: vi.fn(() => probe.result === 'exists'), + serverState: vi.fn(() => server.state), + killSession: vi.fn(), + }, +})); + vi.mock('../src/core/session-discovery.js', () => ({ validateAdoptTarget: vi.fn(() => true), validateAdoptTargetState: vi.fn(() => 'alive'), @@ -169,6 +191,7 @@ vi.mock('../src/core/session-activity.js', () => ({ import { restoreActiveSessions, closeCliMismatchedSessionsForBot } from '../src/core/session-manager.js'; import { TmuxBackend } from '../src/adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../src/adapters/backend/herdr-backend.js'; +import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; import { forkWorker, closeSession } from '../src/core/worker-pool.js'; import { forkAdoptWorker } from '../src/core/worker-pool.js'; import { announceSessionRow } from '../src/core/session-activity.js'; @@ -181,6 +204,9 @@ beforeEach(() => { sessionStore.init(); wp.registry = null; probe.result = 'exists'; + zmxSnapshot.ok = true; + zmxSnapshot.sessions = []; + zmxSnapshot.unhealthySessions = []; server.state = 'running'; herdrProbe.result = 'exists'; bot.cliId = 'claude-code'; @@ -188,13 +214,14 @@ beforeEach(() => { vi.mocked(closeSession).mockClear(); vi.mocked(forkWorker).mockClear(); vi.mocked(announceSessionRow).mockClear(); + vi.mocked(ZmxBackend.probeSessions).mockClear(); }); afterEach(() => { try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ } }); -function makeActivePersistentSession(rootMessageId: string) { +function makeActivePersistentSession(rootMessageId: string, backendType: 'tmux' | 'zmx' = 'tmux') { const s = sessionStore.createSession('oc_chat1', rootMessageId, 'Topic', 'group'); s.larkAppId = 'app_test'; s.workingDir = '/tmp/proj'; @@ -204,7 +231,7 @@ function makeActivePersistentSession(rootMessageId: string) { // (Session.backendType); getSessionPersistentBackendType reads it back rather // than re-deriving from the daemon default. Stamp it so this fixture models a // genuine tmux-backed session. - s.backendType = 'tmux'; + s.backendType = backendType; sessionStore.updateSession(s); return s; // left active } @@ -273,6 +300,36 @@ describe('restoreActiveSessions — persistent-backend zombie-close decision', ( expect(forkWorker).not.toHaveBeenCalled(); }); + it('missing ZMX session stays lazy-recoverable even when another ZMX daemon is running', async () => { + probe.result = 'missing'; + server.state = 'running'; + const s = makeActivePersistentSession('om_zmx_missing', 'zmx'); + const map = new Map<string, DaemonSession>(); + wp.registry = map; + + await restoreActiveSessions(map); + + expect(closeSession).not.toHaveBeenCalled(); + expect(map.get(sessionKey('om_zmx_missing', 'app_test'))).toBeDefined(); + expect(sessionStore.getSession(s.sessionId)!.status).toBe('active'); + expect(forkWorker).not.toHaveBeenCalled(); + }); + + it('classifies multiple ZMX restore rows from one full-list snapshot', async () => { + const first = makeActivePersistentSession('om_zmx_batch_1', 'zmx'); + const second = makeActivePersistentSession('om_zmx_batch_2', 'zmx'); + const map = new Map<string, DaemonSession>(); + wp.registry = map; + + await restoreActiveSessions(map); + + expect(ZmxBackend.probeSessions).toHaveBeenCalledTimes(1); + expect(sessionStore.getSession(first.sessionId)!.status).toBe('active'); + expect(sessionStore.getSession(second.sessionId)!.status).toBe('active'); + expect(closeSession).not.toHaveBeenCalled(); + expect(forkWorker).not.toHaveBeenCalled(); + }); + it('CLI mismatch on restore → closes the active record even when the backend server is down', async () => { // This is the config-switch case: the bot now points at a different CLI, // but an old active session still has its original cliId frozen. If restore diff --git a/test/session-kanban.test.ts b/test/session-kanban.test.ts index ee23b93ef..18abde2c0 100644 --- a/test/session-kanban.test.ts +++ b/test/session-kanban.test.ts @@ -46,6 +46,7 @@ describe('session-board normalizers', () => { expect(normalizeSessionTitle(' 修复登录 bug ')).toBe('修复登录 bug'); expect(normalizeSessionTitle('第一行\n 第二行')).toBe('第一行 第二行'); expect(normalizeSessionTitle('安全\r\n标题\t\u001b[31m\u0000\u009b')).toBe('安全 标题 [31m'); + expect(normalizeSessionTitle('\x1b]52;c;payload\x07安全\t标题\u009b2J')).toBe(']52;c;payload安全 标题2J'); expect(normalizeSessionTitle('a'.repeat(300))).toHaveLength(200); expect(normalizeSessionTitle(' ')).toBeNull(); expect(normalizeSessionTitle('')).toBeNull(); diff --git a/test/session-manager-auto-recover.test.ts b/test/session-manager-auto-recover.test.ts index c5d703cef..330b3aee7 100644 --- a/test/session-manager-auto-recover.test.ts +++ b/test/session-manager-auto-recover.test.ts @@ -30,10 +30,11 @@ import { shouldAutoForkOnRestore, staggeredRecoveryFork } from '../src/core/sess import type { DaemonSession } from '../src/core/types.js'; describe('shouldAutoForkOnRestore', () => { - it('eagerly re-forks every persistent backend (tmux/herdr/zellij)', () => { + it('eagerly re-forks every persistent backend (tmux/herdr/zellij/zmx)', () => { expect(shouldAutoForkOnRestore('tmux')).toBe(true); expect(shouldAutoForkOnRestore('herdr')).toBe(true); expect(shouldAutoForkOnRestore('zellij')).toBe(true); + expect(shouldAutoForkOnRestore('zmx')).toBe(true); }); it('never eagerly forks the pty backend — it has no pane to re-attach', () => { diff --git a/test/session-resume.test.ts b/test/session-resume.test.ts index 033f22d60..637c19fc2 100644 --- a/test/session-resume.test.ts +++ b/test/session-resume.test.ts @@ -54,19 +54,26 @@ vi.mock('../src/core/worker-pool.js', () => ({ killStalePids: vi.fn(), getCurrentCliVersion: vi.fn(() => '1.0.0-test'), restoreUsageLimitRuntimeState: vi.fn(), - // Faithful: mirror the real setActiveSessionSafe — if a DIFFERENT entry - // already holds the key, evict it (close) before setting, instead of a - // bare overwrite that would mask a lingering occupant. + // Faithful compare-and-set registration: a newer/different occupant wins. setActiveSessionSafe: vi.fn(async (map: Map<string, any>, key: string, ds: any) => { const prev = map.get(key); - if (prev && prev !== ds) { - for (const [k, v] of map) { if (v === prev) { map.delete(k); break; } } - } + if (prev && prev !== ds) return false; map.set(key, ds); + return true; }), // Real predicate (same logic as production): worker OR persisted CLI markers. isRelayableRealSession: (ds: any) => !!ds?.worker || !!ds?.session?.cliId || !!ds?.session?.lastCliInput, + isDisposableCommandScratch: (ds: any) => + !ds?.worker + && !ds?.pendingRepo + && ds?.pendingPrompt === undefined + && ds?.pendingRawInput === undefined + && !ds?.adoptedFrom + && !ds?.session?.adoptedFrom + && !ds?.session?.queued + && !ds?.session?.cliId + && !ds?.session?.lastCliInput, // Faithful closeSession: actually evict the entry from the live Map (by // sessionId, as the real one does via activeSessionsRegistry) AND mark the // persisted row closed — so tests verify the eviction MECHANISM, not just @@ -140,7 +147,13 @@ vi.mock('../src/core/session-activity.js', () => ({ })); import { restoreActiveSessions, resumeSession } from '../src/core/session-manager.js'; -import { restoreUsageLimitRuntimeState, closeSession, forkAdoptWorker } from '../src/core/worker-pool.js'; +import { + closeSession, + forkAdoptWorker, + killStalePids, + restoreUsageLimitRuntimeState, + setActiveSessionSafe, +} from '../src/core/worker-pool.js'; import { TmuxBackend } from '../src/adapters/backend/tmux-backend.js'; import * as sessionStore from '../src/services/session-store.js'; import { sessionKey } from '../src/core/types.js'; @@ -369,6 +382,39 @@ describe('resumeSession', () => { } }); + it.each(['queued', 'dormant-real', 'deferred-prompt'] as const)( + 'blocks on a worker-less %s occupant instead of treating it as scratch', + async (kind) => { + const chatId = `oc_${kind}`; + const closed = makeClosedSession({ chatId, scope: 'chat' }); + const map = new Map<string, DaemonSession>(); + const occupant: any = { + session: { + sessionId: `occupant-${kind}`, + cliId: kind === 'dormant-real' ? 'claude-code' : undefined, + lastCliInput: undefined, + queued: kind === 'queued', + }, + worker: null, + pendingRepo: false, + pendingPrompt: kind === 'deferred-prompt' ? '' : undefined, + chatId, + scope: 'chat', + larkAppId: 'app_test', + }; + map.set(sessionKey(chatId, 'app_test'), occupant); + + const r = await resumeSession(closed.sessionId, map); + expect(r).toEqual({ + ok: false, + error: 'anchor_occupied', + activeSessionId: `occupant-${kind}`, + }); + expect(closeSession).not.toHaveBeenCalled(); + expect(map.get(sessionKey(chatId, 'app_test'))).toBe(occupant); + }, + ); + it('does NOT block on a persisted scratch sibling (no cliId / lastCliInput) — closes it and resumes', async () => { const closed = makeClosedSession({ rootMessageId: 'om_scratch_thread' }); // Store-only scratch sibling: active, same anchor, but never ran a CLI. @@ -481,6 +527,173 @@ describe('resumeSession', () => { expect(map.size).toBe(3); }); + it('keeps the row closed when a concurrent close cancels resume registration', async () => { + const closed = makeClosedSession({ rootMessageId: 'om_cancel_resume' }); + vi.mocked(setActiveSessionSafe).mockImplementationOnce(async (_map, _key, ds) => { + sessionStore.closeSession(ds.session.sessionId); + return false; + }); + + const r = await resumeSession(closed.sessionId, new Map()); + expect(r).toEqual({ ok: false, error: 'resume_cancelled' }); + expect(sessionStore.getSession(closed.sessionId)?.status).toBe('closed'); + }); + + it('restores a real session ahead of an older same-anchor command scratch', async () => { + const scratch = sessionStore.createSession('oc_collision', 'om_collision', '/help'); + scratch.larkAppId = 'app_test'; + scratch.scope = 'thread'; + scratch.cliId = undefined; + scratch.lastCliInput = undefined; + sessionStore.updateSession(scratch); + + const real = sessionStore.createSession('oc_collision', 'om_collision', 'real work'); + real.larkAppId = 'app_test'; + real.scope = 'thread'; + real.cliId = 'claude-code'; + real.lastCliInput = 'continue'; + sessionStore.updateSession(real); + + const map = new Map<string, DaemonSession>(); + wp.registry = map; + await restoreActiveSessions(map); + + expect(map.get(sessionKey('om_collision', 'app_test'))?.session.sessionId).toBe(real.sessionId); + expect(sessionStore.getSession(scratch.sessionId)?.status).toBe('closed'); + expect(sessionStore.getSession(real.sessionId)?.status).toBe('active'); + }); + + it('never lets startup restore overwrite a fresh runtime occupant', async () => { + const persisted = sessionStore.createSession('oc_runtime', 'om_runtime', 'persisted work'); + persisted.larkAppId = 'app_test'; + persisted.scope = 'thread'; + persisted.cliId = 'claude-code'; + sessionStore.updateSession(persisted); + + const fresh: any = { + session: { sessionId: 'fresh-runtime', status: 'active', cliId: 'claude-code' }, + worker: { killed: false }, + larkAppId: 'app_test', + chatId: 'oc_runtime', + scope: 'thread', + }; + const map = new Map<string, DaemonSession>([ + [sessionKey('om_runtime', 'app_test'), fresh], + ]); + wp.registry = map; + + await restoreActiveSessions(map); + + expect(map.get(sessionKey('om_runtime', 'app_test'))).toBe(fresh); + expect(sessionStore.getSession(persisted.sessionId)?.status).toBe('closed'); + }); + + it('preserves a different live object for the same session id before stale-PID cleanup', async () => { + const persisted = sessionStore.createSession('oc_runtime_same', 'om_runtime_same', 'persisted work'); + persisted.larkAppId = 'app_test'; + persisted.scope = 'thread'; + persisted.cliId = 'claude-code'; + persisted.pid = 54_321; + sessionStore.updateSession(persisted); + + const live: any = { + session: { ...persisted }, + worker: { killed: false }, + larkAppId: 'app_test', + chatId: persisted.chatId, + chatType: 'group', + scope: 'thread', + }; + const map = new Map<string, DaemonSession>([ + [sessionKey('om_runtime_same', 'app_test'), live], + ]); + wp.registry = map; + const processKill = vi.spyOn(process, 'kill').mockImplementation((() => true) as typeof process.kill); + vi.mocked(killStalePids).mockImplementationOnce((rows: any[], runtime?: ReadonlyMap<string, any>) => { + const runtimeIds = new Set([...(runtime?.values() ?? [])].map(ds => ds.session.sessionId)); + for (const row of rows) { + if (row.pid && !runtimeIds.has(row.sessionId)) process.kill(row.pid, 0); + } + }); + + await restoreActiveSessions(map); + + expect(killStalePids).toHaveBeenCalledWith(expect.any(Array), map); + expect(processKill).not.toHaveBeenCalled(); + expect(map.get(sessionKey('om_runtime_same', 'app_test'))).toBe(live); + expect(closeSession).not.toHaveBeenCalledWith(persisted.sessionId); + processKill.mockRestore(); + }); + + it('does not probe-close a fresh persistent runtime session registered during restore', async () => { + const persisted = sessionStore.createSession('oc_restore_seed', 'om_restore_seed', 'restore seed'); + persisted.larkAppId = 'app_test'; + persisted.scope = 'thread'; + persisted.cliId = 'claude-code'; + sessionStore.updateSession(persisted); + + const fresh: any = { + session: { + sessionId: 'fresh-live-persistent', + status: 'active', + backendType: 'tmux', + cliId: 'claude-code', + }, + worker: { killed: false }, + larkAppId: 'app_test', + chatId: 'oc_fresh_runtime', + chatType: 'group', + scope: 'thread', + }; + const freshKey = sessionKey('om_fresh_runtime', 'app_test'); + const map = new Map<string, DaemonSession>(); + wp.registry = map; + + // Inject the dispatcher-created session at the exact await boundary of + // startup CAS registration. It is not part of the disk snapshot and + // therefore must not enter restore's backing probe/zombie-close pass. + vi.mocked(setActiveSessionSafe).mockImplementationOnce(async (target, key, ds) => { + target.set(freshKey, fresh); + target.set(key, ds); + return true; + }); + + await restoreActiveSessions(map); + + expect(map.get(freshKey)).toBe(fresh); + expect(fresh.session.status).toBe('active'); + expect(closeSession).not.toHaveBeenCalledWith(fresh.session.sessionId); + }); + + it('does not probe-close a restored persistent session woken during registration', async () => { + const persisted = sessionStore.createSession('oc_woken_restore', 'om_woken_restore', 'woken restore'); + persisted.larkAppId = 'app_test'; + persisted.scope = 'thread'; + persisted.cliId = 'claude-code'; + persisted.backendType = 'tmux'; + sessionStore.updateSession(persisted); + + const key = sessionKey('om_woken_restore', 'app_test'); + const map = new Map<string, DaemonSession>(); + wp.registry = map; + + // The CAS publishes ds synchronously, then its Promise yields. A real + // inbound message can fork this exact object before restore resumes; its + // tmux pane may not exist yet and must not be classified as a zombie. + vi.mocked(setActiveSessionSafe).mockImplementationOnce(async (target, restoreKey, ds) => { + target.set(restoreKey, ds); + ds.worker = { killed: false }; + return true; + }); + + await restoreActiveSessions(map); + + expect(map.get(key)?.session.sessionId).toBe(persisted.sessionId); + expect(map.get(key)?.worker).toBeTruthy(); + expect(sessionStore.getSession(persisted.sessionId)?.status).toBe('active'); + expect(closeSession).not.toHaveBeenCalledWith(persisted.sessionId); + }); + it('restores usage-limit runtime state for active sessions after daemon restart', async () => { const s = sessionStore.createSession('oc_chat_limit', 'om_limit', 'Limited topic'); s.larkAppId = 'app_test'; diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 7315ec950..a4bfd9273 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -59,6 +59,7 @@ import { init, createSession, getSession, + getOwnedSession, listSessions, closeSession, updateSession, @@ -90,6 +91,15 @@ afterEach(() => { // ─── init() ─────────────────────────────────────────────────────────────── describe('init()', () => { + it('keeps cross-file discovery read-only and exposes owner-scoped lookup separately', () => { + init('app-A'); + const ownedByA = createSession('chat1', 'root1', 'Bot A'); + + init('app-B'); + expect(getSession(ownedByA.sessionId)?.sessionId).toBe(ownedByA.sessionId); + expect(getOwnedSession(ownedByA.sessionId)).toBeUndefined(); + }); + it('should create the data directory on first operation if it does not exist', () => { const subDir = join(tempDir, 'nested', 'data'); tempDir = subDir; diff --git a/test/session-title.test.ts b/test/session-title.test.ts index f42b5deb9..c2ae79ece 100644 --- a/test/session-title.test.ts +++ b/test/session-title.test.ts @@ -10,6 +10,7 @@ import { dashboardEventBus, type DashboardEvent } from '../src/core/dashboard-ev import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitlePrompt, + normalizeSessionTitleSource, updateSessionTitle, } from '../src/core/session-title.js'; @@ -111,29 +112,43 @@ describe('extractBotmuxLarkNativeSessionTitlePrompt', () => { describe('updateSessionTitle', () => { beforeEach(() => vi.clearAllMocks()); - it('normalizes, persists, and publishes one dashboard patch', () => { + it('normalizes, persists, and publishes one dashboard patch with title metadata', () => { const session = makeSession(); session.nativeSessionTitleAwaitingContent = true; const events: DashboardEvent[] = []; const unsubscribe = dashboardEventBus.subscribe(event => events.push(event)); + let result; try { - expect(updateSessionTitle(session, ' First line\n Second line ')).toEqual({ - ok: true, - title: 'First line Second line', - }); + result = updateSessionTitle(session, ' First line\n Second line ', 'agent'); } finally { unsubscribe(); } - expect(session.title).toBe('First line Second line'); + expect(result).toMatchObject({ + ok: true, + title: 'First line Second line', + source: 'agent', + }); + expect(session).toMatchObject({ + title: 'First line Second line', + titleSource: 'agent', + titleUpdatedAt: expect.any(String), + }); expect(session.nativeSessionTitle).toBe('First line Second line'); expect(session.nativeSessionTitleUserDefined).toBe(true); expect(session.nativeSessionTitleAwaitingContent).toBeUndefined(); expect(sessionStore.updateSession).toHaveBeenCalledWith(session); expect(events).toEqual([{ type: 'session.update', - body: { sessionId: 'session-1', patch: { title: 'First line Second line' } }, + body: { + sessionId: 'session-1', + patch: { + title: 'First line Second line', + titleUpdatedAt: session.titleUpdatedAt, + titleSource: 'agent', + }, + }, }]); }); @@ -143,7 +158,10 @@ describe('updateSessionTitle', () => { const unsubscribe = dashboardEventBus.subscribe(event => events.push(event)); try { - expect(updateSessionTitle(session, ' ')).toEqual({ ok: false, error: 'bad_title' }); + expect(updateSessionTitle(session, ' ', 'dashboard')).toEqual({ + ok: false, + error: 'bad_title', + }); } finally { unsubscribe(); } @@ -153,6 +171,11 @@ describe('updateSessionTitle', () => { expect(events).toEqual([]); }); + it('normalizes untrusted source labels to the caller fallback', () => { + expect(normalizeSessionTitleSource('agent', 'dashboard')).toBe('agent'); + expect(normalizeSessionTitleSource('spoofed', 'dashboard')).toBe('dashboard'); + }); + it('keeps the canonical dashboard title separate from temporary TUI prompt labels', async () => { const { readFileSync } = await import('node:fs'); const workerPoolSource = readFileSync(new URL('../src/core/worker-pool.ts', import.meta.url), 'utf8'); diff --git a/test/tmux-reattach-backend.test.ts b/test/tmux-reattach-backend.test.ts index c6348ba3f..dbc7f0e08 100644 --- a/test/tmux-reattach-backend.test.ts +++ b/test/tmux-reattach-backend.test.ts @@ -38,10 +38,22 @@ vi.mock('../src/adapters/backend/zellij-backend.js', () => ({ }, })); +vi.mock('../src/adapters/backend/zmx-backend.js', () => ({ + ZmxBackend: class MockZmxBackend { + static sessionName = vi.fn((id: string) => `bmx-${id.slice(0, 8)}`); + static hasSession = vi.fn(() => false); + constructor(public sessionName: string, public opts?: unknown) {} + }, +})); + import { TmuxBackend } from '../src/adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../src/adapters/backend/herdr-backend.js'; import { ZellijBackend } from '../src/adapters/backend/zellij-backend.js'; -import { selectSessionBackend } from '../src/adapters/backend/session-backend-selector.js'; +import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; +import { + backendSandboxCompatibilityError, + selectSessionBackend, +} from '../src/adapters/backend/session-backend-selector.js'; describe('selectSessionBackend', () => { beforeEach(() => { @@ -230,4 +242,44 @@ describe('selectSessionBackend', () => { expect(selected.backend.constructor.name).toBe('MockZellijBackend'); expect((selected.backend as any).opts).toEqual({ ownsSession: true, isReattach: false }); }); + + it('uses zmx attach as a direct PTY backend', () => { + vi.mocked(ZmxBackend.hasSession).mockReturnValue(true); + + const selected = selectSessionBackend({ sessionId: '9cfa0024-197d-4781-845b-c541dceb8980', backendType: 'zmx' }); + + expect(selected.isZellijMode).toBe(false); + expect(selected.isTmuxMode).toBe(false); + expect(selected.isPipeMode).toBe(false); + expect(selected.backend.constructor.name).toBe('MockZmxBackend'); + expect((selected.backend as any).opts).toEqual({ ownsSession: true, isReattach: true }); + }); +}); + +describe('backendSandboxCompatibilityError', () => { + it('fails closed for either ZMX filesystem-isolation mode', () => { + expect(backendSandboxCompatibilityError({ + backendType: 'zmx', + fileSandboxRequested: true, + effectiveReadIsolationRequested: false, + })).toMatch(/does not support/); + expect(backendSandboxCompatibilityError({ + backendType: 'zmx', + fileSandboxRequested: false, + effectiveReadIsolationRequested: true, + })).toMatch(/does not support/); + }); + + it('allows unsandboxed ZMX, including a Linux legacy readIsolation no-op', () => { + expect(backendSandboxCompatibilityError({ + backendType: 'zmx', + fileSandboxRequested: false, + effectiveReadIsolationRequested: false, + })).toBeUndefined(); + expect(backendSandboxCompatibilityError({ + backendType: 'tmux', + fileSandboxRequested: true, + effectiveReadIsolationRequested: true, + })).toBeUndefined(); + }); }); diff --git a/test/transfer-session.test.ts b/test/transfer-session.test.ts index 6d8264a2c..193e3219e 100644 --- a/test/transfer-session.test.ts +++ b/test/transfer-session.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('../src/services/session-store.js', () => ({ updateSession: vi.fn(), getSession: vi.fn(), + getOwnedSession: vi.fn(), closeSession: vi.fn(), })); @@ -42,12 +43,38 @@ vi.mock('../src/im/lark/client.js', () => ({ MessageWithdrawnError: class extends Error {}, })); +const unsubscribeDocFileMock = vi.fn(); +const removeCommentReactionMock = vi.fn(); +vi.mock('../src/im/lark/doc-comment.js', () => ({ + replyToDocComment: vi.fn(), + chunkCommentText: vi.fn(() => []), + unsubscribeDocFile: (...a: any[]) => unsubscribeDocFileMock(...a), + removeCommentReaction: (...a: any[]) => removeCommentReactionMock(...a), +})); + +const listDocSubscriptionsMock = vi.fn(() => [] as Array<{ fileToken: string; fileType: string }>); +const removeDocSubscriptionMock = vi.fn(); +vi.mock('../src/services/doc-subs-store.js', () => ({ + listDocSubscriptionsForSession: (...a: any[]) => listDocSubscriptionsMock(...a), + removeDocSubscription: (...a: any[]) => removeDocSubscriptionMock(...a), +})); + // transferSession accepts forkWorker/killWorker overrides for testability — // real forkWorker would actually spawn a child process and attach tmux. const forkWorkerSpy = vi.fn(); const killWorkerSpy = vi.fn(); -import { transferSession, setActiveSessionsRegistry, setActiveSessionSafe } from '../src/core/worker-pool.js'; +import { + closeSession, + destroyUnregisteredPersistentBacking, + forkAdoptWorker, + forkWorker, + transferSession, + setActiveSessionsRegistry, + setActiveSessionIfActive, + setActiveSessionSafe, + rollbackRejectedSessionAndGetWinner, +} from '../src/core/worker-pool.js'; import * as sessionStore from '../src/services/session-store.js'; import { dashboardEventBus } from '../src/core/dashboard-events.js'; import { sessionKey } from '../src/core/types.js'; @@ -427,16 +454,18 @@ describe('transferSession', () => { rootMessageId: 'om_relay_cmd_msg', scope: 'chat', title: '/relay', + cliId: undefined, + lastCliInput: undefined, }, worker: null, // command-time placeholder, no real worker chatId: 'oc_target', scope: 'chat', }); registry.set(sessionKey('oc_target', 'cli_app_test'), scratchDs); - // getSession is consulted by closeSession to decide whether to mark + // getOwnedSession is consulted by closeSession to decide whether to mark // the store row closed — return a status='active' record so the store // close path fires. - vi.mocked(sessionStore.getSession).mockImplementation((sid: string) => + vi.mocked(sessionStore.getOwnedSession).mockImplementation((sid: string) => sid === 'scratch-relay-cmd' ? ({ ...scratchDs.session, status: 'active' }) as any : undefined, ); @@ -448,6 +477,40 @@ describe('transferSession', () => { expect(registry.get(sessionKey('oc_target', 'cli_app_test'))).toBe(movingDs); }); + it.each(['dormant-real', 'pending-repo', 'queued', 'deferred-prompt'] as const)( + 'never evicts a worker-less %s target as disposable scratch', + async (kind) => { + const movingDs = makeDs(); + registry.set(sessionKey('om_source_root', 'cli_app_test'), movingDs); + + const target = makeDs({ + worker: null, + pendingRepo: kind === 'pending-repo', + pendingPrompt: kind === 'deferred-prompt' ? '' : undefined, + session: { + ...movingDs.session, + sessionId: `protected-${kind}`, + chatId: 'oc_target', + rootMessageId: 'om_target_protected', + scope: 'chat', + cliId: kind === 'dormant-real' ? 'claude-code' : undefined, + lastCliInput: undefined, + queued: kind === 'queued', + }, + chatId: 'oc_target', + scope: 'chat', + }); + registry.set(sessionKey('oc_target', 'cli_app_test'), target); + + expect(await callTransfer(movingDs.session.sessionId, 'oc_target', 'om_M1_target')).toEqual({ + ok: false, + error: 'target_chat_has_session', + }); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); + expect(registry.get(sessionKey('oc_target', 'cli_app_test'))).toBe(target); + }, + ); + it('allows transfer when target chat has only thread-scope sessions (no chat-scope collision)', async () => { const movingDs = makeDs(); registry.set(sessionKey('om_source_root', 'cli_app_test'), movingDs); @@ -534,6 +597,64 @@ describe('transferSession', () => { expect(ds.session.chatId).toBe('oc_target'); }); + it('aborts without rewriting routing when the source closes during card freeze', async () => { + const ds = makeDs(); + const sourceKey = sessionKey('om_source_root', 'cli_app_test'); + registry.set(sourceKey, ds); + + let releaseFreeze!: () => void; + updateMessageMock.mockImplementationOnce(() => new Promise<void>((resolve) => { + releaseFreeze = resolve; + })); + + const moving = callTransfer(ds.session.sessionId, 'oc_target', 'om_M1_target'); + expect(updateMessageMock).toHaveBeenCalledTimes(1); + + // Simulate dashboard/CLI close while updateMessage is in flight. + ds.session.status = 'closed'; + registry.delete(sourceKey); + releaseFreeze(); + + const r = await moving; + expect(r).toEqual({ ok: false, error: 'session_not_active' }); + expect(killWorkerSpy).not.toHaveBeenCalled(); + expect(forkWorkerSpy).not.toHaveBeenCalled(); + expect(ds.chatId).toBe('oc_source'); + expect(registry.has(sessionKey('oc_target', 'cli_app_test'))).toBe(false); + }); + + it('does not evict a target session created while the source card is freezing', async () => { + const ds = makeDs(); + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + + let releaseFreeze!: () => void; + updateMessageMock.mockImplementationOnce(() => new Promise<void>((resolve) => { + releaseFreeze = resolve; + })); + const moving = callTransfer(ds.session.sessionId, 'oc_target', 'om_M1_target'); + + const target = makeDs({ + worker: { killed: false } as any, + session: { + ...makeDs().session, + sessionId: 'target-created-during-freeze', + chatId: 'oc_target', + rootMessageId: 'om_target_existing', + scope: 'chat', + }, + chatId: 'oc_target', + scope: 'chat', + }); + registry.set(sessionKey('oc_target', 'cli_app_test'), target); + releaseFreeze(); + + const r = await moving; + expect(r).toEqual({ ok: false, error: 'target_chat_has_session' }); + expect(killWorkerSpy).not.toHaveBeenCalled(); + expect(registry.get(sessionKey('oc_target', 'cli_app_test'))).toBe(target); + expect(registry.get(sessionKey('om_source_root', 'cli_app_test'))).toBe(ds); + }); + it('does not call updateMessage when there is no source-chat card to freeze', async () => { const ds = makeDs({ streamCardId: undefined, session: { ...makeDs().session, streamCardId: undefined, @@ -599,25 +720,17 @@ describe('setActiveSessionSafe', () => { } as DaemonSession; } - it('closes the prior occupant when the key is already held by a different session', async () => { - // Same-key collision: this is the second half of the scratch-ghost fix. - // restoreActiveSessions iterates two on-disk active sessions resolving - // to the same chat-scope key. Bare Map.set silently drops the loser; - // setActiveSessionSafe closes it instead so its store row doesn't stay - // status='active' as a ghost. + it('preserves the current occupant when a different session tries to register', async () => { const prevDs = makeSimpleDs('prev-sess'); const newDs = makeSimpleDs('new-sess'); - vi.mocked(sessionStore.getSession).mockImplementation((sid: string) => - sid === 'prev-sess' ? ({ ...prevDs.session, status: 'active' }) as any : undefined, - ); const key = sessionKey('oc_c', 'cli_app_test'); registry.set(key, prevDs); - await setActiveSessionSafe(registry, key, newDs); + expect(await setActiveSessionSafe(registry, key, newDs)).toBe(false); - expect(registry.get(key)).toBe(newDs); - expect(sessionStore.closeSession).toHaveBeenCalledWith('prev-sess'); + expect(registry.get(key)).toBe(prevDs); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); }); it('is a no-op when the key already holds the same session instance', async () => { @@ -640,4 +753,249 @@ describe('setActiveSessionSafe', () => { expect(registry.get(key)).toBe(ds); expect(sessionStore.closeSession).not.toHaveBeenCalled(); }); + + it('refuses to register a session closed while its creator was awaiting', () => { + const ds = makeSimpleDs('closed-before-register'); + ds.session.status = 'closed'; + const key = sessionKey('oc_c', 'cli_app_test'); + + expect(setActiveSessionIfActive(registry, key, ds)).toBe(false); + expect(registry.has(key)).toBe(false); + }); + + it('rolls back a rejected row before returning the latest active routing winner', async () => { + const rejected = makeSimpleDs('rejected-sess'); + const staleWinner = makeSimpleDs('stale-winner'); + const latestWinner = makeSimpleDs('latest-winner'); + const key = sessionKey('oc_c', 'cli_app_test'); + registry.set(key, staleWinner); + const rollback = vi.fn(async (sessionId: string) => { + expect(sessionId).toBe(rejected.session.sessionId); + // Simulate another creator replacing the key while close cleanup yields. + registry.set(key, latestWinner); + }); + + await expect( + rollbackRejectedSessionAndGetWinner(registry, key, rejected, rollback), + ).resolves.toBe(latestWinner); + expect(rollback).toHaveBeenCalledOnce(); + }); + + it('does not reroute an inbound event to a winner that closed during rollback', async () => { + const rejected = makeSimpleDs('rejected-sess'); + const closedWinner = makeSimpleDs('closed-winner'); + const key = sessionKey('oc_c', 'cli_app_test'); + registry.set(key, closedWinner); + + const winner = await rollbackRejectedSessionAndGetWinner( + registry, + key, + rejected, + async () => { closedWinner.session.status = 'closed'; }, + ); + + expect(winner).toBeUndefined(); + }); + +}); + +describe('closeSession concurrency', () => { + beforeEach(() => { + vi.clearAllMocks(); + listDocSubscriptionsMock.mockReturnValue([]); + unsubscribeDocFileMock.mockResolvedValue(undefined); + removeCommentReactionMock.mockResolvedValue(undefined); + }); + + it('commits closed state before a slow document unsubscribe can yield', async () => { + const registry = new Map<string, DaemonSession>(); + const ds = makeDs({ + worker: { + killed: false, + exitCode: null, + signalCode: null, + send: vi.fn(), + once: vi.fn(), + kill: vi.fn(), + } as any, + }); + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + setActiveSessionsRegistry(registry); + + let stored = { ...ds.session } as Session; + vi.mocked(sessionStore.getOwnedSession).mockImplementation((sid: string) => + sid === ds.session.sessionId ? stored : undefined, + ); + vi.mocked(sessionStore.closeSession).mockImplementation(() => { + stored = { ...stored, status: 'closed', closedAt: new Date().toISOString() }; + }); + listDocSubscriptionsMock.mockReturnValue([{ fileToken: 'doc-token', fileType: 'docx' }]); + + let releaseUnsubscribe!: () => void; + unsubscribeDocFileMock.mockImplementation(() => new Promise<void>((resolve) => { + releaseUnsubscribe = resolve; + })); + + const closing = closeSession(ds.session.sessionId); + + // closeSession has reached its first await, but all authoritative state is + // already closed. A continuation that captured `ds` cannot resurrect it. + expect(ds.session.status).toBe('closed'); + expect(registry.has(sessionKey('om_source_root', 'cli_app_test'))).toBe(false); + expect(sessionStore.closeSession).toHaveBeenCalledWith(ds.session.sessionId); + const key = sessionKey('om_source_root', 'cli_app_test'); + registry.set(key, ds); // stale async continuation re-published the same object + expect(() => forkWorker(ds, 'late message')).not.toThrow(); + expect(registry.has(key)).toBe(false); + + ds.adoptedFrom = { + source: 'tmux', + tmuxTarget: '0:0.0', + originalCliPid: 42, + cwd: '/tmp/project', + }; + registry.set(key, ds); + expect(() => forkAdoptWorker(ds)).not.toThrow(); + expect(registry.has(key)).toBe(false); + + releaseUnsubscribe(); + await closing; + expect(removeDocSubscriptionMock).toHaveBeenCalledWith( + expect.any(String), + 'cli_app_test', + 'doc-token', + ); + }); + + it('tears down only explicitly stamped bot-owned persistent backings', () => { + const kill = vi.fn(); + const base = makeDs().session; + const zmx = { ...base, backendType: 'zmx' as const }; + expect(destroyUnregisteredPersistentBacking(zmx, kill)).toBe(true); + expect(kill).toHaveBeenCalledWith('zmx', 'bmx-sess-abc'); + + kill.mockClear(); + expect(destroyUnregisteredPersistentBacking({ ...zmx, adoptedFrom: { + source: 'tmux', tmuxTarget: '0:0.0', originalCliPid: 42, cwd: '/tmp', + } }, kill)).toBe(false); + expect(destroyUnregisteredPersistentBacking({ ...zmx, queued: true }, kill)).toBe(false); + expect(destroyUnregisteredPersistentBacking({ ...zmx, backendType: 'pty' }, kill)).toBe(false); + expect(kill).not.toHaveBeenCalled(); + }); + + it('removes every binding while only remotely unsubscribing legacy/API-managed records', async () => { + const registry = new Map<string, DaemonSession>(); + const ds = makeDs(); + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + setActiveSessionsRegistry(registry); + vi.mocked(sessionStore.getOwnedSession).mockReturnValue(ds.session); + listDocSubscriptionsMock.mockReturnValue([ + { fileToken: 'legacy-doc', fileType: 'docx' }, + { fileToken: 'api-doc', fileType: 'docx', managedBy: 'subscribe-lark-doc' }, + { fileToken: 'watch-doc', fileType: 'docx', managedBy: 'watch-comment' }, + ] as any); + // A remote API failure must not retain the local binding or stop cleanup + // of the other records. + unsubscribeDocFileMock.mockRejectedValueOnce(new Error('remote unavailable')); + + await closeSession(ds.session.sessionId); + + expect(unsubscribeDocFileMock.mock.calls.map(call => call[1].fileToken)).toEqual([ + 'legacy-doc', + 'api-doc', + ]); + expect(removeDocSubscriptionMock.mock.calls.map(call => call[2])).toEqual([ + 'legacy-doc', + 'api-doc', + 'watch-doc', + ]); + }); + + it('clears every per-turn doc target before awaiting reaction cleanup', async () => { + const registry = new Map<string, DaemonSession>(); + const ds = makeDs(); + const target = { + fileToken: 'doc-token', + fileType: 'docx', + commentId: 'comment-1', + turnId: 'turn-1', + replyId: 'reply-1', + reactionId: 'reaction-1', + }; + ds.docCommentTurns = new Map([['turn-1', target]]); + ds.session.docCommentTargets = { 'turn-1': target }; + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + setActiveSessionsRegistry(registry); + vi.mocked(sessionStore.getOwnedSession).mockReturnValue(ds.session); + + let releaseReaction!: () => void; + removeCommentReactionMock.mockImplementation(() => new Promise<void>((resolve) => { + releaseReaction = resolve; + })); + + const closing = closeSession(ds.session.sessionId); + + expect(ds.session.status).toBe('closed'); + expect(registry.size).toBe(0); + expect(ds.docCommentTurns).toBeUndefined(); + expect(ds.session.docCommentTargets).toBeUndefined(); + expect(sessionStore.closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(dashboardEventBus.publish).toHaveBeenCalledWith(expect.objectContaining({ + type: 'session.update', + })); + + releaseReaction(); + await closing; + expect(removeCommentReactionMock).toHaveBeenCalledWith( + 'cli_app_test', + { fileToken: 'doc-token', fileType: 'docx' }, + 'comment-1', + 'reply-1', + 'reaction-1', + ); + }); + + it('persists stale per-turn cleanup when re-closing an already-closed owned row', async () => { + setActiveSessionsRegistry(new Map()); + const stored = makeDs().session; + stored.status = 'closed'; + stored.docCommentTargets = { + 'turn-stale': { + fileToken: 'doc-token', + fileType: 'docx', + commentId: 'comment-1', + turnId: 'turn-stale', + replyId: 'reply-1', + reactionId: 'reaction-1', + }, + }; + vi.mocked(sessionStore.getOwnedSession).mockReturnValue(stored); + + await closeSession(stored.sessionId); + + expect(stored.docCommentTargets).toBeUndefined(); + expect(sessionStore.updateSession).toHaveBeenCalledWith(stored); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); + expect(removeCommentReactionMock).toHaveBeenCalledTimes(1); + }); + + it('does not treat another bot file found by read-only lookup as owned close state', async () => { + setActiveSessionsRegistry(new Map()); + vi.mocked(sessionStore.getOwnedSession).mockReturnValue(undefined); + vi.mocked(sessionStore.getSession).mockReturnValue({ + ...makeDs().session, + sessionId: 'foreign-session', + larkAppId: 'other_app', + }); + + await expect(closeSession('foreign-session')).resolves.toEqual({ + ok: true, + alreadyClosed: true, + known: false, + }); + + expect(sessionStore.getSession).not.toHaveBeenCalled(); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); + expect(dashboardEventBus.publish).not.toHaveBeenCalled(); + }); }); diff --git a/test/trigger-session-root-message.test.ts b/test/trigger-session-root-message.test.ts index e60f003cc..54f987379 100644 --- a/test/trigger-session-root-message.test.ts +++ b/test/trigger-session-root-message.test.ts @@ -57,6 +57,12 @@ vi.mock('../src/core/worker-pool.js', () => ({ return true; }, getCurrentCliVersion: vi.fn(() => 'test-cli-version'), + setActiveSessionIfActive: (map: Map<string, any>, key: string, ds: any) => { + if (map.has(key) && map.get(key) !== ds) return false; + map.set(key, ds); + return true; + }, + closeSession: vi.fn(async () => ({ ok: true, alreadyClosed: false, known: true })), })); const mockRememberLastCliInput = vi.fn(); diff --git a/test/worker-queue-merge.test.ts b/test/worker-queue-merge.test.ts index e05d1684e..05196bd26 100644 --- a/test/worker-queue-merge.test.ts +++ b/test/worker-queue-merge.test.ts @@ -3,6 +3,7 @@ import { mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, + resetPreservingPendingCliInputs, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, @@ -240,3 +241,32 @@ describe('durable turn queue boundary', () => { expect(ordinaryTail).toEqual([{ content: 'legacy-1', turnId: 't1' }]); }); }); + +describe('resetPreservingPendingCliInputs', () => { + it('restores unwritten prompts after a reset clears the live queue', () => { + const pending = [ + { content: 'initial hello', turnId: 't1' }, + { content: 'follow-up', turnId: 't2' }, + ]; + + resetPreservingPendingCliInputs(pending, () => { + pending.length = 0; + }); + + expect(pending).toEqual([ + { content: 'initial hello', turnId: 't1' }, + { content: 'follow-up', turnId: 't2' }, + ]); + }); + + it('keeps the snapshot ahead of items produced during reset and restores on throw', () => { + const pending = [{ content: 'queued' }]; + + expect(() => resetPreservingPendingCliInputs(pending, () => { + pending.push({ content: 'reset-added' }); + throw new Error('reset failed'); + })).toThrow('reset failed'); + + expect(pending.map(item => item.content)).toEqual(['queued', 'reset-added']); + }); +}); diff --git a/test/worker-ready-display-mode.test.ts b/test/worker-ready-display-mode.test.ts index 4bda8c9f8..a51e93875 100644 --- a/test/worker-ready-display-mode.test.ts +++ b/test/worker-ready-display-mode.test.ts @@ -266,6 +266,34 @@ describe('Worker ready: set_display_mode re-sync', () => { expect(sessionReplyMock).not.toHaveBeenCalled(); }); + it('doc-native session never posts a TUI prompt card to the virtual doc: chat id', async () => { + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + scope: 'chat', + chatId: 'doc:doc_token_123', + session: { + ...makeDs().session, + scope: 'chat', + chatId: 'doc:doc_token_123', + rootMessageId: 'doc:doc_token_123', + }, + worker: fakeWorker, + }); + + __testOnly_setupWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { + type: 'tui_prompt', + description: 'Approve command?', + options: [{ text: 'Yes', selected: false }], + multiSelect: false, + turnId: 'turn-doc', + }); + await flush(); + + expect(sessionReplyMock).not.toHaveBeenCalled(); + expect(ds.tuiPromptCardId).toBeUndefined(); + }); + it('POST path sends set_display_mode when displayMode is screenshot', async () => { const fakeWorker = makeFakeWorker(); // streamCardPending = true forces POST path (no existing card to PATCH) diff --git a/test/worker-suspend.test.ts b/test/worker-suspend.test.ts index bcbe29287..ac760c2b7 100644 --- a/test/worker-suspend.test.ts +++ b/test/worker-suspend.test.ts @@ -41,7 +41,7 @@ const CLI_IDS: CliId[] = [ 'copilot', ]; -const BACKENDS: BackendType[] = ['pty', 'tmux', 'herdr', 'zellij']; +const BACKENDS: BackendType[] = ['pty', 'tmux', 'herdr', 'zellij', 'zmx']; describe('worker suspend backend gating', () => { it.each(BACKENDS)('classifies %s correctly', (backend) => { @@ -52,6 +52,7 @@ describe('worker suspend backend gating', () => { expect(isSuspendableBackendType('tmux')).toBe(true); expect(isSuspendableBackendType('herdr')).toBe(true); expect(isSuspendableBackendType('zellij')).toBe(true); + expect(isSuspendableBackendType('zmx')).toBe(true); expect(isSuspendableBackendType('pty')).toBe(false); }); }); diff --git a/test/zmx-backend-helpers.test.ts b/test/zmx-backend-helpers.test.ts new file mode 100644 index 000000000..dddda771d --- /dev/null +++ b/test/zmx-backend-helpers.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { + ...actual, + execFileSync: vi.fn(), + }; +}); + +import { execFileSync } from 'node:child_process'; +import { + buildFreshAttachArgs, + buildReattachArgs, + buildZmxLaunchFiles, + findSessionPid, + parseZmxList, + parseZmxShortList, + terminalOscColorQueryReplies, + tmuxKeyToBytes, + zmxControlEnv, + ZmxBackend, +} from '../src/adapters/backend/zmx-backend.js'; +import { parseZmxVersion, probeZmxFunctional, zmxEnv } from '../src/setup/ensure-zmx.js'; + +const execFileSyncMock = vi.mocked(execFileSync); + +beforeEach(() => { + execFileSyncMock.mockReset(); +}); + +describe('zmx env/probe helpers', () => { + it('strips inherited session vars but preserves the socket dir', () => { + const env = zmxEnv({ + PATH: '/bin', + ZMX_SESSION: 'parent', + ZMX_SESSION_PREFIX: 'dev-', + ZMX_DIR: '/tmp/zmx', + } as any); + + expect(env.ZMX_SESSION).toBeUndefined(); + expect(env.ZMX_SESSION_PREFIX).toBeUndefined(); + expect(env.ZMX_DIR).toBe('/tmp/zmx'); + expect(env.PATH).toContain('/bin'); + expect(env.PATH).toContain('.local/share/mise/shims'); + }); + + it('requires both version and list to succeed', () => { + execFileSyncMock.mockReturnValueOnce('zmx 0.6.0\n' as any); + execFileSyncMock.mockReturnValueOnce('' as any); + + expect(probeZmxFunctional()).toEqual({ ok: true, version: 'zmx 0.6.0' }); + expect(execFileSyncMock).toHaveBeenNthCalledWith(1, 'zmx', ['version'], expect.any(Object)); + expect(execFileSyncMock).toHaveBeenNthCalledWith(2, 'zmx', ['list'], expect.any(Object)); + }); + + it('parses real multiline output and rejects unsupported or malformed versions', () => { + expect(parseZmxVersion('zmx\t\t0.6.0\nghostty_vt\tdev\n')).toEqual([0, 6, 0]); + expect(parseZmxVersion('zmx 0.7.1')).toEqual([0, 7, 1]); + expect(parseZmxVersion('unknown')).toBeNull(); + + execFileSyncMock.mockReturnValueOnce('zmx 0.5.9\n' as any); + expect(probeZmxFunctional()).toEqual({ + ok: false, + reason: 'zmx >= 0.6.0 才受支持(当前 0.5.9)', + }); + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + + execFileSyncMock.mockReset(); + execFileSyncMock.mockReturnValueOnce('garbage\n' as any); + expect(probeZmxFunctional()).toEqual({ + ok: false, + reason: '无法解析 zmx 版本:garbage', + }); + }); +}); + +describe('zmx backend pure helpers', () => { + it('maps tmux-style special keys to terminal bytes', () => { + expect(tmuxKeyToBytes('Enter')).toBe('\r'); + expect(tmuxKeyToBytes('C-c')).toBe('\x03'); + expect(tmuxKeyToBytes('C-j')).toBe('\x0a'); + expect(tmuxKeyToBytes('M-b')).toBe('\x1bb'); + expect(tmuxKeyToBytes('M-Enter')).toBe('\x1b\r'); + expect(tmuxKeyToBytes('PPage')).toBe('\x1b[5~'); + expect(tmuxKeyToBytes('NPage')).toBe('\x1b[6~'); + expect(tmuxKeyToBytes('weird')).toBe('weird'); + }); + + it('answers OSC color queries without treating color setters as queries', () => { + expect(terminalOscColorQueryReplies(10, '?')).toEqual([ + '\x1b]10;rgb:a9a9/b1b1/d6d6\x1b\\', + ]); + expect(terminalOscColorQueryReplies(4, '1;?;255;?')).toEqual([ + '\x1b]4;1;rgb:f7f7/7676/8e8e\x1b\\', + '\x1b]4;255;rgb:eeee/eeee/eeee\x1b\\', + ]); + expect(terminalOscColorQueryReplies(10, '?;?')).toEqual([ + '\x1b]10;rgb:a9a9/b1b1/d6d6\x1b\\', + '\x1b]11;rgb:1a1a/1b1b/2626\x1b\\', + ]); + expect(terminalOscColorQueryReplies(11, '#000000')).toEqual([]); + expect(terminalOscColorQueryReplies(4, '1;?;2;#ffffff')).toEqual([ + '\x1b]4;1;rgb:f7f7/7676/8e8e\x1b\\', + ]); + }); + + it('parses session pid from zmx list details', () => { + execFileSyncMock.mockReturnValueOnce('other\nbmx-abcd1234\n' as any); + execFileSyncMock.mockReturnValueOnce( + ' name=other\tpid=11\tclients=0\n' + + ' name=bmx-abcd1234\tpid=4242\tclients=1\tcmd=codex\n' as any, + ); + + expect(findSessionPid('bmx-abcd1234')).toBe(4242); + }); + + it('parses healthy and unhealthy rows from the full list', () => { + expect(parseZmxList( + ' name=bmx-abcd1234\tpid=42\tclients=1\n' + + ' name=my notes\tpid=43\tclients=0\n' + + ' name=bmx-timeout\terr=Timeout\n', + )).toEqual({ + sessions: ['bmx-abcd1234', 'my notes'], + unhealthySessions: ['bmx-timeout'], + malformedLines: [], + }); + }); + + it('only reads ZMX status from the second tab field', () => { + expect(parseZmxList( + ' name=bmx-healthy\tpid=123\tclients=1\tstart_dir=/tmp/err=logs\tcmd=agent --prompt err=retry\n' + + ' name=bmx-unhealthy\terr=Timeout pid=999\tcmd=agent pid=123\n', + )).toEqual({ + sessions: ['bmx-healthy'], + unhealthySessions: ['bmx-unhealthy'], + malformedLines: [], + }); + }); + + it('accepts literal-newline command continuations without reading their status text', () => { + expect(parseZmxList( + ' name=bmx-healthy\tpid=123\tclients=1\tcmd=agent --prompt first\n' + + 'second err=retry pid=999\n' + + 'name=literal prompt text, not a record\n' + + 'name=literal\tfield\tpid=999\n' + + ' name=bmx-unhealthy\terr=Timeout\n', + )).toEqual({ + sessions: ['bmx-healthy'], + unhealthySessions: ['bmx-unhealthy'], + malformedLines: [], + }); + }); + + it('parses short-list names strictly', () => { + expect(parseZmxShortList('bmx-one\nmy notes\n')).toEqual({ + sessions: ['bmx-one', 'my notes'], + malformedLines: [], + }); + expect(parseZmxShortList('bmx-one\nwarning:\tpartial\n')).toEqual({ + sessions: ['bmx-one'], + malformedLines: ['warning:\tpartial'], + }); + }); + + it('does not infer a session pid from cwd or argv fields', () => { + execFileSyncMock.mockReturnValueOnce('bmx-other\n' as any); + execFileSyncMock.mockReturnValueOnce( + ' name=bmx-target\terr=Timeout\tcmd=agent --pid=999\n' + + ' name=bmx-other\tpid=42\tcmd=agent bmx-target pid=777\n' as any, + ); + expect(findSessionPid('bmx-target')).toBeNull(); + }); + + it('fails closed when full-list output is malformed', () => { + expect(parseZmxList('')).toEqual({ + sessions: [], + unhealthySessions: [], + malformedLines: [], + }); + expect(parseZmxList('warning: partial response\n')).toEqual({ + sessions: [], + unhealthySessions: [], + malformedLines: ['warning: partial response'], + }); + execFileSyncMock.mockReturnValueOnce('bmx-good\n' as any); + execFileSyncMock.mockReturnValueOnce( + 'warning: partial response\n name=bmx-good\tpid=42\tclients=1\n' as any, + ); + expect(ZmxBackend.probeSession('bmx-other')).toBe('unknown'); + }); + + it('does not classify an errored target as missing', () => { + execFileSyncMock.mockReturnValueOnce('' as any); + execFileSyncMock.mockReturnValueOnce(' name=bmx-timeout\terr=Timeout\n' as any); + expect(ZmxBackend.probeSession('bmx-timeout')).toBe('unknown'); + }); + + it('does not trust a healthy-looking full row that is absent from --short', () => { + execFileSyncMock.mockReturnValueOnce('bmx-real\n' as any); + execFileSyncMock.mockReturnValueOnce( + ' name=bmx-real\tpid=11\tclients=0\tcmd=agent --prompt first\n' + + ' name=bmx-forged\tpid=999\tclients=0\n' as any, + ); + expect(ZmxBackend.probeSession('bmx-forged')).toBe('unknown'); + }); + + it('lists botmux sessions from the authoritative short list', () => { + execFileSyncMock.mockReturnValueOnce('bmx-abcd1234\nnotes\nbmx-deadbeef\n' as any); + execFileSyncMock.mockReturnValueOnce( + ' name=bmx-abcd1234\tpid=11\tclients=0\n' + + ' name=notes\tpid=12\tclients=0\n' + + ' name=bmx-deadbeef\tpid=13\tclients=1\n' as any, + ); + + expect(ZmxBackend.listBotmuxSessions()).toEqual(['bmx-abcd1234', 'bmx-deadbeef']); + }); + + it('builds a race-safe attach command and strips nested-session identity', () => { + expect(buildReattachArgs('bmx-abcd1234')).toEqual([ + 'attach', 'bmx-abcd1234', '/bin/sh', '-c', 'exit 75', + ]); + + const opts = { + cwd: '/tmp/work', + cols: 80, + rows: 24, + env: { PATH: '/bin', ZMX_SESSION: 'outer', BOTMUX_SESSION_ID: 'session-secret' }, + injectEnv: { + ZMX_SESSION: 'evil', + ZMX_SESSION_PREFIX: 'evil-', + SAFE_FLAG: "yes ' quoted", + }, + }; + const bootstrapPath = '/tmp/private/bootstrap.sh'; + const payloadPath = '/tmp/private/payload.sh'; + const readyMarker = '\x1b]5150;botmux-zmx-ready=0123456789abcdef0123456789abcdef\x1b\\'; + const completionMarker = '\x1b]5150;botmux-zmx-started=0123456789abcdef0123456789abcdef\x1b\\'; + const releaseToken = 'fedcba9876543210fedcba9876543210'; + const argv = buildFreshAttachArgs('bmx-abcd1234', bootstrapPath); + const files = buildZmxLaunchFiles( + 'codex', + ['--flag', 'private prompt'], + opts, + payloadPath, + readyMarker, + completionMarker, + releaseToken, + ); + + expect(argv).toEqual(['attach', 'bmx-abcd1234', '/bin/sh', bootstrapPath]); + expect(argv.join(' ')).not.toContain('private prompt'); + expect(files.bootstrap).not.toContain('private prompt'); + expect(files.bootstrap).not.toContain('session-secret'); + expect(files.bootstrap).not.toContain("yes ' quoted"); + expect(files.bootstrap).toContain(payloadPath); + expect(files.bootstrap).toContain(`printf '%s' '${readyMarker}'`); + expect(files.bootstrap).toContain(`printf '%s' '${completionMarker}'`); + expect(files.bootstrap).toContain(`release_line" = '${releaseToken}'`); + expect(files.bootstrap).toContain('bootstrap-watchdog'); + expect(files.bootstrap).toContain('sleep 8'); + expect(files.bootstrap).toContain('stty -echo'); + expect(files.payload).toContain('private prompt'); + expect(files.payload).toContain('BOTMUX_SESSION_ID=session-secret'); + expect(files.payload).toContain("SAFE_FLAG=yes '"); + expect(files.payload).not.toContain('ZMX_SESSION=evil'); + expect(files.payload).not.toContain('ZMX_SESSION_PREFIX=evil-'); + + const controlEnv = zmxControlEnv(opts); + expect(controlEnv.BOTMUX_SESSION_ID).toBeUndefined(); + expect(controlEnv.SAFE_FLAG).toBeUndefined(); + expect(controlEnv.ZMX_SESSION).toBeUndefined(); + expect(controlEnv.PATH).toContain('/bin'); + }); +}); diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts new file mode 100644 index 000000000..417132ab1 --- /dev/null +++ b/test/zmx-backend-recovery.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fakePtys = vi.hoisted(() => [] as FakePty[]); + +class FakePty { + readonly writes: string[] = []; + spawnArgs: string[] = []; + killed = false; + private dataCb: ((data: string) => void) | undefined; + private exitCb: ((event: { exitCode: number; signal?: number }) => void) | undefined; + + write(data: string): void { this.writes.push(data); } + resize(): void {} + kill(): void { this.killed = true; } + onData(cb: (data: string) => void): void { this.dataCb = cb; } + onExit(cb: (event: { exitCode: number; signal?: number }) => void): void { this.exitCb = cb; } + emitData(data: string): void { this.dataCb?.(data); } + emitExit(exitCode: number, signal?: number): void { this.exitCb?.({ exitCode, signal }); } +} + +vi.mock('node-pty', () => ({ + spawn: vi.fn((_file: string, args: string[]) => { + const child = new FakePty(); + child.spawnArgs = args; + fakePtys.push(child); + return child; + }), +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { ...actual, execFileSync: vi.fn() }; +}); + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; + +const execFileSyncMock = vi.mocked(execFileSync); + +function spawnBackend(): { backend: ZmxBackend; child: FakePty } { + execFileSyncMock.mockReturnValueOnce('' as never); // --short: no pre-existing session + execFileSyncMock.mockReturnValueOnce('' as never); // full list + const backend = new ZmxBackend('bmx-test0001'); + backend.spawn('/bin/sh', ['-c', 'echo ready'], { + cwd: '/tmp', + cols: 80, + rows: 24, + env: { PATH: '/bin' }, + }); + return { backend, child: fakePtys.at(-1)! }; +} + +function launchMarkers(child: FakePty): { ready: string; completion: string; release: string } { + const bootstrapPath = child.spawnArgs.at(-1); + if (!bootstrapPath) throw new Error('missing ZMX bootstrap path'); + const bootstrap = readFileSync(bootstrapPath, 'utf8'); + const nonce = bootstrap.match(/botmux-zmx-ready=([0-9a-f]{32})/)?.[1]; + const release = bootstrap.match(/release_line" = '([0-9a-f]{32})'/)?.[1]; + if (!nonce || !bootstrap.includes(`botmux-zmx-started=${nonce}`)) { + throw new Error('missing ZMX launch markers'); + } + if (!release) throw new Error('missing ZMX private release token'); + const ready = `\x1b]5150;botmux-zmx-ready=${nonce}\x1b\\`; + const completion = `\x1b]5150;botmux-zmx-started=${nonce}\x1b\\`; + return { ready, completion, release }; +} + +describe('ZmxBackend recovery transport', () => { + beforeEach(() => { + vi.useFakeTimers(); + fakePtys.length = 0; + execFileSyncMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps queued input FIFO and retries an inconclusive flush probe on a quiet attach', () => { + const { backend, child } = spawnBackend(); + const markers = launchMarkers(child); + backend.write('A'); + child.emitData(`ready:${markers.ready.slice(0, 19)}`); + expect(child.writes).toEqual([]); + child.emitData(`${markers.ready.slice(19)}${markers.completion}\n`); + backend.write('B'); + + // First post-attach probe is inconclusive, then the same silent attach is + // confirmed live. No second data frame should be required to release input. + execFileSyncMock.mockReturnValueOnce('' as never); + execFileSyncMock.mockReturnValueOnce(' name=bmx-test0001\terr=Timeout\n' as never); + execFileSyncMock.mockReturnValueOnce('bmx-test0001\n' as never); + execFileSyncMock.mockReturnValueOnce(' name=bmx-test0001\tpid=42\tclients=1\n' as never); + vi.advanceTimersByTime(150); + expect(child.writes).toEqual([`${markers.release}\r`]); + vi.advanceTimersByTime(300); + expect(child.writes).toEqual([`${markers.release}\r`, 'AB']); + backend.kill(); + vi.advanceTimersByTime(5 * 60_000); + }); + + it('strips the split fresh-ready marker before output and releases the bootstrap once', () => { + const { backend, child } = spawnBackend(); + const markers = launchMarkers(child); + const output: string[] = []; + backend.onData(data => output.push(data)); + + child.emitData(`zmx-prefix${markers.ready.slice(0, 11)}`); + expect(output).toEqual([]); + expect(child.writes).toEqual([]); + + child.emitData(`${markers.ready.slice(11)}${markers.completion.slice(0, 13)}`); + expect(child.writes).toEqual([`${markers.release}\r`]); + expect(output).toEqual([]); + + child.emitData(`${markers.completion.slice(13)}cli-suffix`); + expect(output.join('')).toBe('zmx-prefixcli-suffix'); + expect(output.join('')).not.toContain(markers.ready); + expect(output.join('')).not.toContain(markers.completion); + + child.emitData('later'); + expect(child.writes).toEqual([`${markers.release}\r`]); + expect(output.join('')).toBe('zmx-prefixcli-suffixlater'); + backend.kill(); + vi.advanceTimersByTime(5 * 60_000); + }); + + it('quarantines until the unverified fresh session disappears, then exits once', () => { + const { backend, child } = spawnBackend(); + const output: string[] = []; + const exits: Array<[number | null, string | null]> = []; + backend.onData(data => output.push(data)); + backend.onExit((code, signal) => exits.push([code, signal])); + + child.emitData('foreign session output'); + expect(child.writes).toEqual([]); + expect(output).toEqual([]); + + execFileSyncMock.mockReturnValueOnce('' as never); + execFileSyncMock.mockReturnValueOnce('' as never); + vi.advanceTimersByTime(5_000); + expect(exits).toEqual([]); + expect(child.killed).toBe(true); + expect(fakePtys).toHaveLength(1); + + vi.advanceTimersByTime(100); + expect(exits).toEqual([[75, null]]); + + child.emitExit(0, 0); + vi.advanceTimersByTime(10_000); + expect(fakePtys).toHaveLength(1); + }); + + it('quarantines a crash-left bootstrap on reattach without forwarding output or input', () => { + const original = spawnBackend(); + const markers = launchMarkers(original.child); + original.backend.kill(); + + execFileSyncMock.mockReturnValueOnce('bmx-test0001\n' as never); + execFileSyncMock.mockReturnValueOnce( + ' name=bmx-test0001\tpid=42\tclients=0\tcmd=/bin/sh /tmp/botmux-zmx-launch-x/bootstrap.sh\n' as never, + ); + const reattached = new ZmxBackend('bmx-test0001', { isReattach: true }); + const output: string[] = []; + const exits: Array<[number | null, string | null]> = []; + reattached.onData(data => output.push(data)); + reattached.onExit((code, signal) => exits.push([code, signal])); + reattached.spawn('/bin/sh', ['-c', 'echo resumed'], { + cwd: '/tmp', + cols: 80, + rows: 24, + env: { PATH: '/bin' }, + }); + const child = fakePtys.at(-1)!; + + reattached.write('hello\r'); + child.emitData(`\x1b[2J${markers.ready.slice(0, 17)}`); + child.emitData(markers.ready.slice(17)); + expect(child.killed).toBe(true); + expect(child.writes).toEqual([]); + expect(output).toEqual([]); + expect(exits).toEqual([]); + + execFileSyncMock.mockReturnValueOnce('' as never); + execFileSyncMock.mockReturnValueOnce('' as never); + vi.advanceTimersByTime(100); + expect(exits).toEqual([[75, null]]); + expect(fakePtys).toHaveLength(2); + vi.advanceTimersByTime(5 * 60_000); + }); + + it('never kills an unverified same-name session when quarantine is destroyed', () => { + const original = spawnBackend(); + const markers = launchMarkers(original.child); + original.backend.kill(); + + execFileSyncMock.mockReturnValueOnce('bmx-test0001\n' as never); + execFileSyncMock.mockReturnValueOnce( + ' name=bmx-test0001\tpid=42\tclients=0\tcmd=/bin/sh /tmp/botmux-zmx-launch-x/bootstrap.sh\n' as never, + ); + const reattached = new ZmxBackend('bmx-test0001', { isReattach: true }); + reattached.spawn('/bin/sh', ['-c', 'echo resumed'], { + cwd: '/tmp', + cols: 80, + rows: 24, + env: { PATH: '/bin' }, + }); + const child = fakePtys.at(-1)!; + execFileSyncMock.mockClear(); + + child.emitData(markers.ready); + expect(child.killed).toBe(true); + reattached.destroySession(); + + expect(execFileSyncMock.mock.calls.some(([, args]) => + Array.isArray(args) && args[0] === 'kill', + )).toBe(false); + vi.advanceTimersByTime(5 * 60_000); + }); + + it('normalizes node-pty signal 0 to a normal null exit signal', () => { + const { backend, child } = spawnBackend(); + const markers = launchMarkers(child); + child.emitData(markers.ready); + child.emitData(markers.completion); + const exits: Array<[number | null, string | null]> = []; + backend.onExit((code, signal) => exits.push([code, signal])); + execFileSyncMock.mockReturnValueOnce('' as never); // --short: target is truly gone + execFileSyncMock.mockReturnValueOnce('' as never); // full list + + child.emitExit(0, 0); + vi.advanceTimersByTime(50); + expect(exits).toEqual([[0, null]]); + vi.advanceTimersByTime(5 * 60_000); + }); +}); diff --git a/test/zmx-backend.e2e.ts b/test/zmx-backend.e2e.ts new file mode 100644 index 000000000..81d8dde2e --- /dev/null +++ b/test/zmx-backend.e2e.ts @@ -0,0 +1,203 @@ +/** + * E2E smoke for ZmxBackend. + * + * Requires: zmx installed (skips if unavailable) + * Run: pnpm vitest run --project e2e test/zmx-backend.e2e.ts + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; + +const TEST_SESSION = `bmx-e2e-zmx-${process.pid}`; +const QUERY_SESSION = `bmx-e2e-da-${process.pid}`; +const RECOVERY_SESSION = `bmx-e2e-recover-${process.pid}`; +const PRIVATE_VALUE = `zmx-private-${process.pid}`; + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +function crashAttachClient(backend: ZmxBackend): void { + // The backing ZMX daemon and CLI are separate from this node-pty viewer. + // White-box the viewer only in this E2E so production does not need a + // crash-only API solely for exercising automatic transport recovery. + const attachClient = (backend as unknown as { + process: { kill(signal?: string): void } | null; + }).process; + expect(attachClient).not.toBeNull(); + attachClient!.kill('SIGKILL'); +} + +function waitFor(fn: () => boolean, timeoutMs = 5000, description = 'condition'): Promise<void> { + const started = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + if (fn()) return resolve(); + if (Date.now() - started > timeoutMs) { + return reject(new Error(`timed out waiting for ${description}`)); + } + setTimeout(tick, 50); + }; + tick(); + }); +} + +describe('ZmxBackend e2e', () => { + afterEach(() => { + ZmxBackend.killSession(TEST_SESSION); + ZmxBackend.killSession(QUERY_SESSION); + ZmxBackend.killSession(RECOVERY_SESSION); + }); + + it.skipIf(!ZmxBackend.isAvailable())('preserves first output, real size, resize, detach, and reattach', async () => { + let output = ''; + const backend = new ZmxBackend(TEST_SESSION); + backend.spawn('sh', ['-lc', [ + "printf '\\033[31mBOOT\\033[0m\\n'", + "printf 'TERM=%s\\n' \"$TERM\"", + "printf 'ZMX_SESSION=%s\\n' \"${ZMX_SESSION-unset}\"", + "printf 'ZMX_SESSION_PREFIX=%s\\n' \"${ZMX_SESSION_PREFIX-unset}\"", + "printf 'PRIVATE=%s\\n' \"${PROVIDER_TEST_TOKEN-unset}\"", + 'stty size', + "trap \"printf 'WINCH:'; stty size\" WINCH", + // A WINCH may interrupt a shell builtin read on some platforms. Keep + // waiting so this fixture tests the backend's resize semantics instead + // of the shell's EINTR policy. + 'while :; do IFS= read -r line || continue; echo "GOT:$line"; [ "$line" = done ] && exit 0; done', + ].join('; ')], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + injectEnv: { PROVIDER_TEST_TOKEN: PRIVATE_VALUE }, + }); + backend.onData(d => { output += d; }); + + await waitFor(() => output.includes('BOOT'), 5000, 'initial BOOT output'); + expect(output).toContain('\x1b[31mBOOT\x1b[0m'); + expect(output).toContain('TERM=xterm-256color'); + expect(output).toContain('ZMX_SESSION=unset'); + expect(output).toContain('ZMX_SESSION_PREFIX=unset'); + expect(output).toContain(`PRIVATE=${PRIVATE_VALUE}`); + const retainedCommand = ZmxBackend.listDetails(); + expect(retainedCommand).not.toContain(PRIVATE_VALUE); + expect(retainedCommand).not.toContain("printf 'PRIVATE="); + await waitFor(() => /24\s+80/.test(output), 5000, 'initial 80x24 size'); + expect(output).toMatch(/24\s+80/); + + backend.resize(101, 33); + await waitFor(() => /WINCH:\s*33\s+101/.test(output), 5000, 'resize to 101x33'); + + backend.sendText('hello\r'); + await waitFor(() => output.includes('GOT:hello'), 5000, 'initial hello input'); + const cliPid = backend.getChildPid(); + expect(cliPid).toEqual(expect.any(Number)); + + backend.kill(); + expect(ZmxBackend.hasSession(TEST_SESSION)).toBe(true); + + let reattachOutput = ''; + let exit: { code: number | null; signal: string | null } | undefined; + const reattached = new ZmxBackend(TEST_SESSION, { isReattach: true }); + reattached.spawn('sh', ['-lc', 'echo should-not-run'], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + }); + reattached.onData(d => { reattachOutput += d; }); + reattached.onExit((code, signal) => { exit = { code, signal }; }); + await waitFor(() => reattachOutput.includes('GOT:hello'), 5000, 'warm snapshot'); + expect(reattached.getChildPid()).toBe(cliPid); + await waitFor(() => /WINCH:\s*24\s+80/.test(reattachOutput), 5000, 'reattach resize to 80x24'); + reattached.sendText('done\r'); + + try { + await waitFor(() => reattachOutput.includes('GOT:done'), 5000, 'reattached done input'); + } catch (err) { + throw new Error( + `${err instanceof Error ? err.message : String(err)}; ` + + `session=${ZmxBackend.probeSession(TEST_SESSION)}; output=${JSON.stringify(reattachOutput.slice(-500))}`, + ); + } + // The warm attach snapshot must not be followed by a second live replay of + // the same line (the old history + tail transport duplicated this case). + expect(countOccurrences(reattachOutput, 'GOT:hello')).toBe(1); + await waitFor(() => !!exit, 5000, 'normal session exit'); + expect(exit?.code).toBe(0); + expect(ZmxBackend.hasSession(TEST_SESSION)).toBe(false); + }); + + it.skipIf(!ZmxBackend.isAvailable())('recovers a crashed attach client without losing or duplicating input', async () => { + let output = ''; + const exits: Array<{ code: number | null; signal: string | null }> = []; + const backend = new ZmxBackend(RECOVERY_SESSION); + backend.spawn('sh', ['-lc', [ + "printf 'READY\\n'", + 'while IFS= read -r line; do echo "GOT:$line"; [ "$line" = done ] && exit 0; done', + ].join('; ')], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + }); + backend.onData(data => { output += data; }); + backend.onExit((code, signal) => { exits.push({ code, signal }); }); + + await waitFor(() => output.includes('READY')); + const cliPid = backend.getChildPid(); + expect(cliPid).toEqual(expect.any(Number)); + + crashAttachClient(backend); + await waitFor(() => (backend as unknown as { state: string }).state === 'recovering'); + expect(ZmxBackend.hasSession(RECOVERY_SESSION)).toBe(true); + expect(backend.getChildPid()).toBe(cliPid); + expect(exits).toHaveLength(0); + + // This write occurs while there is no attach client. Recovery must retain + // it, deliver it to the original CLI once, and never surface viewer death + // as a session exit. + backend.sendText('buffered-during-recovery\r'); + await waitFor(() => output.includes('GOT:buffered-during-recovery')); + expect(countOccurrences(output, 'GOT:buffered-during-recovery')).toBe(1); + expect(exits).toHaveLength(0); + expect(ZmxBackend.hasSession(RECOVERY_SESSION)).toBe(true); + expect(backend.getChildPid()).toBe(cliPid); + + backend.sendText('done\r'); + await waitFor(() => output.includes('GOT:done')); + await waitFor(() => exits.length === 1); + await waitFor(() => !ZmxBackend.hasSession(RECOVERY_SESSION)); + expect(exits).toHaveLength(1); + expect(exits[0]?.code).toBe(0); + }); + + it.skipIf(!ZmxBackend.isAvailable())('answers terminal DA, cursor, and color queries without a browser', async () => { + let output = ''; + let exited = false; + const backend = new ZmxBackend(QUERY_SESSION); + backend.spawn(process.execPath, ['-e', [ + 'process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "const timer=setTimeout(()=>{console.error('QUERY_TIMEOUT');process.exit(2)},2000)", + 'let phase=0', + "process.stdin.on('data',d=>{if(phase===0){console.log('CPR='+d.toString('hex'));phase=1;process.stdout.write('\\x1b[c')}else if(phase===1){console.log('DA='+d.toString('hex'));phase=2;process.stdout.write('\\x1b]10;?\\x1b\\\\')}else{clearTimeout(timer);console.log('COLOR='+d.toString('hex'));process.exit(0)}})", + "process.stdout.write('abc\\x1b[6n')", + ].join(';')], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + }); + backend.onData(data => { output += data; }); + backend.onExit(() => { exited = true; }); + + await waitFor(() => output.includes('CPR=') && output.includes('DA=') && output.includes('COLOR=')); + await waitFor(() => exited); + // The private fresh-release token is entered with echo disabled, so the + // stateful responder sees "abc" at row 1, column 4 with no bootstrap line. + expect(output).toContain('CPR=1b5b313b3452'); // ESC [ 1 ; 4 R + expect(output).toMatch(/DA=1b5b(?:3f313b3263|3f36323b323263)/); + expect(output).toContain('COLOR=1b5d31303b7267623a613961392f623162312f643664361b5c'); + expect(output).not.toContain('QUERY_TIMEOUT'); + }); +}); From c6a8370afde86731f8dbd8d7078dd3348c58ac4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Fri, 17 Jul 2026 13:44:25 +0800 Subject: [PATCH 02/26] =?UTF-8?q?fix(rebase):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=98=E5=9F=BA=E5=90=8E=E7=9A=84=E9=9B=86=E6=88=90=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worker.ts | 34 ++++++++++++++++--- src/workflows/v3/worker-fence.ts | 20 +++++++---- test/backend-gate.test.ts | 30 ++++++++++++++++ test/dashboard-ipc.test.ts | 28 ++++++++++++--- test/initial-passthrough-ownership.test.ts | 4 ++- test/scheduler-silent-execute.test.ts | 5 +++ test/session-kanban.test.ts | 2 +- .../vc-meeting-runtime-lease-recovery.test.ts | 14 ++++---- 8 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 93b74bd3c..54cc8613d 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6921,10 +6921,21 @@ async function spawnCli( let willReattachPersistent = selectedBackend.isReattach === true; if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') { const persistentTarget = selectedBackend.persistentBackendTarget; - const paneLive = persistentTarget - ? probePersistentBackendTarget(persistentTarget) === 'exists' - : false; - if (paneLive) { + // Fail closed on an inconclusive probe: treating 'unknown' as "no pane" + // would cold-spawn a second CLI next to a live one holding MCP state. + const paneProbe = persistentTarget + ? probePersistentBackendTarget(persistentTarget) + : 'missing'; + if (paneProbe === 'unknown') { + throw new Error( + `[mcp-gateway] refusing to start session ${cfg.sessionId}: ` + + `could not verify existing ${effectiveBackendType} pane`, + ); + } + if (effectiveBackendType === 'zmx') { + resolvedZmxSessionProbe = paneProbe; + } + if (paneProbe === 'exists') { // The trusted Gateway host belongs to the worker and cannot survive a // worker/daemon replacement. Cold-resume the CLI so its MCP client gets a // fresh relay socket instead of reattaching to a dead connection. @@ -6932,6 +6943,21 @@ async function spawnCli( const persistentTarget = selectedBackend.persistentBackendTarget; if (persistentTarget) killPersistentBackendTarget(persistentTarget); else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName); + // Confirm the stale pane is really gone before re-selecting. Re-selection + // below decides reattach-vs-fresh from this probe, so an unconfirmed kill + // would let the new backend reattach to the pane we just tried to remove. + const postKillProbe = persistentTarget + ? probePersistentBackendTarget(persistentTarget) + : probePersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName); + if (postKillProbe !== 'missing') { + throw new Error( + `[mcp-gateway] refusing to start session ${cfg.sessionId}: ` + + `could not confirm stale ${effectiveBackendType} pane termination`, + ); + } + if (effectiveBackendType === 'zmx') { + resolvedZmxSessionProbe = postKillProbe; + } selectedBackend = selectBackend(); isTmuxMode = selectedBackend.isTmuxMode; isPipeMode = selectedBackend.isPipeMode; diff --git a/src/workflows/v3/worker-fence.ts b/src/workflows/v3/worker-fence.ts index 26c7c165f..fb2526ad4 100644 --- a/src/workflows/v3/worker-fence.ts +++ b/src/workflows/v3/worker-fence.ts @@ -560,6 +560,19 @@ export function discoverV3AttemptWorker(attemptDir: string): V3AttemptWorkerDisc } } + // Both predicates are mandatory. Check the non-sensitive command line + // first so unrelated same-uid processes with intentionally unreadable + // environments (for example sshd, gpg-agent, or sd-pam) can be ruled out + // instead of making every attempt discovery permanently ambiguous. + // Failure to inspect a possible command remains fail-closed. + try { + if (!isBotmuxWorkerCommandLine(readFileSync(join(procDir, 'cmdline')))) continue; + } catch (err) { + if (isGoneProcError(err)) continue; + unverifiablePids.push(pid); + continue; + } + let envMatches = false; try { envMatches = readFileSync(join(procDir, 'environ')) @@ -575,13 +588,6 @@ export function discoverV3AttemptWorker(attemptDir: string): V3AttemptWorkerDisc } if (!envMatches) continue; - try { - if (!isBotmuxWorkerCommandLine(readFileSync(join(procDir, 'cmdline')))) continue; - } catch (err) { - if (isGoneProcError(err)) continue; - unverifiablePids.push(pid); - continue; - } const procStart = readProcessStartIdentity(pid); if (!procStart) { if (processExists(pid) !== 'missing') unverifiablePids.push(pid); diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index fd7b8dfcc..d2ce6e56a 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -93,3 +93,33 @@ describe('ZMX filesystem-isolation gate', () => { expect(failure).toBeGreaterThan(notify); }); }); + +describe('persistent backend cold-restart ordering', () => { + it('selects the backend only after stale persistent panes have been removed', () => { + const coldRestartGate = workerSource.indexOf( + 'if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length', + ); + const backendSelection = workerSource.indexOf( + 'const selectedBackend = selectSessionBackend({', + ); + + expect(coldRestartGate).toBeGreaterThan(-1); + expect(backendSelection).toBeGreaterThan(coldRestartGate); + }); + + it('fails closed on an uncertain MCP pane and refreshes the cached ZMX probe after killing it', () => { + const start = workerSource.indexOf( + 'if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length', + ); + const end = workerSource.indexOf('const willReattachPersistent =', start); + const gate = workerSource.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(gate).toContain('probePersistentSession('); + expect(gate).toContain("paneProbe === 'unknown'"); + expect(gate).toContain("postKillProbe !== 'missing'"); + expect(gate).toContain("effectiveBackendType === 'zmx'"); + expect(gate).toContain('resolvedZmxSessionProbe = postKillProbe'); + }); +}); diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 160a86e66..4399cd463 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -679,14 +679,32 @@ describe('POST /api/sessions/:sessionId/rename', () => { }); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ ok: true, title: 'New Title', agentSync: 'requested' }); - expect(sessionStore.getSession(session.sessionId)?.title).toBe('New Title'); - expect(sessionStore.getSession(session.sessionId)?.nativeSessionTitle).toBe('New Title'); - expect(sessionStore.getSession(session.sessionId)?.nativeSessionTitleUserDefined).toBe(true); + const renameResult = await res.json(); + expect(renameResult).toEqual({ + ok: true, + title: 'New Title', + titleUpdatedAt: expect.any(String), + titleSource: 'dashboard', + agentSync: 'requested', + }); + expect(sessionStore.getSession(session.sessionId)).toMatchObject({ + title: 'New Title', + titleUpdatedAt: renameResult.titleUpdatedAt, + titleSource: 'dashboard', + nativeSessionTitle: 'New Title', + nativeSessionTitleUserDefined: true, + }); expect(send).toHaveBeenCalledWith({ type: 'rename_session', title: 'New Title' }); expect(events).toContainEqual({ type: 'session.update', - body: { sessionId: session.sessionId, patch: { title: 'New Title' } }, + body: { + sessionId: session.sessionId, + patch: { + title: 'New Title', + titleUpdatedAt: renameResult.titleUpdatedAt, + titleSource: 'dashboard', + }, + }, }); } finally { findSpy?.mockRestore(); diff --git a/test/initial-passthrough-ownership.test.ts b/test/initial-passthrough-ownership.test.ts index 337c218d2..71fa32bd6 100644 --- a/test/initial-passthrough-ownership.test.ts +++ b/test/initial-passthrough-ownership.test.ts @@ -54,7 +54,9 @@ describe('startInitialPassthroughSession ownership', () => { it('hands a registration loser to the post-rollback winner with live passthrough semantics', () => { expect(region).toContain('rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds)'); - expect(region).toContain('deliverPassthroughToExistingSession(winner, cmd, commandContent, anchor, larkAppId)'); + expect(region).toContain('deliverPassthroughToExistingSession(winner, cmd, commandContent, anchor, larkAppId, {'); + expect(region).toContain("senderIsBot: parsed.senderType === 'app' || parsed.senderType === 'bot'"); + expect(region).toContain('substitute,'); }); }); diff --git a/test/scheduler-silent-execute.test.ts b/test/scheduler-silent-execute.test.ts index e4ed0b457..d9c9ba903 100644 --- a/test/scheduler-silent-execute.test.ts +++ b/test/scheduler-silent-execute.test.ts @@ -67,6 +67,11 @@ vi.mock('../src/core/worker-pool.js', () => ({ killStalePids: vi.fn(), getCurrentCliVersion: vi.fn(() => 'test-cli-v1'), restoreUsageLimitRuntimeState: vi.fn(), + setActiveSessionIfActive: vi.fn((map: Map<string, any>, k: string, ds: any) => { + if (map.has(k) && map.get(k) !== ds) return false; + map.set(k, ds); + return true; + }), setActiveSessionSafe: vi.fn(async (map: Map<string, any>, k: string, ds: any) => { map.set(k, ds); }), getActiveSessionsRegistry: vi.fn(() => null), isRelayableRealSession: vi.fn(() => false), diff --git a/test/session-kanban.test.ts b/test/session-kanban.test.ts index 18abde2c0..773c04249 100644 --- a/test/session-kanban.test.ts +++ b/test/session-kanban.test.ts @@ -46,7 +46,7 @@ describe('session-board normalizers', () => { expect(normalizeSessionTitle(' 修复登录 bug ')).toBe('修复登录 bug'); expect(normalizeSessionTitle('第一行\n 第二行')).toBe('第一行 第二行'); expect(normalizeSessionTitle('安全\r\n标题\t\u001b[31m\u0000\u009b')).toBe('安全 标题 [31m'); - expect(normalizeSessionTitle('\x1b]52;c;payload\x07安全\t标题\u009b2J')).toBe(']52;c;payload安全 标题2J'); + expect(normalizeSessionTitle('\x1b]52;c;payload\x07安全\t标题\u009b2J')).toBe(']52;c;payload 安全 标题 2J'); expect(normalizeSessionTitle('a'.repeat(300))).toHaveLength(200); expect(normalizeSessionTitle(' ')).toBeNull(); expect(normalizeSessionTitle('')).toBeNull(); diff --git a/test/vc-meeting-runtime-lease-recovery.test.ts b/test/vc-meeting-runtime-lease-recovery.test.ts index 18f350db6..6474dfd81 100644 --- a/test/vc-meeting-runtime-lease-recovery.test.ts +++ b/test/vc-meeting-runtime-lease-recovery.test.ts @@ -10,7 +10,7 @@ vi.mock('@larksuiteoapi/node-sdk', () => { import { __testOnly_createVcMeetingRuntimeLeaseRecovery as createRecovery } from '../src/daemon.js'; -type FakeSession = DaemonSession & { testPersistentScope?: 'tmux' | 'herdr' | 'zellij' | 'none' | 'unknown' }; +type FakeSession = DaemonSession & { testPersistentScope?: 'tmux' | 'herdr' | 'zellij' | 'zmx' | 'none' | 'unknown' }; function ref(overrides: Partial<VcMeetingAmbiguousReceiptRef> = {}): VcMeetingAmbiguousReceiptRef { return { @@ -83,9 +83,9 @@ function fakeSession(options: { function harness(input: { sessions?: FakeSession[]; - probe?: (backend: 'tmux' | 'herdr' | 'zellij', sessionName: string) => 'exists' | 'missing' | 'unknown'; + probe?: (backend: 'tmux' | 'herdr' | 'zellij' | 'zmx', sessionName: string) => 'exists' | 'missing' | 'unknown'; missingPersistentScope?: FakeSession['testPersistentScope']; - backendAvailable?: (backend: 'tmux' | 'herdr' | 'zellij') => boolean; + backendAvailable?: (backend: 'tmux' | 'herdr' | 'zellij' | 'zmx') => boolean; } = {}) { const sessions = new Map((input.sessions ?? []).map(ds => [ds.session.sessionId, ds])); const sent: Array<{ sessionId: string; turnId: string; dispatchAttempt: number }> = []; @@ -109,11 +109,11 @@ function harness(input: { }, resolvePersistentScope: (ds: FakeSession) => ds.testPersistentScope ?? 'unknown', resolveMissingPersistentScope: () => input.missingPersistentScope ?? 'unknown', - backendAvailable: (backend: 'tmux' | 'herdr' | 'zellij') => input.backendAvailable?.(backend) ?? true, + backendAvailable: (backend: 'tmux' | 'herdr' | 'zellij' | 'zmx') => input.backendAvailable?.(backend) ?? true, killPersistent: (backend: string, sessionName: string) => { backingKills.push(`${backend}:${sessionName}`); }, - probePersistent: (backend: 'tmux' | 'herdr' | 'zellij', sessionName: string) => { + probePersistent: (backend: 'tmux' | 'herdr' | 'zellij' | 'zmx', sessionName: string) => { probes.push(`${backend}:${sessionName}`); return input.probe?.(backend, sessionName) ?? 'missing'; }, @@ -224,8 +224,8 @@ describe('VC meeting runtime lease recovery', () => { }); h.recovery.arm(ref(), 'agent_test'); - expect(h.backingKills.map(value => value.split(':')[0])).toEqual(['tmux', 'herdr', 'zellij']); - expect(h.probes.map(value => value.split(':')[0])).toEqual(['tmux', 'herdr', 'zellij']); + expect(h.backingKills.map(value => value.split(':')[0])).toEqual(['tmux', 'herdr', 'zellij', 'zmx']); + expect(h.probes.map(value => value.split(':')[0])).toEqual(['tmux', 'herdr', 'zellij', 'zmx']); expect(h.recovery.snapshot()).toMatchObject([{ receiverSessionId: 'session_a', deliveryKey: 'delivery_a', From 1448cd15421d17ac36012ee2f6192c8fd193201c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Thu, 23 Jul 2026 07:27:17 +0800 Subject: [PATCH 03/26] =?UTF-8?q?feat(backend):=20=E5=AE=8C=E5=96=84=20ZMX?= =?UTF-8?q?=20=E6=8C=81=E4=B9=85=E4=BC=9A=E8=AF=9D=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + docs-site/docs/en/bots-json.md | 2 +- docs-site/docs/en/web-terminal.md | 2 +- docs-site/docs/en/zmx.md | 40 +- docs-site/docs/zh/bots-json.md | 2 +- docs-site/docs/zh/web-terminal.md | 2 +- docs-site/docs/zh/zmx.md | 40 +- src/adapters/backend/capabilities.ts | 28 + src/adapters/backend/critical-control-key.ts | 25 + .../backend/session-backend-selector.ts | 13 +- src/adapters/backend/types.ts | 15 + src/adapters/backend/zmx-backend.ts | 2041 +++++++++++------ src/adapters/cli/strict-input-handle.ts | 41 + src/cli.ts | 28 +- src/core/command-handler.ts | 28 +- src/core/dashboard-ipc-server.ts | 13 +- src/core/device-isolation-daemon.ts | 24 +- src/core/persistent-backend.ts | 30 +- src/core/session-manager.ts | 85 +- src/core/types.ts | 4 + src/core/worker-pool.ts | 270 ++- src/daemon.ts | 30 +- src/dashboard/session-card-model.ts | 12 +- src/dashboard/web/i18n.ts | 12 +- src/global-config.ts | 5 +- src/i18n/en.ts | 15 +- src/i18n/zh.ts | 15 +- src/im/lark/card-builder.ts | 82 +- src/im/lark/card-handler.ts | 67 +- src/im/lark/sessions-card.ts | 2 + src/services/backend-availability.ts | 2 +- src/services/bot-config-store.ts | 2 +- src/setup/ensure-zmx.ts | 15 +- src/types.ts | 13 +- src/worker.ts | 522 ++++- test/backend-availability.test.ts | 4 +- test/backend-capabilities.test.ts | 26 + test/backend-gate.test.ts | 68 +- test/card-builder.test.ts | 40 + test/card-handler-repo-select.test.ts | 29 +- test/card-integration.test.ts | 94 +- test/command-handler.test.ts | 68 +- test/critical-control-key.test.ts | 73 + test/dashboard-i18n-c5.test.ts | 1 + test/dashboard-ipc.test.ts | 28 +- test/device-isolation-daemon.test.ts | 113 +- test/kill-worker-orphaned-backend.test.ts | 37 +- test/persistent-backend-type.test.ts | 15 + test/raw-input-followup-atomicity.test.ts | 29 +- test/restart-worker-null-reattach.test.ts | 2 +- test/restore-zombie-close.test.ts | 109 + test/session-card-model.test.ts | 7 + test/sessions-card.test.ts | 18 + test/strict-input-handle.test.ts | 59 + test/substitute-control-card.test.ts | 25 +- test/tmux-reattach-backend.test.ts | 10 +- test/transfer-session.test.ts | 2 +- .../vc-meeting-runtime-lease-recovery.test.ts | 21 +- test/worker-pipe-initial-screen-order.test.ts | 19 + test/worker-ready-display-mode.test.ts | 69 +- test/write-link-delivery.test.ts | 27 + test/zmx-backend-helpers.test.ts | 176 +- test/zmx-backend-recovery.test.ts | 691 ++++-- test/zmx-backend.e2e.ts | 428 ++-- 64 files changed, 4395 insertions(+), 1421 deletions(-) create mode 100644 src/adapters/backend/capabilities.ts create mode 100644 src/adapters/backend/critical-control-key.ts create mode 100644 src/adapters/cli/strict-input-handle.ts create mode 100644 test/backend-capabilities.test.ts create mode 100644 test/critical-control-key.test.ts create mode 100644 test/strict-input-handle.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a03b2edf4..4df998b4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,4 +27,5 @@ jobs: - run: npm install -g pnpm@9.5.0 - run: pnpm install --frozen-lockfile + - run: pnpm test - run: pnpm build diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index 09bf9e7e2..fc4669457 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -134,7 +134,7 @@ You can also add it to the corresponding bot entry directly (manual `bots.json` | `sandboxReadonlyPaths` | Extra existing paths mounted read-only inside the sandbox, useful for shared source snapshots, reference repos, or generated docs the bot should inspect but not modify | | `sandboxNetwork` | Network policy for sandboxed sessions. Omitted / `true` keeps current network and proxy access; `false` adds `--unshare-net` and blocks normal network egress | -> The ZMX backend cannot currently enforce the file sandbox or read isolation. Combining `backendType: "zmx"` with a per-bot/global sandbox, or with the standalone effective `readIsolation` mode on macOS, fails closed and returns an actionable session notification. On Linux, the bare legacy `readIsolation` flag is a no-op under the worker's unified semantics and does not incorrectly gate ZMX; enable the sandbox and use tmux / PTY when isolation is required. See [ZMX backend boundaries](/en/zmx#unsupported-combinations). +> ZMX cannot enforce the file sandbox or effective read isolation, so configurations that enable those boundaries fail closed; see [ZMX backend boundaries](/en/zmx#unsupported-combinations). ## Cards and terminal diff --git a/docs-site/docs/en/web-terminal.md b/docs-site/docs/en/web-terminal.md index 882cec26d..133bf4246 100644 --- a/docs-site/docs/en/web-terminal.md +++ b/docs-site/docs/en/web-terminal.md @@ -1,6 +1,6 @@ # Web Terminal (interactive) -Every session comes with an xterm.js-based Web Terminal, at an address like `http://<WEB_EXTERNAL_HOST>:<port>`. +Every session except a ZMX-backed one comes with an xterm.js-based Web Terminal, at an address like `http://<WEB_EXTERNAL_HOST>:<port>`; for ZMX, use `botmux list` or `zmx attach` to enter a local terminal and see the [ZMX backend guide](/en/zmx). ![Web Terminal](https://magic-builder.tos-cn-beijing.volces.com/uploads/1780033301701_web_terminal.gif) diff --git a/docs-site/docs/en/zmx.md b/docs-site/docs/en/zmx.md index d9ab5e946..be75682fb 100644 --- a/docs-site/docs/en/zmx.md +++ b/docs-site/docs/en/zmx.md @@ -1,15 +1,17 @@ # ZMX Session Backend -[ZMX](https://github.com/neurosnap/zmx) is an optional persistent-session backend for botmux. It is intended for macOS and Linux hosts that want native terminal features while only needing attach/detach and session persistence. +[ZMX](https://github.com/neurosnap/zmx) is an optional persistent-session backend for botmux. It is intended for macOS and Linux hosts that want a lightweight session daemon to keep the CLI alive, with a native local attach available whenever the complete terminal experience is needed. ZMX is an **explicit opt-in** backend. botmux does not install ZMX, and merely finding it on `PATH` never makes botmux select it automatically. ## Install and probe -botmux requires **zmx >= 0.6.0**. ZMX officially supports macOS and Linux. +botmux requires **zmx >= 0.7.1**. This version floor is a **prerequisite assumption**: the integration treats 0.7.1 as the first release assumed to contain the behavior specified by upstream [issue #201](https://github.com/neurosnap/zmx/issues/201) and [PR #202](https://github.com/neurosnap/zmx/pull/202), where `send` only queues input without claiming the leader or rewriting terminal size; the installed 0.7.1+ build must actually contain that fix. ZMX officially supports macOS and Linux. + +> **⚠️ Current release prerequisite (as of 2026-07-23)**: ZMX's latest official release and Homebrew tap are still at 0.6.0, so running the `brew install` command below currently installs a version that **does not satisfy** this integration. Wait for an official **>= 0.7.1** build that actually contains PR #202 before enabling ZMX; do not bypass the gate by spoofing a version. The installation commands below describe the target flow after that release is available. ```bash -# Homebrew (macOS / Linuxbrew) +# Homebrew (macOS / Linuxbrew; wait for a >= 0.7.1 tap release containing PR #202) brew install neurosnap/tap/zmx # Verify the daemon user's PATH and control plane @@ -21,6 +23,8 @@ On other hosts, download the prebuilt binary for your architecture from the [off Before creating a new ZMX-backed session, botmux checks the executable, version, and `zmx list` control plane. Any failure **fails closed** with an actionable session error; botmux never silently falls back to PTY. +> **⚠️ Upgrading from 0.6**: replacing the `zmx` binary on disk does not replace per-session daemons that are already running; after upgrading to a 0.7.1+ build containing PR #202, manually stop and recreate every session launched by 0.6, then restart botmux. botmux performs **no automatic cold migration**, and `botmux restart` alone is insufficient: a 0.6 daemon does not understand the new `send` message and can silently discard input even when the command exits successfully. + For contributors, the default `pnpm test` command runs mocked/pure unit tests and **does not require ZMX to be installed**. Coverage that launches a real `zmx` binary lives in `*.e2e.ts`, runs only when E2E is requested explicitly, and skips automatically when ZMX is unavailable—the same pattern already used by the tmux and Herdr E2E suites. ## Enable ZMX @@ -45,15 +49,31 @@ Run `botmux restart` after editing. A per-bot `backendType` overrides the deploy ## Runtime model -botmux assigns each managed session the deterministic name `bmx-<first 8 chars of sessionId>`. The worker runs one real `zmx attach` client inside node-pty. This single bidirectional path carries raw ANSI output, keyboard input, paste, and resize; it does not split transport across `zmx tail` and `zmx send`. On reconnect, ZMX's terminal snapshot is restored over that same attach path. +botmux assigns each managed session the deterministic name `bmx-<first 8 chars of sessionId>`. The ZMX daemon owns the CLI's PTY. Instead of keeping a fake attach leader alive, botmux uses three leaderless interfaces: + +- `zmx tail` is used only as a low-latency change/liveness signal. botmux drains stdout but never gives its payload bytes to the worker: the current upstream `zmx tail` ANSI filter deletes multi-byte UTF-8, so Chinese and emoji cannot rely on this stream. +- `zmx send` queues raw input bytes into the PTY without attaching, changing the leader, or resizing it. +- `zmx history` is the sole authoritative plain-text screen source. Tail/send wake asynchronous capture immediately; a staggered 250ms hot poll and at-most roughly 1.5s cold safety poll also catches pure Unicode that produces no tail event. Before idle completion, botmux forces one post-call capture (bounded retries, then the last successful snapshot). + +A new session briefly uses one non-interactive client for creation only; output and input then use the interfaces above. A local `zmx attach` can therefore become the real leader and let the local terminal control size and the full TUI. Input sent from Lark through botmux does not steal that leadership. | Event | ZMX session / CLI | botmux behavior | |------|-------------------|-----------------| -| `botmux restart` | Stays alive | Rebuilds workers in staggered batches and attaches to the original session without restarting the CLI | +| `botmux restart` | Stays alive | Rebuilds workers and `tail` observers in staggered batches without restarting the CLI | | Backing session is missing during restore | Original process is gone | Retains the active/transcript record instead of destroying it as a zombie; lazily resumes on the next message | -| Worker / attach client disconnects | Stays alive | Reconnects after confirming that the ZMX session still exists | +| Worker / `tail` observer disconnects | Stays alive | Rebuilds the observer after confirming that the ZMX session still exists | | `/close` or close button | Destroyed / terminated | Force-kills the backing session instead of leaving an unmanaged process behind | +## Display, input, and terminal-size boundary + +This integration deliberately uses eventually consistent plain-text screen semantics. Its persistent-session lifecycle is close to tmux, but it is not a complete terminal mirror: + +- ZMX exposes an **eventually consistent plain-text screen** from `history`; it does not preserve color, cursor state, OSC, or the alternate screen. Capture is single-flight per session and a dirty latch forces one follow-up when activity arrives during a capture. +- The ZMX backend does not provide botmux's interactive Web TUI or resize the backing PTY. Use local `zmx attach` when you need raw ANSI, a fullscreen TUI, or terminal-size negotiation. +- A local attach leader controls terminal size. Without one, the ZMX session keeps its existing size; botmux's `send` does not change it. +- Upstream `send` currently provides no delivery ACK or backpressure. botmux sends 1 KiB chunks and rejects any single backend input over 64 KiB before writing a prefix. It never retries an ambiguous result automatically because the input may already be queued and a retry could duplicate it; the backend reports failure to its caller instead of hiding a retry internally. +- `zmx history` can restore only the bounded scrollback still retained by ZMX / ghostty; older output is evicted once the upstream scrollback budget is exceeded. It reconstructs the eventually consistent observable state rather than a lossless transcript or terminal recording; transient output after the daemon has exited cannot be recovered. Workflow raw PTY replay logs therefore do not have tmux's lossless semantics. + ## Enter the same session locally ```bash @@ -67,12 +87,14 @@ When the daemon runs on macOS, you can also explicitly enable **Native CLI openi ## Unsupported combinations - **Adopt**: ZMX is not scanned or accepted as a `/adopt` source. Use the supported tmux / Herdr / Zellij path when adopting an existing external session. +- **Runners that depend on hidden OSC completion events**: `codex-app`, `mira`, and `mir` lose their final/thread events in plain history, so these combinations fail closed at startup. Use tmux / PTY for those CLIs. - **File sandbox and read isolation**: the child PTY belongs to the ZMX session daemon, so botmux cannot currently apply its bwrap / Seatbelt filesystem boundary. Combining `backendType: "zmx"` with `sandbox: true`, global `BOTMUX_SANDBOX=1`, or the standalone effective `readIsolation: true` mode on macOS therefore **fails closed**; the worker posts an actionable session notification before refusing to start. On Linux, the bare legacy `readIsolation` flag is a no-op under the unified worker semantics: it neither provides isolation nor incorrectly gates ZMX. When isolation is required, enable the sandbox and switch to tmux / PTY; otherwise explicitly disable the corresponding isolation setting. ## Troubleshooting -1. Run `zmx version` and `zmx list` as the same user that runs the daemon to verify the version, `PATH`, and socket directory. -2. If you set `ZMX_DIR`, make sure the daemon and the shell used for local attach share the same value. botmux preserves `ZMX_DIR`, but strips inherited `ZMX_SESSION` / `ZMX_SESSION_PREFIX` so nested sessions and prefixes cannot rewrite the deterministic `bmx-*` target. -3. Inspect `botmux logs`. When a probe is inconclusive, botmux conservatively refuses to start/recreate a session so it cannot launch a duplicate CLI or delete a still-live one. +1. Run `zmx version` and `zmx list` as the same user that runs the daemon to verify version 0.7.1 or newer, `PATH`, and the socket directory. +2. After an upgrade from 0.6, manually stop and recreate old session daemons; botmux does not cold-migrate them automatically, and restarting botmux alone does not replace them. Check this first if `zmx send` succeeds but the CLI receives nothing. +3. If you set `ZMX_DIR`, make sure the daemon and the shell used for local attach share the same value. botmux preserves `ZMX_DIR`, but strips inherited `ZMX_SESSION` / `ZMX_SESSION_PREFIX` so nested sessions and prefixes cannot rewrite the deterministic `bmx-*` target. +4. Inspect `botmux logs`. When a probe is inconclusive, botmux conservatively refuses to start/recreate a session so it cannot launch a duplicate CLI or delete a still-live one. Dashboard session queries can report the ZMX backend and deterministic session name, but those fields are not liveness checks. See [Dashboard external read-only queries and security boundary](/en/dashboard#external-read-only-queries). diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index 4bbcba192..8e146a38a 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -134,7 +134,7 @@ | `sandboxReadonlyPaths` | 在沙盒内额外只读挂载的已存在路径,适合共享源码快照、参考仓库或生成文档等只允许查看、不允许修改的输入 | | `sandboxNetwork` | 沙盒会话的网络策略。缺省 / `true` 保留当前网络和代理访问;`false` 添加 `--unshare-net`,阻断普通网络出口 | -> ZMX 后端当前无法执行文件沙盒或读隔离。`backendType: "zmx"` 与 per-bot / 全局沙盒,或 macOS 上独立生效的 `readIsolation` 同时启用时会 fail closed,并向会话返回操作提示。Linux 上单独设置旧 `readIsolation` 标志按 worker 统一语义是 no-op,不会误拦 ZMX;需要真实隔离时请启用 sandbox 并改用 tmux / PTY。见 [ZMX 后端边界](/zmx#不支持的组合)。 +> ZMX 无法执行文件沙盒或实际生效的读隔离,开启这些边界的配置组合会 fail closed,详见 [ZMX 后端边界](/zmx#不支持的组合)。 ## 卡片与终端 diff --git a/docs-site/docs/zh/web-terminal.md b/docs-site/docs/zh/web-terminal.md index 0b63d0c9c..ecf8e3a74 100644 --- a/docs-site/docs/zh/web-terminal.md +++ b/docs-site/docs/zh/web-terminal.md @@ -1,6 +1,6 @@ # Web 终端(可交互) -每个会话都带一个基于 xterm.js 的 Web 终端,地址形如 `http://<WEB_EXTERNAL_HOST>:<端口>`。 +除 ZMX 后端外,每个会话都带一个基于 xterm.js 的 Web 终端,地址形如 `http://<WEB_EXTERNAL_HOST>:<端口>`;ZMX 请通过 `botmux list` 或 `zmx attach` 进入本机终端,详见 [ZMX 后端](/zmx)。 ![Web 终端](https://magic-builder.tos-cn-beijing.volces.com/uploads/1780033301701_web_terminal.gif) diff --git a/docs-site/docs/zh/zmx.md b/docs-site/docs/zh/zmx.md index 2ea1577da..103d52233 100644 --- a/docs-site/docs/zh/zmx.md +++ b/docs-site/docs/zh/zmx.md @@ -1,15 +1,17 @@ # ZMX 会话后端 -[ZMX](https://github.com/neurosnap/zmx) 是 botmux 的一个可选持久会话后端。它适合希望保留原生终端特性,又只需要 attach / detach 与会话常驻能力的 macOS / Linux 主机。 +[ZMX](https://github.com/neurosnap/zmx) 是 botmux 的一个可选持久会话后端。它适合希望用轻量会话 daemon 保住 CLI,并在需要完整终端体验时从本机原生 attach 的 macOS / Linux 主机。 ZMX 是**显式 opt-in** 后端:botmux 不会自动安装 ZMX,也不会因为它已在 `PATH` 中就自动选用。 ## 安装与探测 -botmux 要求 **zmx >= 0.6.0**。ZMX 官方支持 macOS 和 Linux。 +botmux 要求 **zmx >= 0.7.1**。这里的版本下限是一项**前置假设**:本集成将 0.7.1 视为假定包含上游 [issue #201](https://github.com/neurosnap/zmx/issues/201) / [PR #202](https://github.com/neurosnap/zmx/pull/202) 修复行为的首个发布版,即 `send` 只排队输入而不抢占 leader 或改写终端尺寸;实际安装的 0.7.1+ build 必须已包含这项修复。ZMX 官方支持 macOS 和 Linux。 + +> **⚠️ 当前发布前置(截至 2026-07-23)**:ZMX 官方 latest release 与 Homebrew tap 仍为 0.6.0,执行下面的 `brew install` 目前会安装一个**不满足**本集成要求的版本。请等待上游发布实际包含 PR #202 行为的正式 **>= 0.7.1** build 后再启用 ZMX;不要通过伪造版本号绕过门禁。本页安装命令描述的是该正式版本发布后的目标流程。 ```bash -# Homebrew(macOS / Linuxbrew) +# Homebrew(macOS / Linuxbrew;等待 tap 发布包含 PR #202 的 >= 0.7.1) brew install neurosnap/tap/zmx # 验证 daemon 用户的 PATH 与控制面 @@ -21,6 +23,8 @@ zmx list 每次启动新 ZMX 会话前,botmux 都会校验可执行文件、版本和 `zmx list` 控制面。任意一项失败都会 **fail closed** 并向会话返回可操作的错误;绝不会悄悄降级到 PTY。 +> **⚠️ 从 0.6 升级**:替换磁盘上的 `zmx` 二进制不会替换已经运行的逐会话 daemon;升级到包含 PR #202 的 0.7.1+ build 后,请手动关闭并重新创建所有 0.6 会话,再重启 botmux。botmux **不会自动冷迁移**旧会话,只运行 `botmux restart` 也不够;0.6 daemon 不认识新版 `send` 消息,可能在命令返回成功时仍静默丢弃输入。 + 开发时,默认 `pnpm test` 只跑 mock / 纯函数单测,**不要求本机安装 ZMX**。会启动真实 `zmx` 的覆盖位于 `*.e2e.ts`,只在显式运行 E2E 时参与,并在 ZMX 不可用时自动跳过;这与仓库现有 tmux / Herdr E2E 的处理方式一致。 ## 开启 ZMX @@ -45,15 +49,31 @@ BACKEND_TYPE=zmx ## 运行模型 -botmux 为每个受管会话使用确定性名称 `bmx-<sessionId 前 8 位>`。worker 在 node-pty 中运行一个真实的 `zmx attach` 客户端,由这一条双向链路承载原始 ANSI 输出、键盘输入、粘贴和 resize;不使用 `zmx tail` + `zmx send` 的分离旁路。重连时,ZMX 自己的终端快照也通过同一 attach 链路恢复。 +botmux 为每个受管会话使用确定性名称 `bmx-<sessionId 前 8 位>`。ZMX daemon 持有 CLI 的 PTY;botmux 不再常驻一个假的 attach leader,而是使用三个无 leader 的接口: + +- `zmx tail`:只作为低延迟的变化 / 存活信号;botmux 会排空其 stdout,但**不会把正文交给 worker**。当前上游 `zmx tail` 的 ANSI 过滤会删除 UTF-8 多字节,中文 / emoji 不能以这里的字节为准。 +- `zmx send`:把原始输入字节排进 PTY,不 attach、不切换 leader、也不 resize。 +- `zmx history`:唯一权威的纯文本屏幕源。tail / send 会立即唤醒异步采集;即使 tail 对纯中文完全无事件,也有热态 250ms、稳态最迟约 1.5s 的错峰安全轮询。每次 idle 定稿前还会强制补拉一轮(失败时有界重试后使用最后成功快照)。 + +新会话只在创建时短暂启动一次非交互客户端,随后输出和输入都走上面的接口。这样本地用户执行 `zmx attach` 时可以成为真正的 leader,由本地终端控制尺寸和完整 TUI;botmux 发送飞书输入不会把 leader 抢走。 | 事件 | ZMX session / CLI | botmux 行为 | |------|-------------------|-------------| -| `botmux restart` | 保持存活 | 恢复时分批重建 worker 并 attach 原会话,不重起 CLI | +| `botmux restart` | 保持存活 | 恢复时分批重建 worker 与 `tail` 观察者,不重起 CLI | | 恢复时 backing session 已不存在 | 原进程已不在 | 保留 active / transcript 记录,不当作僵尸销毁;下一条消息时 lazy resume | -| worker / attach 客户端断开 | 保持存活 | 确认 ZMX session 仍在后自动重连 | +| worker / `tail` 观察者断开 | 保持存活 | 确认 ZMX session 仍在后重建观察者 | | `/close` 或关闭按钮 | 销毁 / 终止 | 执行强制 kill,不会只留一个脱管会话 | +## 显示、输入与终端尺寸边界 + +这条集成刻意选择最终一致的纯文本屏幕语义,行为更接近 tmux 的持久会话生命周期,但不是 tmux 的完整终端镜像: + +- ZMX 向 botmux 提供的是 `history` 的**最终一致纯文本屏幕**,不保留颜色、光标状态、OSC 或 alternate screen。采集单会话 single-flight,并在采集中出现新活动时强制补拉,避免并发 history 风暴或漏掉飞行中的尾段。 +- ZMX 后端不提供 botmux 的交互式 Web TUI,也不向 backing PTY 发送 resize。需要 raw ANSI、全屏 TUI 或尺寸协商时,请使用本机 `zmx attach`。 +- 本机 attach 的 leader 负责终端尺寸;没有本机 leader 时沿用 ZMX 会话的既有尺寸。botmux 的 `send` 不会改变它。 +- 上游 `send` 目前没有投递 ACK / backpressure。botmux 以 1 KiB 分片发送,并在写入任何前缀前拒绝超过 64 KiB 的单次后端输入;结果不确定时不会自动重试,以免把已经入队的输入重复提交。后端会向调用方返回失败,而不是在内部隐藏重试。 +- `zmx history` 只能恢复 ZMX / ghostty 当时仍保留的有界 scrollback;超过上游滚动缓冲预算后,较早输出会被淘汰。它构成最终一致的当前可观察状态,不是无损 transcript 或终端录屏;进程退出后才出现且未被最后一次采集命中的瞬态输出也无法补回。Workflow 的 raw PTY replay log 因此不具备 tmux 的无损语义。 + ## 在本机进入同一会话 ```bash @@ -67,12 +87,14 @@ botmux list ## 不支持的组合 - **Adopt**:ZMX 不是 `/adopt` 的扫描/接入源;需要 adopt 现有外部会话时,使用 tmux / Herdr / Zellij 支持的路径。 +- **依赖隐藏 OSC 完成事件的 runner**:`codex-app`、`mira`、`mir` 的 final / thread 事件会被纯文本 history 消费掉,因此该组合启动时 fail closed;请为这些 CLI 使用 tmux / PTY。 - **文件沙盒与读隔离**:ZMX 子 PTY 属于会话 daemon,当前无法套用 botmux 的 bwrap / Seatbelt 文件边界。因此 `sandbox: true`、全局 `BOTMUX_SANDBOX=1`,或 macOS 上独立生效的 `readIsolation: true` 与 `backendType: "zmx"` 同时出现时会 **fail closed**;worker 会先向会话返回可操作提示,再拒绝启动。Linux 上单独设置旧 `readIsolation` 标志按统一 worker 语义是 no-op,不代表会话已隔离,也不会误拦 ZMX。需要真实隔离时,请启用 sandbox 并改用 tmux / PTY;否则明确关闭相应隔离配置。 ## 排错 -1. 以运行 daemon 的同一用户执行 `zmx version` 和 `zmx list`,确认版本、`PATH` 和 socket 目录可用。 -2. 如果显式设了 `ZMX_DIR`,确保 daemon 和本地 attach 的 shell 使用同一值。botmux 会保留 `ZMX_DIR`,但会清掉继承的 `ZMX_SESSION` / `ZMX_SESSION_PREFIX`,避免嵌套会话和名称前缀改写 `bmx-*` 目标。 -3. 查看 `botmux logs`。探测结果不确定时,botmux 会保守拒绝启动/重建,避免重复启动 CLI 或误删仍存活的会话。 +1. 以运行 daemon 的同一用户执行 `zmx version` 和 `zmx list`,确认版本至少为 0.7.1、`PATH` 和 socket 目录可用。 +2. 如果刚从 0.6 升级,手动关闭并重新创建旧会话 daemon;botmux 不做自动冷迁移,仅重启 botmux 不会替换它们。出现 `zmx send` 返回成功但 CLI 没收到输入,优先检查这一项。 +3. 如果显式设了 `ZMX_DIR`,确保 daemon 和本地 attach 的 shell 使用同一值。botmux 会保留 `ZMX_DIR`,但会清掉继承的 `ZMX_SESSION` / `ZMX_SESSION_PREFIX`,避免嵌套会话和名称前缀改写 `bmx-*` 目标。 +4. 查看 `botmux logs`。探测结果不确定时,botmux 会保守拒绝启动/重建,避免重复启动 CLI 或误删仍存活的会话。 Dashboard 的会话查询可返回 ZMX 后端与确定性会话名,但这些字段不等于存活检查。见 [Dashboard 对外只读查询与安全边界](/dashboard#对外只读查询)。 diff --git a/src/adapters/backend/capabilities.ts b/src/adapters/backend/capabilities.ts new file mode 100644 index 000000000..6efd68a10 --- /dev/null +++ b/src/adapters/backend/capabilities.ts @@ -0,0 +1,28 @@ +import type { BackendType } from './types.js'; +import type { CliId } from '../cli/types.js'; + +/** + * Whether botmux can expose its xterm.js Web Terminal for this backend. + * + * ZMX exposes only its authoritative plain-text `history` screen to botmux; + * `tail` is merely a wakeup signal. That cannot reconstruct the raw ANSI TUI. + * Native `zmx attach` remains available through the local-terminal entrypoint. + */ +export function backendSupportsWebTerminal(backendType: BackendType): boolean { + return backendType !== 'zmx'; +} + +/** + * Runner CLIs emit their authoritative final/thread events as hidden OSC. + * ZMX history is plain terminal state after control-sequence consumption, so + * those events cannot be recovered through its screen-only transport. + */ +export function backendCliCompatibilityError( + backendType: BackendType, + cliId: CliId, +): string | undefined { + if (backendType === 'zmx' && ['codex-app', 'mira', 'mir'].includes(cliId)) { + return `backend "zmx" cannot carry ${cliId}'s hidden OSC final/thread events; use tmux/pty`; + } + return undefined; +} diff --git a/src/adapters/backend/critical-control-key.ts b/src/adapters/backend/critical-control-key.ts new file mode 100644 index 000000000..5aea74608 --- /dev/null +++ b/src/adapters/backend/critical-control-key.ts @@ -0,0 +1,25 @@ +export function isCriticalInterruptKey(key: string): boolean { + return key === 'ctrlc' || key === 'esc'; +} + +/** + * Deliver an interrupt-class terminal key with one bounded retry. + * + * Duplicate C-c / Escape is safer than silently claiming a stopped CLI while + * an ambiguous transport keeps it running. Other navigation keys deliberately + * stay outside this helper and retain best-effort semantics. + */ +export async function sendCriticalControlKey( + sendOnce: () => void | boolean, + wait: (ms: number) => Promise<void> = ms => new Promise(resolve => setTimeout(resolve, ms)), +): Promise<boolean> { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + if (sendOnce() !== false) return true; + } catch { + // A synchronous transport failure is retryable for interrupt keys only. + } + if (attempt === 0) await wait(100); + } + return false; +} diff --git a/src/adapters/backend/session-backend-selector.ts b/src/adapters/backend/session-backend-selector.ts index 6adb2a18e..c5e0d6e0a 100644 --- a/src/adapters/backend/session-backend-selector.ts +++ b/src/adapters/backend/session-backend-selector.ts @@ -46,7 +46,7 @@ export function backendGateUserMessage(backend: BackendType, reason: string): st backend === 'tmux' ? 'macOS: brew install tmux | Debian/Ubuntu: sudo apt-get install -y tmux | 其它发行版用对应包管理器安装 tmux' : backend === 'zmx' - ? '需要 zmx >= 0.6.0;macOS: brew install neurosnap/tap/zmx | Linux: 安装官方 release binary' + ? '需要包含 PR #202 send 行为的 zmx >= 0.7.1;当前官方 0.6.0 尚不满足,请等待对应正式版' : `请确认 ${backend} 已正确安装并可用`; return [ `⚠️ 本机 ${backend} 不可用,无法启动会话。`, @@ -130,9 +130,16 @@ export function selectSessionBackend(opts: { const sessionName = ZmxBackend.sessionName(opts.sessionId); const reattach = opts.hasExistingSession ?? ZmxBackend.hasSession(sessionName); return { - backend: new ZmxBackend(sessionName, { ownsSession: true, isReattach: reattach }), + backend: new ZmxBackend(sessionName, { + ownsSession: true, + isReattach: reattach, + sessionId: opts.sessionId, + }), isTmuxMode: false, - isPipeMode: false, + // ZMX is observed out-of-band (`zmx tail`) and driven independently + // (`zmx send`), matching the worker's pipe-backend data path rather than + // a bidirectional PTY attach client. + isPipeMode: true, isZellijMode: false, persistentSessionName: sessionName, persistentBackendTarget: { backendType: 'zmx', sessionName }, diff --git a/src/adapters/backend/types.ts b/src/adapters/backend/types.ts index 170017665..5c98b4563 100644 --- a/src/adapters/backend/types.ts +++ b/src/adapters/backend/types.ts @@ -56,6 +56,15 @@ export interface SessionBackend { write(data: string): void; resize(cols: number, rows: number): void; onData(cb: (data: string) => void): void; + /** + * Replace the worker's derived screen state with an authoritative snapshot. + * + * Live-only observers use this after reconnecting: output may have been + * produced while the observer was offline, so replaying only subsequent + * chunks would leave idle detection and cards permanently stale. This is a + * reset/rebase signal, not another incremental PTY chunk. + */ + onScreenResync?(cb: (snapshot: string) => void): void; onExit(cb: (code: number | null, signal: string | null) => void): void; kill(): void; /** Permanently destroy the backing session (e.g. kill tmux session). @@ -64,6 +73,12 @@ export interface SessionBackend { /** PID of the CLI process running inside the backend. */ getChildPid?(): number | null; captureCurrentScreen?(): string; + /** + * Complete one authoritative screen refresh that starts after this call. + * Snapshot-only backends use this as a completion fence before the worker + * declares a turn idle, so a final burst cannot be lost to polling phase. + */ + settleCurrentScreen?(): Promise<boolean>; captureViewport?(): string; getPaneSize?(): { cols: number; rows: number } | null; /** diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index ac9fdb3e0..423c53ecf 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -1,10 +1,26 @@ -import * as pty from 'node-pty'; -import { execFileSync } from 'node:child_process'; +import { + execFile, + execFileSync, + spawn, + spawnSync, + type ChildProcess, +} from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { chmodSync, mkdtempSync, rmSync, rmdirSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + closeSync, + fstatSync, + mkdtempSync, + openSync, + readFileSync, + readSync, + renameSync, + rmSync, + rmdirSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import xtermHeadless from '@xterm/headless'; import type { SessionBackend, SpawnOpts, SessionProbe } from './types.js'; import { zmxEnv, probeZmxFunctional } from '../../setup/ensure-zmx.js'; import { @@ -15,33 +31,50 @@ import { } from './tmux-backend.js'; import { logger } from '../../utils/logger.js'; -const { Terminal } = xtermHeadless; - const EARLY_BUFFER_MAX = 1024 * 1024; -const RECOVERY_WRITE_BUFFER_MAX = 256 * 1024; -const RECOVERY_DELAY_MAX_MS = 2000; -const RECOVERY_WRITE_FLUSH_DELAY_MS = 150; -const FRESH_ATTACH_READY_TIMEOUT_MS = 5000; -const FRESH_ATTACH_READY_BUFFER_MAX = 1024 * 1024; -const FRESH_BOOTSTRAP_WATCHDOG_SECONDS = 8; -const STALE_BOOTSTRAP_POLL_MS = 100; -const STALE_BOOTSTRAP_WAIT_MAX_MS = (FRESH_BOOTSTRAP_WATCHDOG_SECONDS + 3) * 1000; -const REATTACH_BOOTSTRAP_SNIFF_MS = 300; -const ZMX_READY_MARKER_RE = /\x1b\]5150;botmux-zmx-ready=([0-9a-f]{32})\x1b\\/; -const ZMX_READY_MARKER_MAX = 96; -const LAUNCH_PAYLOAD_CLEANUP_MS = 5 * 60_000; -const TERMINAL_FG = '#a9b1d6'; -const TERMINAL_BG = '#1a1b26'; -const TERMINAL_CURSOR = '#c0caf5'; -const TERMINAL_ANSI = [ - '#15161e', '#f7768e', '#9ece6a', '#e0af68', - '#7aa2f7', '#bb9af7', '#7dcfff', '#a9b1d6', - '#414868', '#f7768e', '#9ece6a', '#e0af68', - '#7aa2f7', '#bb9af7', '#7dcfff', '#c0caf5', -] as const; - -type AttachMode = 'fresh' | 'reattach'; -type BackendState = 'idle' | 'connecting' | 'attached' | 'recovering' | 'stopped' | 'exited'; +const HISTORY_TAIL_DEBOUNCE_MS = 50; +const HISTORY_HOT_POLL_MS = 250; +// Keep the worst-case safety poll below IdleDetector's 2s quiescence window: +// otherwise a pure-Unicode burst (which broken upstream tail may not signal) +// could be observed only after the worker had already declared the turn idle. +const HISTORY_COLD_POLL_MS = 1250; +const HISTORY_COLD_JITTER_MAX_MS = 250; +const HISTORY_STABLE_POLLS_BEFORE_COLD = 3; +const ZMX_HISTORY_TIMEOUT_MS = 3000; +const ZMX_HISTORY_SETTLE_TIMEOUT_MS = 4000; +const TAIL_RECOVERY_DELAY_MAX_MS = 2000; +const FRESH_READY_TIMEOUT_MS = 5000; +const FRESH_RELEASE_TIMEOUT_MS = 12_000; +const FRESH_CLI_PID_TIMEOUT_MS = 3000; +// ZMX removes history as soon as the PTY root exits. Keep the private launch +// shell alive briefly after the real CLI finishes so the history-only output +// path can publish the final bytes before the daemon unlinks the session. +const ZMX_EXIT_HISTORY_GRACE_SECONDS = 3; +const TAIL_CONNECT_TIMEOUT_MS = 3000; +const MANAGED_KILL_TIMEOUT_MS = 5000; +const ZMX_COMMAND_TIMEOUT_MS = 5000; +const ZMX_HISTORY_MAX_BYTES = 16 * 1024 * 1024; +// ZMX's daemon reads one 4096-byte IPC frame at a time and may observe HUP from +// the short-lived `send` client in the same poll iteration. Keep header+payload +// comfortably below that boundary so it can parse the complete message before +// closing the client (PR #202 currently has no ACK/drain handshake). +const ZMX_SEND_CHUNK_BYTES = 1024; +// PR #202 still has a 256 KiB daemon-side input queue and no send ACK. Reject +// one-shot payloads well below that ceiling before writing any prefix; adapters +// that intentionally stream larger input already split and throttle their calls. +const ZMX_SEND_MAX_BYTES = 64 * 1024; +const ZMX_TRANSPORT_LABEL = 'botmux.transport'; +const ZMX_TRANSPORT = 'tail-send-v1'; +const ZMX_SESSION_LABEL = 'botmux.session'; +const ZMX_LAUNCH_PID_LABEL = 'botmux.launch_pid'; + +type BackendState = 'idle' | 'observing' | 'recovering' | 'stopped' | 'exited'; + +type BackingIdentityProbe = + | { state: 'compatible'; clients: number | null } + | { state: 'missing' } + | { state: 'unknown'; reason: string } + | { state: 'replaced'; reason: string }; interface ZmxSessionProbeResult { ok: true; @@ -50,46 +83,136 @@ interface ZmxSessionProbeResult { raw: string; } +interface ZmxSessionDetails { + name: string; + pid: number; + clients: number | null; + command: string | null; +} + +export type ZmxManagedSessionProbe = + | { state: 'missing' } + | { state: 'unknown'; reason: string } + | { state: 'compatible'; pid: number; clients: number | null } + | { + state: 'incompatible'; + pid: number; + clients: number | null; + reason: 'transport-label' | 'session-label'; + }; + interface ZmxLaunchPayload { + dir: string; bootstrapPath: string; - readyMarker: string; - completionMarker: string; + readyPath: string; + readyNonce: string; + releasePath: string; + releaseTempPath: string; + cliPidPath: string; releaseToken: string; cleanup: () => void; } +const syncSleepCell = new Int32Array(new SharedArrayBuffer(4)); + +function sleepSync(ms: number): void { + Atomics.wait(syncSleepCell, 0, 0, ms); +} + +/** Cross-platform direct-parent probe used only during the private fresh + * handshake and warm reattach. ZMX itself is Unix-only, so Linux `/proc` plus + * BSD-compatible `ps` covers its supported hosts without shell interpolation. */ +function readProcessParentPid(pid: number): number | null { + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (process.platform === 'linux') { + try { + const raw = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const closeParen = raw.lastIndexOf(')'); + if (closeParen < 0) return null; + const fields = raw.slice(closeParen + 2).trim().split(/\s+/); + const parent = Number(fields[1]); + return Number.isSafeInteger(parent) && parent > 0 ? parent : null; + } catch { return null; } + } + for (const ps of ['/usr/bin/ps', '/bin/ps']) { + try { + const raw = execFileSync(ps, ['-o', 'ppid=', '-p', String(pid)], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + env: { PATH: '/usr/bin:/bin', LANG: 'C' }, + }).trim(); + const parent = Number(raw); + if (Number.isSafeInteger(parent) && parent > 0) return parent; + } catch { /* try the other standard BSD/GNU location */ } + } + return null; +} + +/** Convert plain line feeds into terminal-safe CRLF without doubling CRLF. */ +export function normaliseZmxHistory(text: string): string { + // ZMX history emits LF while tail can expose CRLF, and high-throughput tail + // chunking can occasionally repeat the CR at a boundary. All represent the + // same terminal line break in this plain-text transport. + return text.replace(/\r*\n/g, '\r\n'); +} + /** - * Persistent backend driven by one real `zmx attach` client inside node-pty. + * Persistent ZMX backend built from three non-leader primitives: + * + * - `zmx tail` is a low-latency change/liveness signal (never a byte source); + * - `zmx send` injects input without taking client leadership or resizing; + * - `zmx history` is the sole authoritative plain-text screen source. * - * The attach client is the ordered, bidirectional transport: it preserves raw - * terminal bytes, gives the backing PTY its real dimensions, and replays ZMX's - * terminal snapshot on reconnect. Killing the client only detaches; explicit - * session teardown uses `zmx kill --force`. + * A one-shot `zmx attach ... /bin/sh <private-bootstrap>` is used only as the + * race-safe create primitive. Its stdin is /dev/null, so the client exits + * immediately and never remains as a synthetic leader. The private bootstrap + * does not launch the CLI until botmux has proved ownership, stamped the + * transport protocol labels and observed the tail client connected. */ export class ZmxBackend implements SessionBackend { - private process: pty.IPty | null = null; + private tailProcess: ChildProcess | null = null; private readonly dataCbs: Array<(data: string) => void> = []; + private readonly screenResyncCbs: Array<(snapshot: string) => void> = []; private readonly exitCbs: Array<(code: number | null, signal: string | null) => void> = []; private reattaching: boolean; private intentionalExit = false; private exited = false; private state: BackendState = 'idle'; - private epoch = 0; + private tailEpoch = 0; private reconnectAttempt = 0; private reconnectTimer: NodeJS.Timeout | null = null; - private stableAttachTimer: NodeJS.Timeout | null = null; - private freshAttachReadyTimer: NodeJS.Timeout | null = null; - private recoveryWriteFlushTimer: NodeJS.Timeout | null = null; - private recoveryWriteProbeAttempt = 0; - private queryTerminal: InstanceType<typeof Terminal> | null = null; - /** Quarantine may be observing a same-name session owned by another checkout. */ + private stableTailTimer: NodeJS.Timeout | null = null; + private historyTimer: NodeJS.Timeout | null = null; + private historyTimerDueAt = 0; + private historyProcess: ChildProcess | null = null; + private historyGeneration = 0; + private historyCaptureSerial = 0; + private historyInFlight = false; + private historyAgain = false; + private historyAgainActivity = false; + private historyAgainForceResync = false; + private tailActivitySinceCapture = false; + private forceResyncOnNextSnapshot = false; + private stableHistoryPolls = 0; + private snapshotCache = ''; + private hasSnapshot = false; + private pendingScreenResyncReplay = false; + private readonly historyColdJitterMs: number; + private readonly historySettleWaiters: Array<{ + targetSerial: number; + resolve: (success: boolean) => void; + timer: NodeJS.Timeout; + }> = []; + /** A same-name session failed ownership checks and must never be killed. */ private preserveSessionOnDestroy = false; private pendingExit: { code: number | null; signal: string | null } | null = null; private earlyBuffer = ''; - private recoveryWriteBuffer = ''; private lastOpts: SpawnOpts | null = null; - private cols = 200; - private rows = 50; + /** Frozen ZMX PTY-root PID for the session generation we may control. */ + private backingPid: number | null = null; + /** Stable direct child of the PTY root; wrapper resolution may refine cliPid. */ + private launchPid: number | null = null; claudeJsonlPath?: string; cliPid?: number; @@ -97,9 +220,16 @@ export class ZmxBackend implements SessionBackend { constructor( private readonly sessionName: string, - private readonly opts: { ownsSession?: boolean; isReattach?: boolean } = {}, + private readonly opts: { + ownsSession?: boolean; + isReattach?: boolean; + sessionId?: string; + } = {}, ) { this.reattaching = opts.isReattach ?? false; + let hash = 0; + for (const char of sessionName) hash = ((hash * 33) + char.charCodeAt(0)) >>> 0; + this.historyColdJitterMs = hash % (HISTORY_COLD_JITTER_MAX_MS + 1); } static isAvailable(): boolean { @@ -116,19 +246,19 @@ export class ZmxBackend implements SessionBackend { * newlines in argv spill onto continuation lines), so it must never be the * sole source of truth for a healthy session name. */ - static probeSessions(): ZmxSessionProbeResult | { ok: false } { + static probeSessions(env: NodeJS.ProcessEnv = zmxEnv()): ZmxSessionProbeResult | { ok: false } { try { const shortOut = execFileSync('zmx', ['list', '--short'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, - env: zmxEnv(), + env, }); const out = execFileSync('zmx', ['list'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, - env: zmxEnv(), + env, }); const short = parseZmxShortList(shortOut); const parsed = parseZmxList(out); @@ -157,12 +287,12 @@ export class ZmxBackend implements SessionBackend { } } - static hasSession(name: string): boolean { - return ZmxBackend.probeSession(name) === 'exists'; + static hasSession(name: string, env: NodeJS.ProcessEnv = zmxEnv()): boolean { + return ZmxBackend.probeSession(name, env) === 'exists'; } - static probeSession(name: string): SessionProbe { - const probe = ZmxBackend.probeSessions(); + static probeSession(name: string, env: NodeJS.ProcessEnv = zmxEnv()): SessionProbe { + const probe = ZmxBackend.probeSessions(env); if (!probe.ok) return 'unknown'; if (probe.sessions.includes(name)) return 'exists'; if (probe.unhealthySessions.includes(name)) return 'unknown'; @@ -170,10 +300,77 @@ export class ZmxBackend implements SessionBackend { } /** - * ZMX has one daemon per session, not one shared server. This value therefore - * describes botmux-owned sessions only and must not be used to infer whether - * another missing ZMX session is a zombie. + * Verify that a name still resolves to the botmux-owned transport for the + * complete session UUID. The PTY-root PID is sampled on both sides of the label + * reads so a same-name replacement cannot be mistaken for the process whose + * labels we just inspected. */ + static probeManagedSession( + name: string, + expectedSessionId: string | undefined, + env: NodeJS.ProcessEnv = zmxEnv(), + ): ZmxManagedSessionProbe { + const beforeSnapshot = ZmxBackend.probeSessions(env); + if (!beforeSnapshot.ok) { + return { state: 'unknown', reason: `无法读取 ZMX 会话 ${name} 的进程信息` }; + } + if (!beforeSnapshot.sessions.includes(name)) { + return beforeSnapshot.unhealthySessions.includes(name) + ? { state: 'unknown', reason: `ZMX 会话 ${name} 当前无响应` } + : { state: 'missing' }; + } + const before = sessionDetailsFromSnapshot(beforeSnapshot, name); + if (!before) return { state: 'unknown', reason: `ZMX 会话 ${name} 的进程信息不唯一` }; + + let transport: string; + let sessionId = ''; + try { + transport = execFileSync('zmx', ['get', name, ZMX_TRANSPORT_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + if (expectedSessionId) { + sessionId = execFileSync('zmx', ['get', name, ZMX_SESSION_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + } + } catch (err) { + return { + state: 'unknown', + reason: `无法读取 ZMX 会话 ${name} 的所有权标签:${err instanceof Error ? err.message : String(err)}`, + }; + } + + const afterSnapshot = ZmxBackend.probeSessions(env); + const after = afterSnapshot.ok ? sessionDetailsFromSnapshot(afterSnapshot, name) : null; + if (!after || after.pid !== before.pid) { + return { state: 'unknown', reason: `ZMX 会话 ${name} 在所有权校验期间发生变化` }; + } + if (transport !== ZMX_TRANSPORT) { + return { + state: 'incompatible', + pid: after.pid, + clients: after.clients, + reason: 'transport-label', + }; + } + if (expectedSessionId && sessionId !== expectedSessionId) { + return { + state: 'incompatible', + pid: after.pid, + clients: after.clients, + reason: 'session-label', + }; + } + return { state: 'compatible', pid: after.pid, clients: after.clients }; + } + + /** ZMX has one daemon per session rather than one shared server. */ static serverState(): 'running' | 'down' | 'unknown' { const probe = ZmxBackend.probeSessions(); if (!probe.ok) return 'unknown'; @@ -185,10 +382,63 @@ export class ZmxBackend implements SessionBackend { try { execFileSync('zmx', ['kill', name, '--force'], { stdio: 'ignore', - timeout: 5000, + timeout: ZMX_COMMAND_TIMEOUT_MS, env: zmxEnv(), }); - } catch { /* already gone */ } + } catch { /* already gone or daemon unavailable */ } + } + + /** Kill only a session whose full botmux identity is still authoritative. */ + static killManagedSession( + name: string, + expectedSessionId: string, + expectedPid?: number, + env: NodeJS.ProcessEnv = zmxEnv(), + ): void { + const probe = ZmxBackend.probeManagedSession(name, expectedSessionId, env); + if (probe.state === 'missing') return; + if (probe.state !== 'compatible') { + throw new Error( + probe.state === 'unknown' + ? probe.reason + : `ZMX 会话 ${name} 的所有权标签不匹配,已拒绝删除`, + ); + } + if (expectedPid !== undefined && probe.pid !== expectedPid) { + throw new Error(`ZMX 会话 ${name} 的 PTY root PID 已变化,已拒绝删除`); + } + execFileSync('zmx', ['kill', name, '--force'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + env, + }); + // Successful ZMX kills print `killed session <name>` (and forced stale + // cleanup also prints a status line). The exit status plus the generation- + // aware convergence probe below are authoritative, not empty stdout. + + const deadline = Date.now() + MANAGED_KILL_TIMEOUT_MS; + let lastUnknownReason: string | null = null; + while (Date.now() < deadline) { + const after = ZmxBackend.probeManagedSession(name, expectedSessionId, env); + if (after.state === 'missing') return; + // A just-removed socket may briefly remain visible while its control + // endpoint already rejects get/list. Treat that as convergence, not as a + // replacement; an incompatible label or changed PID still fails at once. + if (after.state === 'unknown') { + lastUnknownReason = after.reason; + sleepSync(25); + continue; + } + if (after.state === 'incompatible' || after.pid !== probe.pid) { + throw new Error(`ZMX 会话 ${name} 在删除确认期间被同名会话替换`); + } + sleepSync(25); + } + throw new Error( + `ZMX 会话 ${name} 删除确认超时` + + (lastUnknownReason ? `:${lastUnknownReason}` : ''), + ); } static listBotmuxSessions(): string[] { @@ -206,74 +456,88 @@ export class ZmxBackend implements SessionBackend { } spawn(bin: string, args: string[], opts: SpawnOpts): void { - this.lastOpts = { + const frozenOpts: SpawnOpts = { ...opts, env: { ...opts.env }, injectEnv: opts.injectEnv ? { ...opts.injectEnv } : undefined, }; - this.cols = opts.cols; - this.rows = opts.rows; + const controlEnv = zmxControlEnv(frozenOpts); + const probe = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + controlEnv, + ); + if (probe.state === 'unknown') throw new Error(probe.reason); + if (probe.state === 'incompatible') { + this.preserveSessionOnDestroy = true; + throw new Error( + probe.reason === 'transport-label' + ? `ZMX 会话 ${this.sessionName} 缺少 botmux 传输标签(tail 信号 + history 屏幕 + send 输入);已保留,请手动关闭旧会话后重试` + : `ZMX 会话 ${this.sessionName} 属于另一个完整 botmux session;已保留该会话`, + ); + } - const probe = ZmxBackend.probeSession(this.sessionName); - if (probe === 'unknown') { - throw new Error(`无法确认 ZMX 会话 ${this.sessionName} 的状态`); + if (probe.state === 'compatible') { + // Reattach/fresh is frozen by worker before plugin, startup-command and + // isolation decisions. A late same-name winner must not silently turn a + // fresh launch into an attach to a process created under another policy. + if (!this.reattaching) { + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX 会话 ${this.sessionName} 在启动前已出现;已保留并拒绝改变 fresh 决策`); + } + this.backingPid = probe.pid; + this.lastOpts = frozenOpts; + this.launchPid = this.readManagedLaunchPid(frozenOpts, probe.pid); + this.cliPid = this.launchPid; + } else if (this.reattaching) { + // The daemon selected reattach from an earlier probe. Never turn that + // stale decision into a new CLI after the backing session disappears. + throw new Error(`ZMX 会话 ${this.sessionName} 在重连前已消失`); + } else { + this.lastOpts = frozenOpts; } - // An explicit reattach decision is sticky. If the session disappears in - // the probe-to-attach race, the sentinel command exits instead of silently - // creating a duplicate CLI with a fresh shell. - this.reattaching = this.reattaching || probe === 'exists'; - const mode: AttachMode = this.reattaching ? 'reattach' : 'fresh'; logger.debug( - `[zmx:${this.sessionName}] spawn ${mode} ` + - `bin=${bin} args=${JSON.stringify(args)} cwd=${opts.cwd} ${opts.cols}x${opts.rows}`, + `[zmx:${this.sessionName}] spawn ${this.reattaching ? 'reattach' : 'fresh'} ` + + `bin=${bin} args=${JSON.stringify(args)} cwd=${opts.cwd}`, ); - this.openAttach(mode, bin, args, opts); - } - write(data: string): void { - if (!data || this.exited || this.intentionalExit) return; - // Once input has been queued during a reconnect, keep all subsequent input - // behind it until the target is authoritatively live. Letting new bytes go - // direct while the older buffer awaits its probe would reverse FIFO order. - if ( - this.process && - this.state === 'attached' && - !this.recoveryWriteBuffer && - !this.recoveryWriteFlushTimer - ) { - this.process.write(data); + if (this.reattaching) { + const baselineClients = probe.state === 'compatible' ? (probe.clients ?? 0) : 0; + this.startTail(); + if (!this.waitForTailClient(baselineClients)) { + this.stopTailAfterLaunchFailure(); + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`); + } + this.requestHistoryCapture(0, true, true); return; } - const next = this.recoveryWriteBuffer + data; - if (next.length > RECOVERY_WRITE_BUFFER_MAX) { - logger.warn(`[zmx:${this.sessionName}] recovery input buffer full; dropping oldest bytes`); - } - this.recoveryWriteBuffer = next.slice(-RECOVERY_WRITE_BUFFER_MAX); - if (this.process && this.state === 'attached') { - this.scheduleRecoveryWriteFlush(this.epoch, this.process); - } + + this.createFreshSession(bin, args, opts); } - sendText(text: string): void { - this.write(text); + write(data: string): void { + if (!this.sendText(data)) throw new Error(`ZMX 会话 ${this.sessionName} 输入发送失败`); } - sendSpecialKeys(...keys: string[]): void { - for (const key of keys) this.write(tmuxKeyToBytes(key)); + sendText(text: string): boolean { + return this.sendBytes(Buffer.from(text, 'utf8')); } - pasteText(text: string): void { - this.write(`\x1b[200~${text}\x1b[201~`); + sendSpecialKeys(...keys: string[]): boolean { + return this.sendText(keys.map(tmuxKeyToBytes).join('')); } - resize(cols: number, rows: number): void { - this.cols = cols; - this.rows = rows; - try { this.process?.resize(cols, rows); } catch { /* client may be reconnecting */ } - try { this.queryTerminal?.resize(cols, rows); } catch { /* responder may be rotating */ } + pasteText(text: string): void { + if (!this.sendBytes(Buffer.from(`\x1b[200~${text}\x1b[201~`, 'utf8'), true)) { + throw new Error(`ZMX 会话 ${this.sessionName} 粘贴发送失败`); + } } + /** send intentionally never becomes a leader, so botmux cannot resize ZMX. */ + resize(_cols: number, _rows: number): void {} + onData(cb: (data: string) => void): void { this.dataCbs.push(cb); if (this.earlyBuffer) { @@ -283,386 +547,905 @@ export class ZmxBackend implements SessionBackend { } } + onScreenResync(cb: (snapshot: string) => void): void { + this.screenResyncCbs.push(cb); + if (this.pendingScreenResyncReplay) { + this.pendingScreenResyncReplay = false; + try { cb(this.snapshotCache); } catch { /* listener failure must not kill transport */ } + } + } + onExit(cb: (code: number | null, signal: string | null) => void): void { this.exitCbs.push(cb); if (this.pendingExit) { const exit = this.pendingExit; - this.pendingExit = null; - cb(exit.code, exit.signal); + try { cb(exit.code, exit.signal); } catch { /* listener failure must not block teardown */ } } } getChildPid(): number | null { if (this.cliPid) return this.cliPid; - const pid = findSessionPid(this.sessionName); + const pid = this.backingPid ?? findSessionPid(this.sessionName); if (pid) this.cliPid = pid; return pid; } - /** Detach the viewer while leaving the per-session ZMX daemon and CLI alive. */ + /** Best-effort plain-text terminal snapshot supplied by ZMX history. */ + captureCurrentScreen(): string { + return this.hasSnapshot ? this.snapshotCache : ''; + } + + settleCurrentScreen(): Promise<boolean> { + if (this.exited || this.intentionalExit || !this.lastOpts) return Promise.resolve(false); + // If a capture is already in flight, wait for the dirty-latch follow-up, + // not the older sample that may have started before the final output. + const targetSerial = this.historyCaptureSerial + 1; + return new Promise(resolve => { + const timer = setTimeout(() => { + const index = this.historySettleWaiters.findIndex(waiter => waiter.resolve === resolve); + if (index >= 0) this.historySettleWaiters.splice(index, 1); + resolve(false); + }, ZMX_HISTORY_SETTLE_TIMEOUT_MS); + timer.unref?.(); + this.historySettleWaiters.push({ targetSerial, resolve, timer }); + this.requestHistoryCapture(0); + }); + } + + captureViewport(): string { + return this.captureCurrentScreen(); + } + + getPaneSize(): null { + return null; + } + + isPaneAlive(): boolean { + return this.verifyBackingGeneration('liveness').state === 'compatible'; + } + + /** Detach the read-only observer while leaving the ZMX daemon and CLI alive. */ kill(): void { if (this.state === 'stopped' || this.state === 'exited') return; this.intentionalExit = true; this.state = 'stopped'; - this.epoch++; + this.tailEpoch += 1; this.clearReconnectTimer(); - this.clearStableAttachTimer(); - this.clearFreshAttachReadyTimer(); - this.clearRecoveryWriteFlushTimer(); - this.clearTerminalResponder(); - this.recoveryWriteBuffer = ''; - this.recoveryWriteProbeAttempt = 0; - const process = this.process; - this.process = null; - try { process?.kill(); } catch { /* already gone */ } + this.clearStableTailTimer(); + this.stopHistoryPolling(); + const tail = this.tailProcess; + this.tailProcess = null; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } } destroySession(): void { this.kill(); - if ((this.opts.ownsSession ?? true) && !this.preserveSessionOnDestroy) { - ZmxBackend.killSession(this.sessionName); + if (!(this.opts.ownsSession ?? true) || this.preserveSessionOnDestroy) return; + if (!this.opts.sessionId || !this.lastOpts || this.backingPid == null) return; + try { + ZmxBackend.killManagedSession( + this.sessionName, + this.opts.sessionId, + this.backingPid, + zmxControlEnv(this.lastOpts), + ); + } catch (err) { + this.preserveSessionOnDestroy = true; + logger.warn( + `[zmx:${this.sessionName}] refused unsafe destroy: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); } } - private openAttach(mode: AttachMode, bin: string, args: string[], opts: SpawnOpts): void { - this.clearReconnectTimer(); - this.clearStableAttachTimer(); - this.clearFreshAttachReadyTimer(); - this.clearRecoveryWriteFlushTimer(); - const epoch = ++this.epoch; - let launchPayload: ZmxLaunchPayload | null = null; - const zmxArgs = mode === 'fresh' - ? (() => { - launchPayload = createZmxLaunchPayload(bin, args, opts); - return buildFreshAttachArgs(this.sessionName, launchPayload.bootstrapPath); - })() - : buildReattachArgs(this.sessionName); - - let process: pty.IPty; + private createFreshSession(bin: string, args: string[], opts: SpawnOpts): void { + const launch = createZmxLaunchPayload(bin, args, opts); + let released = false; try { - process = pty.spawn('zmx', zmxArgs, { - name: 'xterm-256color', - cols: this.cols, - rows: this.rows, + const result = spawnSync('zmx', buildFreshAttachArgs(this.sessionName, launch.bootstrapPath), { cwd: opts.cwd, - // Per-session and per-bot values are delivered through the 0600 launch - // payload. Keep them out of the long-lived ZMX daemon environment. + // /dev/null makes this a one-shot create client: it never remains as a + // fake terminal leader and never controls the backing PTY dimensions. + stdio: ['ignore', 'ignore', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, env: zmxControlEnv(opts), }); + + const ready = this.waitForFreshReady(launch); + if (!ready) { + const details = sessionDetails(this.sessionName, zmxControlEnv(opts)); + if (!details?.command?.includes(launch.bootstrapPath)) { + this.preserveSessionOnDestroy = true; + } + const stderr = result.stderr?.toString('utf8').trim(); + throw new Error( + `ZMX 会话 ${this.sessionName} 启动握手超时` + (stderr ? `:${stderr}` : ''), + ); + } + + const created = sessionDetails(this.sessionName, zmxControlEnv(opts)); + if (!created || !created.command?.includes(launch.bootstrapPath)) { + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX 会话 ${this.sessionName} 的 fresh 所有权握手失效;已保留同名会话`); + } + this.backingPid = created.pid; + const launchPid = this.waitForFreshLaunchPid(launch); + if (launchPid == null) { + throw new Error(`ZMX 会话 ${this.sessionName} 未能确认稳定的 CLI launch 子进程`); + } + this.launchPid = launchPid; + this.cliPid = launchPid; + + this.stampProtocolLabels(opts, launchPid); + const managed = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + zmxControlEnv(opts), + ); + if (managed.state !== 'compatible' || managed.pid !== this.backingPid) { + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX 会话 ${this.sessionName} 在 release 前未通过完整身份校验`); + } + const baselineClients = managed.clients ?? 0; + this.startTail(); + if (!this.waitForTailClient(baselineClients)) { + throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`); + } + + // The bootstrap performs one read after observing the release file. A + // direct write has an open-before-content race, so publish a complete + // token atomically within the same private directory. + writeFileSync(launch.releaseTempPath, `${launch.releaseToken}\n`, { mode: 0o600, flag: 'wx' }); + renameSync(launch.releaseTempPath, launch.releasePath); + released = true; + // The bootstrap can now launch the CLI. Polling is required even when + // tail emits no bytes: upstream currently drops pure UTF-8 output while + // stripping ANSI, whereas history preserves it. + this.requestHistoryCapture(0, true, true); } catch (err) { - launchPayload?.cleanup(); + this.stopTailAfterLaunchFailure(); + launch.cleanup(); + // Before release, never kill by name: the bounded private bootstrap exits + // on its own and a same-name race winner must be preserved. After release + // the CLI may already be running, so tear down only through the frozen + // full-session identity and PTY-root PID. + if (released && this.opts.sessionId && this.backingPid != null) { + try { + ZmxBackend.killManagedSession( + this.sessionName, + this.opts.sessionId, + this.backingPid, + zmxControlEnv(opts), + ); + } catch (killErr) { + this.preserveSessionOnDestroy = true; + logger.warn( + `[zmx:${this.sessionName}] failed to tear down released launch after handshake error: ` + + `${killErr instanceof Error ? killErr.message : String(killErr)}`, + ); + } + } + this.lastOpts = null; + this.backingPid = null; + this.launchPid = null; + this.cliPid = undefined; throw err; } - if (launchPayload) { - const payload = launchPayload as ZmxLaunchPayload; - const cleanupTimer = setTimeout(payload.cleanup, LAUNCH_PAYLOAD_CLEANUP_MS); - cleanupTimer.unref?.(); + } + + private waitForFreshReady(launch: ZmxLaunchPayload): boolean { + const deadline = Date.now() + FRESH_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const value = readFileSync(launch.readyPath, 'utf8').trim(); + if (value === launch.readyNonce) return true; + } catch { /* bootstrap has not reached the gate yet */ } + sleepSync(25); } - this.process = process; - this.state = 'connecting'; - this.recoveryWriteProbeAttempt = 0; - this.resetTerminalResponder(process, epoch); - - let freshReadyMarker = launchPayload?.readyMarker ?? null; - let freshCompletionMarker = launchPayload?.completionMarker ?? null; - let freshReleaseSent = false; - let freshReadyBuffer = ''; - let reattachSniffing = mode === 'reattach'; - let reattachSniffBuffer = ''; - let reattachMarkerTail = ''; - - const acceptAttachedData = (attachedData: string) => { - if (epoch !== this.epoch || this.process !== process || this.intentionalExit || this.exited) return; - this.state = 'attached'; - if (!this.stableAttachTimer) { - this.stableAttachTimer = setTimeout(() => { - this.stableAttachTimer = null; - if (epoch === this.epoch && this.state === 'attached') this.reconnectAttempt = 0; - }, 5000); - this.stableAttachTimer.unref?.(); - } - // Keep one authoritative headless terminal beside the attach transport. - // It tracks cursor state and answers terminal queries even with no browser - // connected. ZMX web terminals register no-reply parser handlers so they - // render these zero-width queries without becoming a second responder. - if (attachedData) { - this.queryTerminal?.write(attachedData); - this.emitData(attachedData); + return false; + } + + private waitForFreshLaunchPid(launch: ZmxLaunchPayload): number | null { + const deadline = Date.now() + FRESH_CLI_PID_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const pid = Number(readFileSync(launch.cliPidPath, 'utf8').trim()); + if ( + Number.isSafeInteger(pid) + && pid > 0 + && this.backingPid != null + && readProcessParentPid(pid) === this.backingPid + ) { + return pid; + } + } catch { /* bootstrap has not published a complete pid yet */ } + sleepSync(25); + } + return null; + } + + private readManagedLaunchPid(opts: SpawnOpts, expectedBackingPid: number): number { + let raw: string; + try { + raw = execFileSync('zmx', ['get', this.sessionName, ZMX_LAUNCH_PID_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env: zmxControlEnv(opts), + }).trim(); + } catch (err) { + throw new Error( + `ZMX 会话 ${this.sessionName} 缺少 launch PID 标签:` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + const pid = Number(raw); + if ( + !Number.isSafeInteger(pid) + || pid <= 0 + || readProcessParentPid(pid) !== expectedBackingPid + ) { + throw new Error(`ZMX 会话 ${this.sessionName} 的 launch PID 标签无效或已脱离 PTY root`); + } + const after = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + zmxControlEnv(opts), + ); + if (after.state !== 'compatible' || after.pid !== expectedBackingPid) { + throw new Error(`ZMX 会话 ${this.sessionName} 在 launch PID 校验期间发生变化`); + } + return pid; + } + + private waitForTailClient(baselineClients: number): boolean { + const deadline = Date.now() + TAIL_CONNECT_TIMEOUT_MS; + while (Date.now() < deadline) { + const details = sessionDetails( + this.sessionName, + this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(), + ); + if (details && this.backingPid != null && details.pid !== this.backingPid) { + this.preserveSessionOnDestroy = true; + return false; } - this.scheduleRecoveryWriteFlush(epoch, process); - }; + if (details?.clients != null && details.clients > baselineClients) return true; + if (!details && ZmxBackend.probeSession( + this.sessionName, + this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(), + ) === 'missing') return false; + sleepSync(25); + } + return false; + } - const quarantineBootstrapAttach = (reason: string) => { - if ( - epoch !== this.epoch || - this.process !== process || - this.intentionalExit || - this.exited - ) return; - freshReadyMarker = null; - freshCompletionMarker = null; - freshReadyBuffer = ''; - reattachSniffing = false; - reattachSniffBuffer = ''; - reattachMarkerTail = ''; - this.clearFreshAttachReadyTimer(); - this.clearStableAttachTimer(); - this.clearRecoveryWriteFlushTimer(); - this.clearTerminalResponder(); - // Before the private release token has been sent it is safe to remove the - // payload. cleanup() deliberately leaves the watchdog guard directory in - // place, so the bootstrap still times out and terminates its ZMX session. - if (!freshReleaseSent) launchPayload?.cleanup(); - logger.error(`[zmx:${this.sessionName}] quarantining unverified bootstrap attach: ${reason}`); - this.preserveSessionOnDestroy = true; + private stampProtocolLabels(opts: SpawnOpts, launchPid: number): void { + const labels = [`${ZMX_TRANSPORT_LABEL}=${ZMX_TRANSPORT}`]; + if (this.opts.sessionId) labels.push(`${ZMX_SESSION_LABEL}=${this.opts.sessionId}`); + labels.push(`${ZMX_LAUNCH_PID_LABEL}=${launchPid}`); + const stdout = execFileSync('zmx', ['set', this.sessionName, ...labels], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + env: zmxControlEnv(opts), + }); + if (stdout.trim()) { + throw new Error(`ZMX 协议标签写入返回异常:${stdout.trim()}`); + } + } - // Detach this viewer without killing the named session: the ready marker - // may have come from a same-name session owned by another checkout. The - // private bootstrap watchdog is solely responsible for deleting its own - // payload and terminating its own session. Emit one exit only after that - // session is authoritatively missing, so the daemon can restart the worker - // and recompute fresh-vs-reattach from scratch. If it never disappears, - // remain quarantined instead of auto-restarting into a foreign session. - const quarantineEpoch = ++this.epoch; - if (this.process === process) this.process = null; - this.state = 'recovering'; - try { process.kill(); } catch { /* already detached */ } - const deadline = Date.now() + STALE_BOOTSTRAP_WAIT_MAX_MS; - let overdueWarningEmitted = false; - const poll = () => { - this.reconnectTimer = null; - if ( - quarantineEpoch !== this.epoch || - this.intentionalExit || - this.exited - ) return; - const probe = ZmxBackend.probeSession(this.sessionName); - if (probe === 'missing') { - this.fireExit(75, null); - return; - } - if (Date.now() >= deadline && !overdueWarningEmitted) { - overdueWarningEmitted = true; - logger.error( - `[zmx:${this.sessionName}] quarantined bootstrap did not disappear ` + - `(${probe}); continuing to refuse attach/input until ownership is safe`, - ); - launchPayload?.cleanup(); - } - this.reconnectTimer = setTimeout( - poll, - overdueWarningEmitted ? RECOVERY_DELAY_MAX_MS : STALE_BOOTSTRAP_POLL_MS, - ); - this.reconnectTimer.unref?.(); + private verifyBackingIdentity(context: string): BackingIdentityProbe { + if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) { + return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` }; + } + const wasAlive = this.isBackingProcessAlive(); + let transport: string; + let sessionId: string; + try { + const env = zmxControlEnv(this.lastOpts); + transport = execFileSync('zmx', ['get', this.sessionName, ZMX_TRANSPORT_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + sessionId = execFileSync('zmx', ['get', this.sessionName, ZMX_SESSION_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + } catch (err) { + if (!wasAlive || !this.isBackingProcessAlive()) { + this.fireExit(0, null); + return { state: 'missing' }; + } + return { + state: 'unknown', + reason: `无法读取 ZMX 会话 ${this.sessionName} 的所有权标签:` + + `${err instanceof Error ? err.message : String(err)}`, }; - this.reconnectTimer = setTimeout(poll, STALE_BOOTSTRAP_POLL_MS); - this.reconnectTimer.unref?.(); - }; + } + if (transport !== ZMX_TRANSPORT || sessionId !== this.opts.sessionId) { + return this.rejectBackingReplacement(context, '完整 session / transport 标签已变化'); + } + if (!wasAlive || !this.isBackingProcessAlive()) { + this.fireExit(0, null); + return { state: 'missing' }; + } + return { state: 'compatible', clients: null }; + } - const failFreshAttach = (reason: string) => { - quarantineBootstrapAttach(`fresh ownership check failed: ${reason}`); - }; + /** Full list sampling is reserved for lifecycle edges that need client count. + * Hot send/history paths use target-scoped labels + the frozen PTY-root PID, + * so one unrelated unhealthy ZMX socket cannot freeze every session. */ + private verifyBackingIdentityWithClients(context: string): BackingIdentityProbe { + if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) { + return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` }; + } + const probe = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + zmxControlEnv(this.lastOpts), + ); + if (probe.state === 'missing') { + this.fireExit(0, null); + return probe; + } + if (probe.state === 'unknown') return probe; + if (probe.state === 'incompatible' || probe.pid !== this.backingPid) { + const reason = probe.state === 'incompatible' + ? '完整 session / transport 标签已变化' + : `PTY root PID 已从 ${this.backingPid} 变化为 ${probe.pid}`; + return this.rejectBackingReplacement(context, reason); + } + return { state: 'compatible', clients: probe.clients }; + } - const armFreshAttachReadyTimer = (reason: string) => { - this.clearFreshAttachReadyTimer(); - this.freshAttachReadyTimer = setTimeout(() => { - failFreshAttach(reason); - }, FRESH_ATTACH_READY_TIMEOUT_MS); - this.freshAttachReadyTimer.unref?.(); - }; - if (freshReadyMarker) { - armFreshAttachReadyTimer('bootstrap ready marker timed out'); - } else if (reattachSniffing) { - // A worker can die after creating the private bootstrap but before sending - // its nonce-bound release token. The surviving ZMX child then repeats a - // ready OSC until its watchdog fires. Briefly quarantine every reattach so - // that marker can be recognized across chunk boundaries before any output - // is exposed or any queued user input is flushed. - this.freshAttachReadyTimer = setTimeout(() => { - this.freshAttachReadyTimer = null; - if ( - epoch !== this.epoch || - this.process !== process || - this.intentionalExit || - this.exited || - !reattachSniffing - ) return; - reattachSniffing = false; - const buffered = reattachSniffBuffer; - reattachSniffBuffer = ''; - acceptAttachedData(buffered); - }, REATTACH_BOOTSTRAP_SNIFF_MS); - this.freshAttachReadyTimer.unref?.(); + /** Fast generation check for high-frequency read-only snapshots. */ + private verifyBackingGeneration(context: string): BackingIdentityProbe { + return this.verifyBackingIdentity(context); + } + + private isBackingProcessAlive(): boolean { + if (this.backingPid == null) return false; + try { + process.kill(this.backingPid, 0); + return true; + } catch (err) { + return !!err && typeof err === 'object' && 'code' in err && err.code === 'EPERM'; } + } + + private rejectBackingReplacement(context: string, reason: string): BackingIdentityProbe { + this.preserveSessionOnDestroy = true; + logger.error(`[zmx:${this.sessionName}] ${context} refused: ${reason}`); + this.fireExit(75, null); + return { state: 'replaced', reason }; + } - process.onData((data) => { - if (epoch !== this.epoch || this.intentionalExit || this.exited) return; - - // Keep scanning after the initial quarantine too. The bootstrap emits at - // 50 ms intervals, but an overloaded host can delay delivery beyond the - // sniff window. Its independent release token means ordinary input still - // cannot launch the CLI; a late marker is quarantined before this chunk is - // forwarded and the watchdog is allowed to reap the stale session. - const markerScan = reattachMarkerTail + data; - if (mode === 'reattach' && ZMX_READY_MARKER_RE.test(markerScan)) { - quarantineBootstrapAttach('stale private bootstrap marker detected during reattach'); + private labelsMatchBacking(raw: string): boolean { + const labels = new Map<string, string>(); + for (const pair of raw.trim().split(/\s+/)) { + if (!pair) continue; + const equals = pair.indexOf('='); + if (equals <= 0) continue; + labels.set(pair.slice(0, equals), pair.slice(equals + 1)); + } + return labels.get(ZMX_TRANSPORT_LABEL) === ZMX_TRANSPORT + && labels.get(ZMX_SESSION_LABEL) === this.opts.sessionId; + } + + private readLabelsAsync(generation: number): Promise<string> { + return new Promise((resolve, reject) => { + if (!this.lastOpts || generation !== this.historyGeneration) { + reject(new Error('history capture generation changed')); return; } - reattachMarkerTail = markerScan.slice(-ZMX_READY_MARKER_MAX); - if (reattachSniffing) { - reattachSniffBuffer += data; - if (reattachSniffBuffer.length > FRESH_ATTACH_READY_BUFFER_MAX) { - quarantineBootstrapAttach('reattach bootstrap sniff buffer exceeded limit'); + const child = execFile('zmx', ['get', this.sessionName], { + encoding: 'utf8', + timeout: ZMX_HISTORY_TIMEOUT_MS, + maxBuffer: 64 * 1024, + env: zmxControlEnv(this.lastOpts), + }, (err, stdout, stderr) => { + if (this.historyProcess === child) this.historyProcess = null; + if (generation !== this.historyGeneration) { + reject(new Error('history capture generation changed')); + return; } - return; - } - - let attachedData = data; - if (freshReadyMarker && freshCompletionMarker) { - freshReadyBuffer += data; - if (freshReadyBuffer.length > FRESH_ATTACH_READY_BUFFER_MAX) { - failFreshAttach('bootstrap ready buffer exceeded limit'); + const errorText = stderr?.toString().trim() ?? ''; + if (err || errorText) { + reject(err ?? new Error(errorText)); return; } + resolve(stdout.toString()); + }); + this.historyProcess = child; + }); + } - if (!freshReleaseSent) { - if (!freshReadyBuffer.includes(freshReadyMarker)) return; - freshReleaseSent = true; - // Only our private bootstrap can emit the nonce. Releasing its read - // barrier here proves this PTY created the session instead of attaching - // to a same-name session that appeared after the liveness probe. - try { - process.write(`${launchPayload!.releaseToken}\r`); - } catch { - failFreshAttach('could not release bootstrap barrier'); - return; + private captureHistoryFileAsync(generation: number): Promise<string> { + return new Promise((resolve, reject) => { + if (!this.lastOpts || generation !== this.historyGeneration) { + reject(new Error('history capture generation changed')); + return; + } + let historyDir: string | undefined; + let historyPath: string | undefined; + let historyFd: number | undefined; + let child: ChildProcess | null = null; + let timeout: NodeJS.Timeout | null = null; + let settled = false; + let stderrTail = ''; + + const cleanup = () => { + if (timeout) clearTimeout(timeout); + timeout = null; + if (this.historyProcess === child) this.historyProcess = null; + if (historyFd !== undefined) { + try { closeSync(historyFd); } catch { /* best effort */ } + } + if (historyPath) { + try { rmSync(historyPath, { force: true }); } catch { /* best effort */ } + } + if (historyDir) { + try { rmdirSync(historyDir); } catch { /* best effort */ } + } + }; + const finish = (err?: Error) => { + if (settled) return; + settled = true; + try { + if (err) throw err; + if (generation !== this.historyGeneration || historyFd === undefined) { + throw new Error('history capture generation changed'); + } + const size = fstatSync(historyFd).size; + const length = Math.min(size, ZMX_HISTORY_MAX_BYTES); + const data = Buffer.allocUnsafe(length); + let bytesRead = 0; + while (bytesRead < length) { + const count = readSync( + historyFd, + data, + bytesRead, + length - bytesRead, + size - length + bytesRead, + ); + if (count === 0) break; + bytesRead += count; + } + let bounded = data.subarray(0, bytesRead); + if (size > length) { + const firstLf = bounded.indexOf(0x0a); + if (firstLf >= 0) bounded = bounded.subarray(firstLf + 1); } - // The bootstrap repeats its ready marker because output produced - // before the first ZMX client connects is not replayed. Wait for its - // post-release completion marker so no late repeat can leak through. - armFreshAttachReadyTimer('bootstrap completion marker timed out'); + resolve(normaliseZmxHistory(bounded.toString('utf8'))); + } catch (error) { + reject(error); + } finally { + cleanup(); } + }; - const completionIndex = freshReadyBuffer.indexOf(freshCompletionMarker); - if (completionIndex < 0) return; - attachedData = - freshReadyBuffer.slice(0, completionIndex) + - freshReadyBuffer.slice(completionIndex + freshCompletionMarker.length); - attachedData = attachedData.split(freshReadyMarker).join(''); - freshReadyMarker = null; - freshCompletionMarker = null; - freshReadyBuffer = ''; - this.clearFreshAttachReadyTimer(); + try { + historyDir = mkdtempSync(join(tmpdir(), 'botmux-zmx-history-')); + chmodSync(historyDir, 0o700); + historyPath = join(historyDir, 'history.txt'); + historyFd = openSync(historyPath, 'wx+', 0o600); + // Keep transcript bytes reachable only through the open fd. A private + // regular file also avoids PR #202's single-write pipe truncation. + rmSync(historyPath); + child = spawn('zmx', ['history', this.sessionName], { + stdio: ['ignore', historyFd, 'pipe'], + env: zmxControlEnv(this.lastOpts), + }); + this.historyProcess = child; + child.stderr?.on('data', (chunk: Buffer | string) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4096); + }); + child.once('error', error => finish(error)); + child.once('close', (code, signal) => { + const stderr = stderrTail.trim(); + if (code !== 0 || signal || stderr) { + finish(new Error(stderr || `zmx history exited status=${code} signal=${signal}`)); + return; + } + finish(); + }); + timeout = setTimeout(() => { + try { child?.kill('SIGKILL'); } catch { /* already gone */ } + finish(new Error(`zmx history timed out after ${ZMX_HISTORY_TIMEOUT_MS}ms`)); + }, ZMX_HISTORY_TIMEOUT_MS); + timeout.unref?.(); + } catch (err) { + finish(err instanceof Error ? err : new Error(String(err))); } - acceptAttachedData(attachedData); }); - process.onExit(({ exitCode, signal }) => { - if (epoch !== this.epoch || this.intentionalExit || this.exited) return; - const freshAttachWasUnverified = freshCompletionMarker !== null; - freshReadyMarker = null; - freshCompletionMarker = null; - freshReadyBuffer = ''; - this.clearFreshAttachReadyTimer(); - this.clearStableAttachTimer(); - this.clearRecoveryWriteFlushTimer(); - this.clearTerminalResponder(); - if (this.process === process) this.process = null; - if (freshAttachWasUnverified) { - launchPayload?.cleanup(); - // An unverified fresh attach may have hit a foreign same-name session. - // Never reconnect to it; surface a deterministic launch failure. - this.fireExit(exitCode === 0 ? 75 : exitCode, null); + } + + private async readHistorySnapshotAsync(generation: number): Promise<string | null> { + if (this.exited || this.intentionalExit || !this.lastOpts) return null; + try { + if (!this.isBackingProcessAlive()) { + this.fireExit(0, null); + return null; + } + const before = await this.readLabelsAsync(generation); + if (!this.labelsMatchBacking(before)) { + this.rejectBackingReplacement('history', '完整 session / transport 标签已变化'); + return null; + } + const snapshot = await this.captureHistoryFileAsync(generation); + const after = await this.readLabelsAsync(generation); + if (!this.labelsMatchBacking(after) || !this.isBackingProcessAlive()) { + if (!this.isBackingProcessAlive()) this.fireExit(0, null); + else this.rejectBackingReplacement('history completion', '完整 session / transport 标签已变化'); + return null; + } + return snapshot; + } catch (err) { + if (generation !== this.historyGeneration || this.intentionalExit || this.exited) return null; + logger.warn( + `[zmx:${this.sessionName}] history capture failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + if (!this.isBackingProcessAlive()) this.fireExit(0, null); + return null; + } + } + + private requestHistoryCapture( + delayMs: number, + activity = false, + forceResync = false, + ): void { + if (this.exited || this.intentionalExit || !this.lastOpts) return; + if (activity) { + this.tailActivitySinceCapture = true; + this.stableHistoryPolls = 0; + } + if (forceResync) this.forceResyncOnNextSnapshot = true; + if (this.historyInFlight) { + this.historyAgain = true; + this.historyAgainActivity ||= activity; + this.historyAgainForceResync ||= forceResync; + return; + } + const dueAt = Date.now() + Math.max(0, delayMs); + if (this.historyTimer && this.historyTimerDueAt <= dueAt) return; + if (this.historyTimer) clearTimeout(this.historyTimer); + this.historyTimerDueAt = dueAt; + this.historyTimer = setTimeout(() => { + this.historyTimer = null; + this.historyTimerDueAt = 0; + void this.runHistoryCapture(); + }, Math.max(0, delayMs)); + this.historyTimer.unref?.(); + } + + private async runHistoryCapture(): Promise<void> { + if (this.historyInFlight || this.exited || this.intentionalExit || !this.lastOpts) return; + this.historyInFlight = true; + const captureSerial = ++this.historyCaptureSerial; + const generation = this.historyGeneration; + const activity = this.tailActivitySinceCapture; + const forceResync = this.forceResyncOnNextSnapshot; + this.tailActivitySinceCapture = false; + this.forceResyncOnNextSnapshot = false; + let snapshot: string | null = null; + let captureUsable = false; + try { + snapshot = await this.readHistorySnapshotAsync(generation); + if (snapshot !== null && generation === this.historyGeneration) { + captureUsable = this.publishHistorySnapshot(snapshot, activity, forceResync); + } else { + this.stableHistoryPolls = 0; + } + if (!captureUsable) { + // A failed/ambiguous capture must not consume the wakeup that caused + // it. Preserve both obligations for the next usable history sample. + this.tailActivitySinceCapture ||= activity; + this.forceResyncOnNextSnapshot ||= forceResync; + } + } finally { + if (generation !== this.historyGeneration) return; + const again = this.historyAgain; + const againActivity = this.historyAgainActivity; + const againForceResync = this.historyAgainForceResync; + this.historyAgain = false; + this.historyAgainActivity = false; + this.historyAgainForceResync = false; + // A capture dirtied while in flight predates output that arrived after + // it started. Settle waiters must remain pending for the latched follow- + // up rather than finalizing from this stale-but-otherwise-usable sample. + if (!again) this.resolveHistorySettleWaiters(captureSerial, captureUsable); + this.historyInFlight = false; + if (this.exited || this.intentionalExit || !this.lastOpts) return; + if (again) { + // Preserve the dirty-latch obligation, but give the single-threaded + // daemon a short breathing window before serializing history again. + // Continuous tail chunks otherwise turn a long transcript into a + // back-to-back history loop that can starve concurrent `send` probes. + this.requestHistoryCapture( + HISTORY_TAIL_DEBOUNCE_MS, + againActivity, + againForceResync, + ); return; } - this.state = 'recovering'; - this.scheduleRecovery( - exitCode, - signal !== undefined && signal !== null && signal !== 0 ? String(signal) : null, + const nextDelay = this.stableHistoryPolls >= HISTORY_STABLE_POLLS_BEFORE_COLD + ? HISTORY_COLD_POLL_MS + this.historyColdJitterMs + : HISTORY_HOT_POLL_MS; + this.requestHistoryCapture(nextDelay); + } + } + + private publishHistorySnapshot(snapshot: string, activity: boolean, forceResync: boolean): boolean { + // Current ZMX history failures can exit 0 with empty stdout. Never let an + // ambiguous empty capture erase a previously non-empty authoritative view. + if (snapshot.length === 0 && this.hasSnapshot && this.snapshotCache.length > 0) { + this.stableHistoryPolls = 0; + return false; + } + if (!this.hasSnapshot) { + this.hasSnapshot = true; + this.snapshotCache = snapshot; + this.stableHistoryPolls = snapshot.length === 0 ? 1 : 0; + if (forceResync) this.emitScreenResync(snapshot); + else if (snapshot) this.emitData(snapshot); + return true; + } + + const previous = this.snapshotCache; + if (snapshot === previous) { + this.stableHistoryPolls += 1; + // Tail can signal a cursor-only redraw that leaves plain history equal. + // Rebase derived state so idle detection observes the activity without + // trusting any (possibly UTF-8-corrupted) tail payload bytes. + if (activity) this.emitScreenResync(snapshot); + return true; + } + + this.snapshotCache = snapshot; + this.stableHistoryPolls = 0; + if (!forceResync && snapshot.startsWith(previous)) { + const delta = snapshot.slice(previous.length); + if (delta) this.emitData(delta); + return true; + } + this.emitScreenResync(snapshot); + return true; + } + + private stopHistoryPolling(): void { + this.historyGeneration += 1; + if (this.historyTimer) clearTimeout(this.historyTimer); + this.historyTimer = null; + this.historyTimerDueAt = 0; + const child = this.historyProcess; + this.historyProcess = null; + try { child?.kill('SIGKILL'); } catch { /* already gone */ } + this.historyInFlight = false; + this.historyAgain = false; + this.historyAgainActivity = false; + this.historyAgainForceResync = false; + this.tailActivitySinceCapture = false; + this.forceResyncOnNextSnapshot = false; + for (const waiter of this.historySettleWaiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.resolve(false); + } + } + + private resolveHistorySettleWaiters(captureSerial: number, success: boolean): void { + for (let i = this.historySettleWaiters.length - 1; i >= 0; i -= 1) { + const waiter = this.historySettleWaiters[i]!; + if (waiter.targetSerial > captureSerial) continue; + this.historySettleWaiters.splice(i, 1); + clearTimeout(waiter.timer); + waiter.resolve(success); + } + } + + private emitScreenResync(snapshot: string): void { + if (this.screenResyncCbs.length === 0) { + this.pendingScreenResyncReplay = true; + return; + } + for (const cb of this.screenResyncCbs) { + try { cb(snapshot); } catch { /* listener failure must not kill recovery */ } + } + } + + private sendBytes(bytes: Buffer, bracketedPaste = false): boolean { + if (bytes.length === 0) return true; + if (this.exited || this.intentionalExit || !this.lastOpts) return false; + if (bytes.length > ZMX_SEND_MAX_BYTES) { + throw new Error( + `ZMX 单次输入 ${bytes.length} 字节超过 ${ZMX_SEND_MAX_BYTES} 字节安全上限;` + + '当前 send 协议没有 ACK/backpressure,已在写入任何前缀前拒绝', + ); + } + const identity = this.verifyBackingIdentity('send'); + if (identity.state !== 'compatible') { + if (identity.state === 'unknown') { + logger.warn(`[zmx:${this.sessionName}] send blocked: ${identity.reason}`); + } + return false; + } + + for (let offset = 0; offset < bytes.length; offset += ZMX_SEND_CHUNK_BYTES) { + const chunk = bytes.subarray(offset, Math.min(offset + ZMX_SEND_CHUNK_BYTES, bytes.length)); + // ZMX strips exactly one trailing LF from piped stdin. Appending our own + // framing LF therefore preserves the caller's original bytes exactly, + // including an original trailing LF, while keeping secrets out of argv. + const input = Buffer.concat([chunk, Buffer.from('\n')]); + try { + const stdout = execFileSync('zmx', ['send', this.sessionName], { + input, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: zmxControlEnv(this.lastOpts), + }); + // Several ZMX control-plane failures are reported on stdout with exit + // status 0. Empty stdout is part of the transport contract. + if (stdout.trim()) { + logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`); + this.verifyBackingIdentity('send rejection'); + if (bracketedPaste || offset > 0) this.abortPartialSend(bracketedPaste); + return false; + } + } catch (err) { + const probe = this.verifyBackingIdentity('send failure'); + logger.warn( + `[zmx:${this.sessionName}] send failed (${probe.state}): ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + // Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying + // can duplicate a prompt that the daemon already queued. + if (bracketedPaste || offset > 0) this.abortPartialSend(bracketedPaste); + return false; + } + } + this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true); + return true; + } + + private abortPartialSend(bracketedPaste: boolean): void { + if (!this.lastOpts) return; + // A failed multi-frame paste may have delivered the opening marker but not + // its close. Best-effort close it first, then cancel the partial composer + // so a later retry cannot append to/trivially submit truncated input. + const recovery = bracketedPaste ? '\x1b[201~\x03' : '\x03'; + try { + const stdout = execFileSync('zmx', ['send', this.sessionName], { + input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: zmxControlEnv(this.lastOpts), + }); + if (stdout.trim()) throw new Error(stdout.trim()); + logger.warn(`[zmx:${this.sessionName}] cancelled a partially delivered input sequence`); + } catch (err) { + logger.error( + `[zmx:${this.sessionName}] unable to cancel partially delivered input; ` + + `manual session recovery may be required: ${err instanceof Error ? err.message : String(err)}`, ); + } + this.requestHistoryCapture(0, true, true); + } + + private startTail(): void { + this.clearReconnectTimer(); + const epoch = ++this.tailEpoch; + const child = spawn('zmx', ['tail', this.sessionName], { + cwd: this.lastOpts?.cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(), + }); + this.tailProcess = child; + this.state = 'observing'; + this.clearStableTailTimer(); + this.stableTailTimer = setTimeout(() => { + this.stableTailTimer = null; + if (epoch === this.tailEpoch && this.tailProcess === child && this.state === 'observing') { + this.reconnectAttempt = 0; + } + }, 5000); + this.stableTailTimer.unref?.(); + let settled = false; + let stderrTail = ''; + + child.stdout?.on('data', (chunk: Buffer | string) => { + if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited) return; + // Drain the stream, but never expose its bytes. Upstream's ANSI stripper + // currently deletes UTF-8 and can even consume following ASCII; only the + // existence of a chunk is trustworthy enough to wake history capture. + if ((typeof chunk === 'string' ? chunk.length : chunk.byteLength) > 0) { + this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true); + } + }); + child.stderr?.on('data', (chunk: Buffer | string) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4096); }); + const finish = (code: number | null, signal: NodeJS.Signals | null, err?: Error) => { + if (settled) return; + settled = true; + if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited) return; + this.tailProcess = null; + this.state = 'recovering'; + this.clearStableTailTimer(); + if (err || stderrTail.trim()) { + logger.warn( + `[zmx:${this.sessionName}] tail ended: ` + + `${err?.message ?? stderrTail.trim()}`, + ); + } + this.scheduleTailRecovery(code, signal); + }; + child.once('error', err => finish(null, null, err)); + child.once('close', (code, signal) => finish(code, signal)); } - private scheduleRecovery(code: number | null, signal: string | null): void { + private scheduleTailRecovery(code: number | null, signal: string | null): void { if (this.intentionalExit || this.exited || !this.lastOpts) return; - const delay = Math.min(50 * (2 ** this.reconnectAttempt), RECOVERY_DELAY_MAX_MS); - this.reconnectAttempt++; + const delay = Math.min(50 * (2 ** this.reconnectAttempt), TAIL_RECOVERY_DELAY_MAX_MS); + this.reconnectAttempt += 1; this.clearReconnectTimer(); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (this.intentionalExit || this.exited || !this.lastOpts) return; - - const probe = ZmxBackend.probeSession(this.sessionName); - if (probe === 'missing') { - this.fireExit(code, signal); + const probe = this.verifyBackingIdentityWithClients('tail recovery'); + if (probe.state === 'missing' || probe.state === 'replaced') return; + if (probe.state === 'unknown') { + this.scheduleTailRecovery(code, signal); return; } - if (probe === 'unknown') { - this.scheduleRecovery(code, signal); - return; - } - - logger.warn(`[zmx:${this.sessionName}] attach client exited while session is alive; reconnecting`); try { - this.openAttach('reattach', '/bin/sh', [], this.lastOpts); + logger.warn(`[zmx:${this.sessionName}] tail observer exited while session lives; reconnecting`); + const baselineClients = probe.clients ?? 0; + this.startTail(); + if (!this.waitForTailClient(baselineClients)) { + throw new Error('replacement tail did not become a connected client'); + } + this.requestHistoryCapture(0, true, true); } catch (err) { + if (this.intentionalExit || this.exited) return; + this.stopTailForRecovery(); logger.warn( - `[zmx:${this.sessionName}] attach reconnect failed: ` + + `[zmx:${this.sessionName}] tail restart failed: ` + `${err instanceof Error ? err.message : String(err)}`, ); - this.scheduleRecovery(code, signal); + this.scheduleTailRecovery(code, signal); } }, delay); this.reconnectTimer.unref?.(); } - private scheduleRecoveryWriteFlush(epoch: number, process: pty.IPty): void { - if ( - !this.recoveryWriteBuffer || - this.recoveryWriteFlushTimer || - epoch !== this.epoch || - this.process !== process || - this.state !== 'attached' - ) return; - - // A reattach TOCTOU sentinel can emit a clear frame before its short-lived - // session exits. Wait for a still-live target before releasing input. An - // inconclusive control-plane probe re-arms with bounded backoff: a quiet - // attach must not strand the buffer forever merely because its first probe - // timed out. - const delay = Math.min( - RECOVERY_WRITE_FLUSH_DELAY_MS * (2 ** this.recoveryWriteProbeAttempt), - RECOVERY_DELAY_MAX_MS, - ); - this.recoveryWriteFlushTimer = setTimeout(() => { - this.recoveryWriteFlushTimer = null; - if ( - epoch !== this.epoch || - this.process !== process || - this.state !== 'attached' || - !this.recoveryWriteBuffer - ) return; - - const probe = ZmxBackend.probeSession(this.sessionName); - if (probe === 'unknown') { - this.recoveryWriteProbeAttempt++; - this.scheduleRecoveryWriteFlush(epoch, process); - return; - } - if (probe === 'missing') return; + private stopTailAfterLaunchFailure(): void { + this.tailEpoch += 1; + this.clearStableTailTimer(); + this.stopHistoryPolling(); + const tail = this.tailProcess; + this.tailProcess = null; + this.state = 'idle'; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } + } - this.recoveryWriteProbeAttempt = 0; - const buffered = this.recoveryWriteBuffer; - this.recoveryWriteBuffer = ''; - try { - process.write(buffered); - } catch { - this.recoveryWriteBuffer = buffered + this.recoveryWriteBuffer; - this.scheduleRecoveryWriteFlush(epoch, process); - } - }, delay); - this.recoveryWriteFlushTimer.unref?.(); + private stopTailForRecovery(): void { + this.tailEpoch += 1; + this.clearStableTailTimer(); + const tail = this.tailProcess; + this.tailProcess = null; + this.state = 'recovering'; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } } private clearReconnectTimer(): void { @@ -671,66 +1454,17 @@ export class ZmxBackend implements SessionBackend { this.reconnectTimer = null; } - private clearStableAttachTimer(): void { - if (!this.stableAttachTimer) return; - clearTimeout(this.stableAttachTimer); - this.stableAttachTimer = null; - } - - private clearFreshAttachReadyTimer(): void { - if (!this.freshAttachReadyTimer) return; - clearTimeout(this.freshAttachReadyTimer); - this.freshAttachReadyTimer = null; - } - - private clearRecoveryWriteFlushTimer(): void { - if (!this.recoveryWriteFlushTimer) return; - clearTimeout(this.recoveryWriteFlushTimer); - this.recoveryWriteFlushTimer = null; - } - - private resetTerminalResponder(process: pty.IPty, epoch: number): void { - this.clearTerminalResponder(); - const terminal = new Terminal({ - cols: this.cols, - rows: this.rows, - allowProposedApi: true, - }); - const respond = (response: string) => { - if ( - epoch !== this.epoch || - this.process !== process || - this.intentionalExit || - this.exited - ) return; - try { process.write(response); } catch { /* transport recovery handles it */ } - }; - terminal.onData(respond); - // Browser xterm has a ThemeService and answers OSC color queries; headless - // xterm intentionally does not. Supply stable colors here so no-browser - // sessions still work and ZMX browser tabs can safely suppress duplicates. - for (const ident of [4, 10, 11, 12] as const) { - terminal.parser.registerOscHandler(ident, (data) => { - const replies = terminalOscColorQueryReplies(ident, data); - if (replies.length === 0) return false; - for (const reply of replies) respond(reply); - return true; - }); - } - this.queryTerminal = terminal; - } - - private clearTerminalResponder(): void { - const terminal = this.queryTerminal; - this.queryTerminal = null; - try { terminal?.dispose(); } catch { /* already disposed */ } + private clearStableTailTimer(): void { + if (!this.stableTailTimer) return; + clearTimeout(this.stableTailTimer); + this.stableTailTimer = null; } private emitData(data: string): void { if (this.dataCbs.length === 0) { const next = this.earlyBuffer + data; if (next.length > EARLY_BUFFER_MAX && this.earlyBuffer.length <= EARLY_BUFFER_MAX) { - logger.warn(`[zmx:${this.sessionName}] early output exceeded 1 MiB; keeping newest bytes`); + logger.warn(`[zmx:${this.sessionName}] early output exceeded 1 MiB; keeping newest text`); } this.earlyBuffer = next.slice(-EARLY_BUFFER_MAX); return; @@ -742,43 +1476,29 @@ export class ZmxBackend implements SessionBackend { private fireExit(code: number | null, signal: string | null): void { if (this.exited) return; + // Once the backing daemon is gone, history is no longer queryable. Never + // fall back to tail payload bytes here: upstream may already have deleted + // or corrupted their UTF-8 content. this.exited = true; this.state = 'exited'; + this.tailEpoch += 1; this.clearReconnectTimer(); - this.clearStableAttachTimer(); - this.clearFreshAttachReadyTimer(); - this.clearRecoveryWriteFlushTimer(); - this.clearTerminalResponder(); - this.process = null; - this.recoveryWriteBuffer = ''; - this.recoveryWriteProbeAttempt = 0; - if (this.exitCbs.length === 0) { - this.pendingExit = { code, signal }; - return; - } + this.clearStableTailTimer(); + this.stopHistoryPolling(); + const tail = this.tailProcess; + this.tailProcess = null; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } + this.pendingExit = { code, signal }; + if (this.exitCbs.length === 0) return; for (const cb of this.exitCbs) { try { cb(code, signal); } catch { /* listener failure must not block teardown */ } } } } -/** - * The command is ignored when the named session exists. If it disappeared - * after the liveness probe, the sentinel exits immediately instead of creating - * an unrelated interactive shell. - */ -export function buildReattachArgs(sessionName: string): string[] { - return ['attach', sessionName, '/bin/sh', '-c', 'exit 75']; -} - -export function buildFreshAttachArgs( - sessionName: string, - bootstrapPath: string, -): string[] { - // The ZMX daemon retains its original command for the whole session and - // exposes it through `zmx list`. Keep cwd, env values and CLI argv (including - // an initial user prompt) out of that retained command. The 0600 bootstrap - // unlinks itself before starting the real shell. +export function buildFreshAttachArgs(sessionName: string, bootstrapPath: string): string[] { + // An existing session ignores this command, which makes attach safer than + // `zmx run` (run is an upsert that can inject into a foreign race winner). return ['attach', sessionName, '/bin/sh', bootstrapPath]; } @@ -786,19 +1506,15 @@ function shellSingleQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -/** - * Render the two private files used for a fresh launch. The bootstrap contains - * no user prompt or environment values; the payload is sourced only after the - * user's rcfiles load, then immediately unlinked. Keeping `set --` in the file - * also prevents arbitrary argv bytes from becoming shell syntax. - */ +/** Render the private bootstrap and payload used by a fresh session. */ export function buildZmxLaunchFiles( bin: string, args: string[], opts: SpawnOpts, payloadPath: string, - readyMarker: string, - completionMarker: string, + readyPath: string, + readyNonce: string, + releasePath: string, releaseToken: string, ): { bootstrap: string; payload: string } { const shellSpec = resolveUserShell(process.env, opts.launchShell); @@ -815,9 +1531,7 @@ export function buildZmxLaunchFiles( 'if [ ! -r "$payload" ]; then printf "[botmux] ZMX launch payload unavailable\\n" >&2; exit 126; fi', '. "$payload" || exit 126', 'rm -f -- "$payload"', - 'payload_dir=${payload%/*}', - 'rmdir -- "$payload_dir" 2>/dev/null || true', - 'unset payload payload_dir ZMX_SESSION ZMX_SESSION_PREFIX', + 'unset payload ZMX_SESSION ZMX_SESSION_PREFIX', wrapped, ].join('\n'); const shellCommand = [ @@ -826,75 +1540,103 @@ export function buildZmxLaunchFiles( '-c', shellSingleQuote(userScript), '_', shellSingleQuote(payloadPath), ].join(' '); + const launchDir = payloadPath.slice(0, payloadPath.lastIndexOf('/')); + const cliPidPath = join(launchDir, 'cli-pid'); + const gateScript = [ + 'payload_path=$1', + 'ready_path=$2', + 'release_path=$3', + 'cli_pid_path=$4', + 'ready_nonce=$5', + 'release_token=$6', + 'printf \'%s\\n\' "$$" > "$cli_pid_path" || exit 126', + 'printf \'%s\\n\' "$ready_nonce" > "$ready_path" || exit 126', + 'attempt=0', + `while [ ! -r "$release_path" ] && [ "$attempt" -lt ${Math.ceil(FRESH_RELEASE_TIMEOUT_MS / 100)} ]; do`, + ' sleep 0.1', + ' attempt=$((attempt + 1))', + 'done', + '[ -r "$release_path" ] || exit 75', + 'release_value=', + 'IFS= read -r release_value < "$release_path" || [ -n "$release_value" ]', + '[ "$release_value" = "$release_token" ] || exit 75', + 'rm -f -- "$ready_path" "$release_path"', + 'unset ready_path release_path release_value attempt cli_pid_path ready_nonce release_token', + 'trap - 1 2 15', + 'exec </dev/tty || exit 126', + `exec ${shellCommand}`, + ].join('\n'); + const gateCommand = [ + '/bin/sh', + '-c', shellSingleQuote(gateScript), + '_', + '"$payload_path"', + '"$ready_path"', + '"$release_path"', + '"$cli_pid_path"', + '"$ready_nonce"', + '"$release_token"', + ].join(' '); const bootstrap = [ '#!/bin/sh', + 'umask 077', 'self=$0', 'rm -f -- "$self"', 'unset self ZMX_SESSION ZMX_SESSION_PREFIX', `payload_path=${shellSingleQuote(payloadPath)}`, - 'payload_dir=${payload_path%/*}', - 'watchdog_guard="$payload_dir/bootstrap-watchdog"', - 'mkdir -- "$watchdog_guard" || exit 126', - 'bootstrap_pid=$$', - // The nonce distinguishes a newly executed private bootstrap from the - // probe-to-attach race where ZMX ignores this command and attaches to an - // existing same-name session. The daemon can start before its first client, - // so repeat it until botmux releases the read barrier. Both markers are - // stripped before terminal processing. - '(', - ' while :; do', - ` printf '%s' ${shellSingleQuote(readyMarker)}`, - ' sleep 0.05', - ' done', - ') &', - 'ready_marker_pid=$!', - '(', - ` sleep ${FRESH_BOOTSTRAP_WATCHDOG_SECONDS}`, - ' if rmdir -- "$watchdog_guard" 2>/dev/null; then', - ' kill "$ready_marker_pid" 2>/dev/null || true', - ' wait "$ready_marker_pid" 2>/dev/null || true', - ' rm -f -- "$payload_path"', - ' rmdir -- "$payload_dir" 2>/dev/null || true', - ' kill -TERM "$bootstrap_pid" 2>/dev/null || true', - ' fi', - ') &', - 'watchdog_pid=$!', - 'stop_ready_marker() {', - ' kill "$ready_marker_pid" 2>/dev/null || true', - ' wait "$ready_marker_pid" 2>/dev/null || true', + `ready_path=${shellSingleQuote(readyPath)}`, + `release_path=${shellSingleQuote(releasePath)}`, + `cli_pid_path=${shellSingleQuote(cliPidPath)}`, + `ready_nonce=${shellSingleQuote(readyNonce)}`, + `release_token=${shellSingleQuote(releaseToken)}`, + `launch_dir=${shellSingleQuote(launchDir)}`, + 'cleanup_launch() {', + ' rm -f -- "$ready_path" "$release_path" "$cli_pid_path" "$payload_path"', + ' rmdir -- "$launch_dir" 2>/dev/null || true', '}', - 'stop_watchdog() {', - ' kill "$watchdog_pid" 2>/dev/null || true', - ' wait "$watchdog_pid" 2>/dev/null || true', + // Do not exec away the PTY-root bootstrap. ZMX destroys history together + // with that root process, so retaining it for a short bounded grace after + // the real CLI exits gives the authoritative history poller time to read + // final output (including pure Unicode that tail cannot signal reliably). + // A foreground gate child keeps the normal SIGINT disposition (POSIX async + // jobs may inherit SIGINT ignored), while retaining one stable PID through + // gate -> user shell -> env -> CLI exec. The parent validates and labels + // that PID before atomically publishing release. + 'is_direct_child() {', + ' for ps_bin in /usr/bin/ps /bin/ps; do', + ' [ -x "$ps_bin" ] || continue', + ' child_parent=$("$ps_bin" -o ppid= -p "$child_pid" 2>/dev/null) || continue', + ' [ "$child_parent" -eq "$$" ] 2>/dev/null && return 0', + ' done', + ' return 1', '}', - 'cleanup_uncommitted_launch() {', - ' stop_ready_marker', - ' stop_watchdog', - ' if [ "${launch_committed:-0}" != 1 ]; then', - ' rmdir -- "$watchdog_guard" 2>/dev/null || true', - ' rm -f -- "$payload_path"', - ' rmdir -- "$payload_dir" 2>/dev/null || true', + 'forward_stop() {', + ' trap - 1 15', + ' child_pid=', + ' IFS= read -r child_pid < "$cli_pid_path" 2>/dev/null || true', + ' if [ -n "$child_pid" ] && is_direct_child; then', + ' kill -HUP "$child_pid" 2>/dev/null || true', + ' kill -HUP -- "-$child_pid" 2>/dev/null || true', + ' sleep 0.15', + ' if is_direct_child; then', + ' kill -KILL "$child_pid" 2>/dev/null || true', + ' kill -KILL -- "-$child_pid" 2>/dev/null || true', + ' fi', ' fi', + ' cleanup_launch', + ' exit 75', '}', - 'launch_committed=0', - "trap 'cleanup_uncommitted_launch' 0", - "trap 'exit 75' 1 2 15", - // The release capability is independent from the visible ready nonce and - // exists only in this already-unlinked 0600 bootstrap plus worker memory. - // A stale reattach's ordinary user input can therefore only abort the - // bootstrap; it can never authorize launching a second CLI. - 'stty -echo 2>/dev/null || exit 126', - `IFS= read -r release_line && [ "$release_line" = ${shellSingleQuote(releaseToken)} ] || exit 75`, - 'unset release_line', - 'rmdir -- "$watchdog_guard" 2>/dev/null || exit 75', - 'stop_watchdog', - 'stop_ready_marker', - 'stty echo 2>/dev/null || exit 126', - 'launch_committed=1', - 'trap - 0 1 2 15', - 'unset ready_marker_pid watchdog_pid watchdog_guard bootstrap_pid launch_committed', - `printf '%s' ${shellSingleQuote(completionMarker)}`, - `exec ${shellCommand}`, + "trap 'forward_stop' 1 15", + "trap ':' 2", + gateCommand, + 'cli_status=$?', + // Do not retain a dead PID during the grace period: an external HUP must + // never target an unrelated process that reused the numeric pid. + 'rm -f -- "$cli_pid_path"', + `while ! sleep ${ZMX_EXIT_HISTORY_GRACE_SECONDS}; do :; done`, + 'cleanup_launch', + 'trap - 1 2 15', + 'exit "$cli_status"', '', ].join('\n'); return { bootstrap, payload }; @@ -905,42 +1647,49 @@ function createZmxLaunchPayload(bin: string, args: string[], opts: SpawnOpts): Z chmodSync(dir, 0o700); const bootstrapPath = join(dir, 'bootstrap.sh'); const payloadPath = join(dir, 'payload.sh'); + const readyPath = join(dir, 'ready'); + const releasePath = join(dir, 'release'); + const releaseTempPath = join(dir, 'release.tmp'); + const cliPidPath = join(dir, 'cli-pid'); + const readyNonce = randomBytes(16).toString('hex'); + const releaseToken = randomBytes(16).toString('hex'); const cleanup = () => { - // Never recursively remove the directory: a running bootstrap's watchdog - // uses its child guard directory as an atomic release-vs-timeout lock. It is - // safe to remove the private files early, but deleting that guard would - // strand the bootstrap (and its ZMX daemon) at the read barrier forever. - try { rmSync(payloadPath, { force: true }); } catch { /* already consumed */ } - try { rmSync(bootstrapPath, { force: true }); } catch { /* already unlinked */ } - try { rmdirSync(dir); } catch { /* watchdog guard or active bootstrap remains */ } + for (const path of [readyPath, releasePath, releaseTempPath, cliPidPath, payloadPath, bootstrapPath]) { + try { rmSync(path, { force: true }); } catch { /* already consumed */ } + } + try { rmdirSync(dir); } catch { /* live bootstrap still owns the directory */ } }; try { - const markerNonce = randomBytes(16).toString('hex'); - const releaseToken = randomBytes(16).toString('hex'); - // Use an otherwise inert private OSC sequence: ZMX forwards the raw bytes - // to this client, while its terminal snapshot does not render the nonce or - // retain it as visible scrollback for a later reattach. - const readyMarker = `\x1b]5150;botmux-zmx-ready=${markerNonce}\x1b\\`; - const completionMarker = `\x1b]5150;botmux-zmx-started=${markerNonce}\x1b\\`; const files = buildZmxLaunchFiles( bin, args, opts, payloadPath, - readyMarker, - completionMarker, + readyPath, + readyNonce, + releasePath, releaseToken, ); writeFileSync(payloadPath, files.payload, { mode: 0o600, flag: 'wx' }); writeFileSync(bootstrapPath, files.bootstrap, { mode: 0o600, flag: 'wx' }); - return { bootstrapPath, readyMarker, completionMarker, releaseToken, cleanup }; + return { + dir, + bootstrapPath, + readyPath, + readyNonce, + releasePath, + releaseTempPath, + cliPidPath, + releaseToken, + cleanup, + }; } catch (err) { cleanup(); throw err; } } -/** Strip every payload-delivered key from the persistent ZMX control process. */ +/** Strip every payload-delivered key from ZMX control subprocesses. */ export function zmxControlEnv(opts: SpawnOpts): NodeJS.ProcessEnv { const env = zmxEnv(opts.env); for (const assignment of buildBotmuxEnvAssignments(opts.env, opts.injectEnv)) { @@ -953,61 +1702,6 @@ export function zmxControlEnv(opts: SpawnOpts): NodeJS.ProcessEnv { return env; } -function colorToOscRgb(hex: string): string { - const value = hex.startsWith('#') ? hex.slice(1) : hex; - const parts = [0, 2, 4].map(offset => { - const byte = Number.parseInt(value.slice(offset, offset + 2), 16); - return (byte * 257).toString(16).padStart(4, '0'); - }); - return `rgb:${parts.join('/')}`; -} - -function indexedTerminalColor(index: number): string | null { - if (!Number.isInteger(index) || index < 0 || index > 255) return null; - if (index < TERMINAL_ANSI.length) return TERMINAL_ANSI[index]!; - if (index < 232) { - const n = index - 16; - const levels = [0, 95, 135, 175, 215, 255]; - const red = levels[Math.floor(n / 36)]!; - const green = levels[Math.floor((n % 36) / 6)]!; - const blue = levels[n % 6]!; - return `#${[red, green, blue].map(v => v.toString(16).padStart(2, '0')).join('')}`; - } - const gray = 8 + (index - 232) * 10; - const byte = gray.toString(16).padStart(2, '0'); - return `#${byte}${byte}${byte}`; -} - -/** Replies for the OSC color-query families handled by browser xterm's theme service. */ -export function terminalOscColorQueryReplies( - ident: 4 | 10 | 11 | 12, - data: string, -): string[] { - if (ident === 4) { - const parts = data.split(';'); - if (parts.length < 2 || parts.length % 2 !== 0) return []; - const replies: string[] = []; - for (let i = 0; i < parts.length; i += 2) { - if (!/^\d+$/.test(parts[i]!)) return []; - if (parts[i + 1] !== '?') continue; - const index = Number(parts[i]); - const color = indexedTerminalColor(index); - if (!color) return []; - replies.push(`\x1b]4;${index};${colorToOscRgb(color)}\x1b\\`); - } - return replies; - } - - const colors = [TERMINAL_FG, TERMINAL_BG, TERMINAL_CURSOR]; - const replies: string[] = []; - for (const [offset, token] of data.split(';').entries()) { - const target = ident + offset; - if (token !== '?' || target > 12) continue; - replies.push(`\x1b]${target};${colorToOscRgb(colors[target - 10]!)}\x1b\\`); - } - return replies; -} - export function parseZmxList(output: string): { sessions: string[]; unhealthySessions: string[]; @@ -1021,22 +1715,16 @@ export function parseZmxList(output: string): { if (!line.trim()) continue; const looksLikeRecord = /^\s*name=[^\t]*\t(?:pid=\d+(?:\t|$)|err=)/.test(line); if (!looksLikeRecord) { - // The full list renders cmd= verbatim, including literal newlines. Once - // a real row has started, even a continuation beginning with `name=` is - // opaque unless it also has ZMX's tab-delimited pid=/err= status field. - // A warning or changed row format before the first record is malformed. + // Full-list cmd= is verbatim and may contain literal newlines. Once a + // record starts, continuation text is opaque even when it says name=. if (!sawRecord) malformedLines.push(line); continue; } sawRecord = true; const row = parseZmxListRow(line); - if (!row) { - malformedLines.push(line); - } else if (row.state === 'unhealthy') { - unhealthySessions.push(row.name); - } else { - sessions.push(row.name); - } + if (!row) malformedLines.push(line); + else if (row.state === 'unhealthy') unhealthySessions.push(row.name); + else sessions.push(row.name); } return { sessions, unhealthySessions, malformedLines }; } @@ -1049,12 +1737,9 @@ export function parseZmxShortList(output: string): { const malformedLines: string[] = []; const seen = new Set<string>(); for (const raw of output.split('\n')) { - // ZMX 0.6 permits ordinary spaces in session names. `--short` is one name - // per line, so preserve them verbatim (apart from a CRLF terminator) and - // reject only bytes that make that line boundary/status protocol ambiguous. const name = raw.endsWith('\r') ? raw.slice(0, -1) : raw; if (!name) continue; - if (/[ \x00-\x1f\x7f]/.test(name) || seen.has(name)) { + if (/[\t\x00-\x1f\x7f]/.test(name) || seen.has(name)) { malformedLines.push(raw); continue; } @@ -1068,33 +1753,61 @@ function parseZmxListRow(line: string): { name: string; state: 'healthy' | 'unhealthy'; pid?: number; + clients?: number; + command?: string; } | null { - // The first field is the name and the SECOND tab-delimited field is the - // authoritative status (`pid=...` or `err=...`). Later fields include the - // cwd and full command; treating an `err=`/`pid=` substring there as status - // would misclassify a healthy session whose path or argv contains that text. const fields = line.replace(/^\s*/, '').split('\t'); const nameField = fields[0]; const name = nameField?.startsWith('name=') ? nameField.slice('name='.length) : ''; const status = fields[1]; if (!name || /[\x00-\x1f\x7f]/.test(name) || !status) return null; const pid = status.match(/^pid=(\d+)$/)?.[1]; - if (pid) return { name, state: 'healthy', pid: Number(pid) }; + if (pid) { + const clients = fields.map(field => field.match(/^clients=(\d+)$/)?.[1]).find(Boolean); + const command = fields.find(field => field.startsWith('cmd='))?.slice('cmd='.length); + return { + name, + state: 'healthy', + pid: Number(pid), + clients: clients === undefined ? undefined : Number(clients), + command, + }; + } if (/^err=/.test(status)) return { name, state: 'unhealthy' }; return null; } +function sessionDetails( + sessionName: string, + env: NodeJS.ProcessEnv = zmxEnv(), +): ZmxSessionDetails | null { + const probe = ZmxBackend.probeSessions(env); + return probe.ok ? sessionDetailsFromSnapshot(probe, sessionName) : null; +} + +function sessionDetailsFromSnapshot( + probe: ZmxSessionProbeResult, + sessionName: string, +): ZmxSessionDetails | null { + if (!probe.sessions.includes(sessionName)) return null; + const rows = probe.raw + .split('\n') + .map(parseZmxListRow) + .filter((row): row is NonNullable<typeof row> => + row?.name === sessionName && row.state === 'healthy' && !!row.pid, + ); + if (rows.length !== 1) return null; + const row = rows[0]!; + return { + name: row.name, + pid: row.pid!, + clients: row.clients ?? null, + command: row.command ?? null, + }; +} + export function findSessionPid(sessionName: string): number | null { - const probe = ZmxBackend.probeSessions(); - if (!probe.ok || !probe.sessions.includes(sessionName)) return null; - const matches: number[] = []; - for (const line of probe.raw.split('\n')) { - const row = parseZmxListRow(line); - if (row?.name === sessionName && row.state === 'healthy' && row.pid) matches.push(row.pid); - } - // A multiline command can forge a record-shaped continuation. Refuse an - // ambiguous PID rather than signaling or stamping the wrong process. - return matches.length === 1 ? matches[0]! : null; + return sessionDetails(sessionName)?.pid ?? null; } export function tmuxKeyToBytes(key: string): string { diff --git a/src/adapters/cli/strict-input-handle.ts b/src/adapters/cli/strict-input-handle.ts new file mode 100644 index 000000000..e6f828bd8 --- /dev/null +++ b/src/adapters/cli/strict-input-handle.ts @@ -0,0 +1,41 @@ +import type { PtyHandle } from './types.js'; + +const cachedHandles = new WeakMap<object, PtyHandle>(); + +/** + * Turn boolean write rejection into an exception at the submit boundary. + * + * Several established CLI adapters intentionally ignore the optional boolean + * returned by sendText/sendSpecialKeys because PTY/tmux historically either + * wrote or threw. Snapshot transports can return false for an ambiguous, + * non-retriable send. Wrapping only adapter submission keeps those failures on + * worker's existing notification path without making best-effort navigation + * and startup-control keystrokes throw from unrelated event callbacks. + */ +export function strictInputHandle<T extends PtyHandle>(pty: T): T { + const cached = cachedHandles.get(pty as object); + if (cached) return cached as T; + + const proxy = new Proxy(pty as T & object, { + get(target, property) { + const value = Reflect.get(target, property, target); + if (property === 'sendText' || property === 'sendSpecialKeys') { + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + const result = Reflect.apply(value, target, args); + if (result === false) { + throw new Error(`backend rejected ${String(property)} during prompt submission`); + } + return result; + }; + } + return typeof value === 'function' ? value.bind(target) : value; + }, + set(target, property, value) { + return Reflect.set(target, property, value, target); + }, + }) as T; + + cachedHandles.set(pty as object, proxy); + return proxy; +} diff --git a/src/cli.ts b/src/cli.ts index 81e9f366d..5892ca6b8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1345,7 +1345,7 @@ async function promptEditBotConfig( } printInputHelp('会话后端 backendType', [ - '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr/zmx 支持托管持久会话;zellij 为实验后端(需 zellij >= 0.44)。', + '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr 支持托管持久会话;zmx >= 0.7.1 提供纯文本持久会话 + 本机 attach(无 Web TUI);zellij 为实验后端(需 zellij >= 0.44)。', '选择 traex + herdr 时,可在 Dashboard Settings 中开启 TraeX herdr plugin opt-in 并填写可信插件 spec;默认不会自动安装第三方插件。', '留空保留当前值;输入 - 回到全局默认(未设置 BACKEND_TYPE 时为 tmux);接受 pty / tmux / herdr / zellij / zmx。', ]); @@ -4917,14 +4917,22 @@ async function cmdTitle(rest: string[]): Promise<void> { const source = process.env.BOTMUX_SESSION_ID === session.sessionId ? 'agent' : 'cli'; let res: Response; try { - res = await fetch( - `http://127.0.0.1:${daemon.ipcPort}/api/sessions/${encodeURIComponent(session.sessionId)}/rename`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ title, source }), - }, - ); + res = source === 'agent' + ? await postSessionCliIpc( + daemon.ipcPort, + session.sessionId, + 'rename', + { title, source }, + ) + : await fetchDaemonIpc( + daemon.ipcPort, + `/api/sessions/${encodeURIComponent(session.sessionId)}/rename`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title, source }), + }, + ); } catch (err: any) { console.error(`❌ 无法连接到 daemon (port=${daemon.ipcPort}): ${err?.message ?? err}`); process.exit(1); @@ -5045,6 +5053,8 @@ async function cmdTermLink(rest: string[]): Promise<void> { console.error('❌ daemon 中该会话非活跃,无法获取可操作终端。'); } else if (errCode === 'terminal_unavailable') { console.error('❌ 该会话终端尚未就绪(worker 未起或缺 token)。等会话起来再试。'); + } else if (errCode === 'terminal_unsupported') { + console.error('❌ 该会话后端不提供 Web 终端;ZMX 会话请在本机运行 botmux list 或 zmx attach。'); } else if (errCode === 'no_owner') { console.error('❌ 该 bot 未配置 owner(allowedUsers 为空 / 全开放模式),没有可私密投递的对象。'); } else if (errCode === 'delivery_failed') { diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 7fd121c24..8c9e8ff6e 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1209,7 +1209,9 @@ export async function handleTermLinkCommand( } const channel = await deliverWritableTerminalCardTo(ds, senderOpenId); - if (channel === 'not_ready') { + if (channel === 'unsupported') { + await reply(t('cmd.term.unsupported', undefined, loc)); + } else if (channel === 'not_ready') { await reply(t('cmd.term.not_ready', undefined, loc)); } else if (channel === 'failed') { await reply(t('cmd.term.failed', undefined, loc)); @@ -1277,8 +1279,16 @@ export async function handleCommand( // Capture the closed-session card BEFORE killWorker/closeSession — // it reads the live session's identity off `ds`. const card = buildClosedSessionCard(ds, loc); - killWorker(ds); - sessionStore.closeSession(ds.session.sessionId); + try { + await closeSession(ds.session.sessionId); + } catch (err) { + logger.error(`[${logTag}] Refused /close because backing teardown was not verified: ${err}`); + await sessionReply( + rootId, + `⚠️ 会话关闭失败,已保留 active 记录以便重试:${err instanceof Error ? err.message : String(err)}`, + ); + break; + } activeSessions.delete(sessionKey(rootId, larkAppId!)); // 「会话已关闭」卡片优先「仅自己可见」:普通群里走 ephemeral 只发给执行 // /close 的本人;话题群不支持 ephemeral(18053) 时回退为正常的群内可见回复 @@ -1615,6 +1625,18 @@ export async function handleCommand( // anchor — expected; `/close` the new one first, or use the // command.) Mirrors the `/close` case above. // + // ZMX close is identity/generation verified and may refuse. Prove + // teardown before claiming the card or mutating any state so a + // refusal leaves the current session fully retryable. + try { + teardownAuthoritativePersistentBackingBeforeClose(ds!); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logger.warn(`[${logTag}] Repo switch refused because backing teardown was not proven: ${reason}`); + await sessionReply(rootId, t('cmd.repo.switch_close_failed', { error: reason }, loc)); + return false; + } + // Claim any open repo card BEFORE killWorker / await so a concurrent // card click cannot double-switch while this text path runs. // diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 624b359db..08a39d521 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -66,7 +66,7 @@ import { readGlobalConfig } from '../global-config.js'; import { normalizeChatReplyMode, setChatReplyMode, type ChatReplyMode } from '../services/chat-reply-mode-store.js'; import * as chatFirstSeenStore from '../services/chat-first-seen-store.js'; import * as scheduler from './scheduler.js'; -import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker } from './worker-pool.js'; +import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker, sessionSupportsWebTerminal } from './worker-pool.js'; import { listOnlineDaemons } from '../utils/daemon-discovery.js'; import { isSessionStopped } from './session-liveness.js'; import { getChatMode, replyMessage, sendMessage, resolveUnionIdFromOpenId, listThreadMessages, listChatMessages, listChatBotMembers, getUserProfile, getUserProfileStrict, resolveAllowedUsersWithMap, type ChatBotMember } from '../im/lark/client.js'; @@ -1219,11 +1219,13 @@ ipcRoute('GET', '/api/owner-profile', async (_req, res) => { // Codex/Claude Code 再收到一条 best-effort 原生 /rename,同步其 resume picker。 // 飞书话题标题不受影响。全视图(看板/状态板/表格/抽屉)读同一字段。 ipcRoute('POST', '/api/sessions/:sessionId/rename', async (req, res, params) => { - let body: { title?: unknown; source?: unknown }; + let body: { title?: unknown; source?: unknown } & Record<string, unknown>; try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + const active = findActiveBySessionId(params.sessionId); + const auth = sessionCliIpcAuth(req, active, params.sessionId, body); + if (!auth.ok) return jsonRes(res, 403, { ok: false, error: auth.error }); const title = normalizeSessionTitle(body.title); if (!title) return jsonRes(res, 400, { ok: false, error: 'bad_title' }); - const active = findActiveBySessionId(params.sessionId); const session = active?.session ?? sessionStore.getOwnedSession(params.sessionId); if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); const source = normalizeSessionTitleSource(body.source, 'dashboard'); @@ -1278,6 +1280,9 @@ ipcRoute('GET', '/api/sessions/:sessionId/write-link', (req, res, params) => { if (!ipcHmacAuthorized(req)) return jsonRes(res, 401, { ok: false, error: 'unauthorized' }); const ds = findActiveBySessionId(params.sessionId); if (!ds) return jsonRes(res, 404, { ok: false, error: 'session_not_active' }); + if (!sessionSupportsWebTerminal(ds)) { + return jsonRes(res, 409, { ok: false, error: 'terminal_unsupported' }); + } // Riff backend: the sandbox URL is the writable link — no local worker needed. if (ds.riffAccessUrl) { jsonRes(res, 200, { ok: true, url: ds.riffAccessUrl }); @@ -1324,7 +1329,7 @@ ipcRoute('POST', '/api/sessions/:sessionId/write-link-card', async (req, res, pa if (!ds) return jsonRes(res, 404, { ok: false, error: 'session_not_active' }); const r = await deliverWriteLinkCardToOwners(ds); const status = r.ok ? 200 - : r.error === 'terminal_unavailable' ? 409 + : r.error === 'terminal_unavailable' || r.error === 'terminal_unsupported' ? 409 : r.error === 'no_owner' ? 422 : 502; jsonRes(res, status, r); diff --git a/src/core/device-isolation-daemon.ts b/src/core/device-isolation-daemon.ts index f5cb70256..4ba8a70dd 100644 --- a/src/core/device-isolation-daemon.ts +++ b/src/core/device-isolation-daemon.ts @@ -34,7 +34,7 @@ export const DEVICE_ISOLATION_PREPARE_PATH = '/api/device-isolation/activation/p export const DEVICE_ISOLATION_COMMIT_PATH = '/api/device-isolation/activation/commit'; export const DEVICE_ISOLATION_RELEASE_PATH = '/api/device-isolation/activation/release'; -type LocalPersistentBackend = 'tmux' | 'herdr' | 'zellij'; +type LocalPersistentBackend = 'tmux' | 'herdr' | 'zellij' | 'zmx'; type InventoryBackend = BackendType | 'unknown'; type ProcessIdentity = { pid: number; procStart: string }; @@ -99,7 +99,13 @@ export interface DeviceIsolationDaemonDependencies { processExists: (pid: number) => boolean; signalProcess: (pid: number, signal: NodeJS.Signals) => void; probePersistent: (backendType: LocalPersistentBackend, name: string) => SessionProbe; - killPersistent: (backendType: LocalPersistentBackend, name: string) => void; + /** Full sessionId is mandatory so prefix-addressed backends such as ZMX can + * re-verify ownership before destructive teardown. */ + killPersistent: ( + backendType: LocalPersistentBackend, + name: string, + sessionId: string, + ) => void; closeWorker: (session: DeviceIsolationRuntimeSession) => void; readMarker: () => string | null; sleep: (ms: number) => Promise<void>; @@ -122,7 +128,7 @@ let daemonIdentity: DeviceIsolationDaemonIdentity | null = null; let transaction: ActivationTransaction | null = null; function isPersistentBackend(value: InventoryBackend): value is LocalPersistentBackend { - return value === 'tmux' || value === 'herdr' || value === 'zellij'; + return value === 'tmux' || value === 'herdr' || value === 'zellij' || value === 'zmx'; } function isLocalBackend(value: InventoryBackend): value is Exclude<BackendType, 'riff'> { @@ -454,7 +460,11 @@ async function quiesceOwnedSessions( if (!session) return 'teardown_failed'; dependencies.closeWorker(session); if (target.persistent) { - dependencies.killPersistent(target.persistent.backendType, target.persistent.name); + dependencies.killPersistent( + target.persistent.backendType, + target.persistent.name, + target.sessionId, + ); } } } catch { @@ -486,7 +496,11 @@ async function quiesceOwnedSessions( if (probe === 'exists') { clean = false; try { - dependencies.killPersistent(target.persistent.backendType, target.persistent.name); + dependencies.killPersistent( + target.persistent.backendType, + target.persistent.name, + target.sessionId, + ); } catch { /* verified by the next probe */ } } } diff --git a/src/core/persistent-backend.ts b/src/core/persistent-backend.ts index bbcc060d9..ea83e309d 100644 --- a/src/core/persistent-backend.ts +++ b/src/core/persistent-backend.ts @@ -188,12 +188,21 @@ export function probePersistentBackendTarget(target: PersistentBackendTarget): S return probePersistentSession(target.backendType, target.sessionName); } -export function killPersistentBackendTarget(target: PersistentBackendTarget): void { +/** + * `sessionId` is REQUIRED for ZMX: its destruction is identity-verified against + * the botmux labels stamped on the session, and `killPersistentSession` refuses + * a name-only ZMX kill rather than risk destroying a same-named user session. + * Callers that hold the owning session must always pass it through. + */ +export function killPersistentBackendTarget( + target: PersistentBackendTarget, + sessionId?: string, +): void { if (target.backendType === 'herdr' && target.agentName) { HerdrBackend.killAgent(target.sessionName, target.agentName); return; } - killPersistentSession(target.backendType, target.sessionName); + killPersistentSession(target.backendType, target.sessionName, sessionId); } export function probePersistentSession(backendType: PersistentBackendType, name: string): SessionProbe { @@ -269,10 +278,21 @@ export function probePersistentBackendServer( return 'unknown'; } -/** Kill a backing session (each backend's killSession is a no-op when absent). */ -export function killPersistentSession(backendType: PersistentBackendType, name: string): void { +/** + * Kill a backing session. ZMX additionally requires the complete botmux UUID: + * its public name contains only eight UUID characters, so name-only deletion + * could destroy a different session after a prefix collision. + */ +export function killPersistentSession( + backendType: PersistentBackendType, + name: string, + sessionId?: string, +): void { if (backendType === 'tmux') TmuxBackend.killSession(name); else if (backendType === 'zellij') ZellijBackend.killSession(name); - else if (backendType === 'zmx') ZmxBackend.killSession(name); + else if (backendType === 'zmx') { + if (!sessionId) throw new Error(`refusing name-only ZMX kill for ${name}`); + ZmxBackend.killManagedSession(name, sessionId); + } else HerdrBackend.killSession(name); } diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 13fe4a4f7..418312c6a 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -49,6 +49,7 @@ import { } from './session-create.js'; import { validateZellijAdoptTarget } from './zellij-adopt-discovery.js'; import type { BackendType, SessionProbe } from '../adapters/backend/types.js'; +import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js'; import type { CliTurnPayload, CodexAppAdditionalContextEntry, CodexAppTurnInput, LarkAttachment, LarkMention, ScheduledTask, Session, SubstituteTrigger } from '../types.js'; import { addCodexAppContext } from '../utils/codex-app-context.js'; import type { MessageResource } from '../im/lark/message-parser.js'; @@ -109,9 +110,11 @@ function sessionBotCliMismatch(ds: DaemonSession): { sessionCli: string; botCli: return null; } -async function closeActiveSessionIfCliMismatch(ds: DaemonSession): Promise<boolean> { +type CliMismatchCloseResult = 'not_mismatched' | 'closed' | 'teardown_failed'; + +async function closeActiveSessionIfCliMismatch(ds: DaemonSession): Promise<CliMismatchCloseResult> { const mismatch = sessionBotCliMismatch(ds); - if (!mismatch) return false; + if (!mismatch) return 'not_mismatched'; const tag = ds.session.sessionId.substring(0, 8); const backendType = getSessionPersistentBackendType(ds); @@ -122,12 +125,25 @@ async function closeActiveSessionIfCliMismatch(ds: DaemonSession): Promise<boole if (backendType && (!ds.worker || ds.worker.killed)) { const target = persistentBackendTargetForSession(ds)!; logger.warn(`[${tag}] CLI mismatch (session=${mismatch.sessionCli}, bot=${mismatch.botCli}), closing active session and killing ${backendType} ${target.sessionName}${target.backendType === 'herdr' && target.agentName ? `/${target.agentName}` : ''}`); - killPersistentBackendTarget(target); + try { + killPersistentBackendTarget(target, ds.session.sessionId); + } catch (err) { + // ZMX destruction is deliberately identity-verified and can fail on an + // inconclusive probe or a generation change. Never close the persisted + // row in that state: doing so would orphan a possibly-live CLI with no + // retryable ownership record. The caller skips this row and continues + // restoring/sweeping unrelated sessions. + logger.error( + `[${tag}] CLI mismatch backing teardown failed; keeping active row: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + return 'teardown_failed'; + } } else { logger.warn(`[${tag}] CLI mismatch (session=${mismatch.sessionCli}, bot=${mismatch.botCli}), closing active session`); } await closeSession(ds.session.sessionId); - return true; + return 'closed'; } /** @@ -149,7 +165,14 @@ export async function closeCliMismatchedSessionsForBot(larkAppId: string): Promi if (ds.larkAppId !== larkAppId) continue; if (ds.session.queued) continue; if (ds.adoptedFrom || ds.session.adoptedFrom || ds.session.title?.startsWith('Adopt:')) continue; - if (await closeActiveSessionIfCliMismatch(ds)) closed++; + try { + if (await closeActiveSessionIfCliMismatch(ds) === 'closed') closed++; + } catch (err) { + logger.error( + `[${ds.session.sessionId.substring(0, 8)}] CLI mismatch close failed; continuing sweep: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } } return closed; } @@ -1459,7 +1482,19 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe // instead of resurrecting an invisible worker. const backendType = getSessionPersistentBackendType(ds); if (backendType) { - killPersistentBackendTarget(persistentBackendTargetForSession(ds)!); + try { + killPersistentBackendTarget(persistentBackendTargetForSession(ds)!, session.sessionId); + } catch (err) { + // Keep the row active when destruction cannot be proved. Closing it + // would strand a possibly-live hidden run with no ownership record; + // skipping registration also prevents this restore pass from + // accidentally reattaching it. Continue with the remaining rows. + logger.error( + `[${session.sessionId.substring(0, 8)}] Could not tear down unmaterialized deferred run; keeping active row: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + continue; + } } sessionStore.closeSession(session.sessionId); removeDeferredTopicBinding(config.session.dataDir, session.sessionId); @@ -1477,7 +1512,16 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe ds.replyThreadAliases = aliases; sessionStore.updateSession(session); } - if (await closeActiveSessionIfCliMismatch(ds)) continue; + try { + const mismatchClose = await closeActiveSessionIfCliMismatch(ds); + if (mismatchClose !== 'not_mismatched') continue; + } catch (err) { + logger.error( + `[${session.sessionId.substring(0, 8)}] CLI mismatch close failed during restore; keeping active row: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + continue; + } const anchor = sessionAnchorId(ds); messageQueue.ensureQueue(anchor); if (ds.usageLimit) restoreUsageLimitRuntimeState(ds); @@ -1491,7 +1535,17 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe } if (!restored || session.status !== 'active' || restoreCurrent !== ds) { logger.warn(`[${session.sessionId.substring(0, 8)}] Restore was cancelled while resolving a routing collision`); - await closeSession(session.sessionId); + try { + await closeSession(session.sessionId); + } catch (err) { + // A rejected ZMX ownership/generation probe means teardown was not + // proven. Keep the persisted row active for a later retry and continue + // restoring unrelated sessions instead of crash-looping daemon boot. + logger.error( + `[${session.sessionId.substring(0, 8)}] Could not close restore collision loser; keeping active row: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } continue; } restoredByThisInvocation.push(ds); @@ -1634,7 +1688,16 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe // check on the reattach path too — persistent-backend reattach ignores the // bin/args handed to backend.spawn(), so anything that slips through here // would silently resurrect the old frozen CLI. - if (await closeActiveSessionIfCliMismatch(ds)) continue; + try { + const mismatchClose = await closeActiveSessionIfCliMismatch(ds); + if (mismatchClose !== 'not_mismatched') continue; + } catch (err) { + logger.error( + `[${ds.session.sessionId.substring(0, 8)}] CLI mismatch close failed during reattach; keeping active row: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + continue; + } const tag = ds.session.sessionId.substring(0, 8); logger.info(`[${tag}] ${backendType} session alive, queued for re-attach`); @@ -1670,6 +1733,10 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe * only the first forks; the rest just await the same `ds.workerPort`. */ export async function ensureTerminalWorkerPort(ds: DaemonSession): Promise<number | undefined> { + const frozenBackendType = ds.initConfig?.backendType ?? ds.session.backendType; + if (frozenBackendType !== undefined && !backendSupportsWebTerminal(frozenBackendType)) { + return undefined; + } if (ds.workerPort) return ds.workerPort; if (ds.session.status !== 'active') return undefined; diff --git a/src/core/types.ts b/src/core/types.ts index 32d9ca1ec..9bd4322b7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -39,6 +39,10 @@ export function frozenDisplayMode(fc: FrozenCard): DisplayMode { export interface DaemonSession { session: Session; worker: ChildProcess | null; // fork'd worker process + /** True after the current worker generation has completed init. Kept + * separate from workerPort because backends without a Web Terminal still + * emit screen/idle/screenshot updates and support native local attach. */ + workerReady?: boolean; workerPort: number | null; // HTTP port for xterm.js workerToken: string | null; // write token for xterm.js /** Independent read-only xterm capability. Optional for hydrated/legacy diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 70597199c..8cbcbd5b5 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -37,6 +37,7 @@ import { listDocSubscriptionsForSession, removeDocSubscription } from '../servic import { TmuxBackend } from '../adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../adapters/backend/herdr-backend.js'; import { ZmxBackend } from '../adapters/backend/zmx-backend.js'; +import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js'; import { sandboxEnabled } from '../adapters/backend/sandbox.js'; import { isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, managedTargetsForCliChange, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js'; import { getBot, getAllBots, loadBotConfigs, resolveBrandLabel } from '../bot-registry.js'; @@ -352,11 +353,36 @@ function doneReactionEmojiFor(ds: DaemonSession): string { } catch { return DONE_REACTION_EMOJI_TYPE; } } +/** Worker lifecycle readiness is independent from Web Terminal availability. + * Legacy/test sessions predate workerReady, so retain the old port inference + * only while the explicit flag is absent. */ +export function workerHasInitialized(ds: DaemonSession): boolean { + return ds.workerReady === true + || (ds.workerReady === undefined + && sessionSupportsWebTerminal(ds) + && !!(ds.workerPort ?? ds.session.webPort)); +} + +/** Capability of the backend frozen onto this worker/session generation. */ +export function sessionSupportsWebTerminal(ds: DaemonSession): boolean { + const backendType = ds.initConfig?.backendType ?? ds.session.backendType; + // Pre-backend-stamp sessions are legacy tmux sessions and retain Web TUI. + return backendType === undefined || backendSupportsWebTerminal(backendType); +} + +/** Empty means this session intentionally has no Web Terminal surface. */ +export function readableTerminalUrlFor(ds: DaemonSession): string { + return sessionSupportsWebTerminal(ds) && (ds.workerPort ?? ds.session.webPort) + ? buildTerminalUrl(ds) + : ''; +} + // Per-bot opt-in: the writable terminal link to embed directly in the streaming // card body (token included). Returns undefined unless the bot enabled it AND // the worker port/token are known. Exported for card-handler's re-renders so the // link stays put across button-driven card updates. export function writableTerminalLinkFor(ds: DaemonSession): string | undefined { + if (!sessionSupportsWebTerminal(ds)) return undefined; try { if (getBot(ds.larkAppId).config.writableTerminalLinkInCard !== true) return undefined; } catch { return undefined; } @@ -375,7 +401,7 @@ function scheduleLocalCliOpenReadinessPatch(ds: DaemonSession): void { ds.pendingLocalCliButtonRefresh = true; return; } - if (!ds.streamCardId || !ds.workerPort) return; + if (!ds.streamCardId || !workerHasInitialized(ds)) return; ds.pendingLocalCliButtonRefresh = undefined; const botCfg = getBot(ds.larkAppId).config; const effectiveCliId = sessionCliId(ds, botCfg); @@ -383,7 +409,7 @@ function scheduleLocalCliOpenReadinessPatch(ds: DaemonSession): void { const cardJson = buildStreamingCard( ds.session.sessionId, sessionAnchorId(ds), - buildTerminalUrl(ds), + readableTerminalUrlFor(ds), ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId), ds.lastScreenContent ?? '', status, @@ -579,12 +605,11 @@ function scheduleUsageLimitCardPatch(ds: DaemonSession): void { // screen_update path has already suppressed auxiliary UI. if (ds.session.vcMeetingReceiver) return; if (ds.lastScreenStatus !== 'limited') return; - const port = ds.workerPort ?? ds.session.webPort; - if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !port) return; + if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !workerHasInitialized(ds)) return; const bot = getBot(ds.larkAppId); const effectiveCliId = sessionCliId(ds, bot.config); - const readUrl = buildTerminalUrl(ds); + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const cardJson = buildStreamingCard( ds.session.sessionId, @@ -744,8 +769,8 @@ export function recallFrozenCards(ds: DaemonSession): void { * manually summon a live card in an otherwise-quiet session. Parks the current * card (if any) first so `recallFrozenCards` withdraws it once the fresh one * lands — the thread ends up with a single live card. Returns false when the - * worker terminal isn't ready yet (no port), so the caller can surface a - * friendly "not ready" message. + * worker isn't initialized yet, so the caller can surface a friendly "not + * ready" message. A ready backend without Web Terminal still gets the card. * * Note: this does NOT itself flip `ds.streamingCardForced` — the caller sets * that so the card keeps live-patching afterwards even when the bot opted out. @@ -758,11 +783,10 @@ export async function postFreshStreamingCard( // publish one into the listener chat as a streaming-card side channel. if (ds.session.vcMeetingReceiver) return false; if (isDocNativeSession(ds)) return false; - const port = ds.workerPort ?? ds.session.webPort; - if (!port) return false; + if (!workerHasInitialized(ds)) return false; const botCfg = getBot(ds.larkAppId).config; const effectiveCliId = sessionCliId(ds, botCfg); - const readUrl = buildTerminalUrl(ds); + const readUrl = readableTerminalUrlFor(ds); const title = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const status = ds.lastScreenStatus ?? 'idle'; @@ -847,12 +871,11 @@ export async function postPrivateSnapshotCard( ds: DaemonSession, audience: string[], ): Promise<{ sent: number; total: number; notReady: boolean }> { - const port = ds.workerPort ?? ds.session.webPort; - if (!port) return { sent: 0, total: audience.length, notReady: true }; + if (!workerHasInitialized(ds)) return { sent: 0, total: audience.length, notReady: true }; const botCfg = getBot(ds.larkAppId).config; const effectiveCliId = sessionCliId(ds, botCfg); - const readUrl = buildTerminalUrl(ds); + const readUrl = readableTerminalUrlFor(ds); const title = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const status = ds.lastScreenStatus ?? 'idle'; const cardJson = buildPrivateSnapshotCard( @@ -927,7 +950,7 @@ export async function deliverWriteLinkCard( export interface WriteLinkOwnerDelivery { ok: boolean; - error?: 'terminal_unavailable' | 'no_owner' | 'delivery_failed'; + error?: 'terminal_unavailable' | 'terminal_unsupported' | 'no_owner' | 'delivery_failed'; delivered: number; total: number; channels: Array<'ephemeral' | 'dm' | 'failed'>; @@ -941,6 +964,7 @@ export interface WriteLinkOwnerDelivery { * ({@link deliverWritableTerminalCardTo}, behind the `/term` slash command). */ export function buildWritableTerminalCard(ds: DaemonSession): string | null { + if (!sessionSupportsWebTerminal(ds)) return null; // Riff backend: the sandbox URL is the writable link — no local worker/token needed. if (ds.riffAccessUrl) { const botCfg = getBot(ds.larkAppId).config; @@ -986,6 +1010,9 @@ export function buildWritableTerminalCard(ds: DaemonSession): string | null { * these private channels — it is never returned to the CLI caller / stdout. */ export async function deliverWriteLinkCardToOwners(ds: DaemonSession): Promise<WriteLinkOwnerDelivery> { + if (!sessionSupportsWebTerminal(ds)) { + return { ok: false, error: 'terminal_unsupported', delivered: 0, total: 0, channels: [] }; + } const cardJson = buildWritableTerminalCard(ds); if (!cardJson) return { ok: false, error: 'terminal_unavailable', delivered: 0, total: 0, channels: [] }; @@ -1018,7 +1045,8 @@ export async function deliverWriteLinkCardToOwners(ds: DaemonSession): Promise<W export async function deliverWritableTerminalCardTo( ds: DaemonSession, operatorOpenId: string, -): Promise<'ephemeral' | 'dm' | 'failed' | 'not_ready'> { +): Promise<'ephemeral' | 'dm' | 'failed' | 'not_ready' | 'unsupported'> { + if (!sessionSupportsWebTerminal(ds)) return 'unsupported'; const cardJson = buildWritableTerminalCard(ds); if (!cardJson) return 'not_ready'; return deliverWriteLinkCard(ds, operatorOpenId, cardJson); @@ -1030,12 +1058,41 @@ export interface SubstituteControlCardDelivery { } /** - * DM a writable-terminal control card to the bot's owner(s) for a substitute-mode session. + * Build the private owner control card used by substitute-mode sessions. + * Web-capable backends keep the writable-terminal card; backends without a + * Web Terminal (currently ZMX) still need restart / close controls. + */ +function buildSubstituteControlCard(ds: DaemonSession): string | null { + const writableCard = buildWritableTerminalCard(ds); + if (writableCard) return writableCard; + + // A Web-capable backend returning null simply is not ready yet. Preserve the + // existing retry-on-next-ready behavior instead of sending a partial card. + if (sessionSupportsWebTerminal(ds)) return null; + + const botCfg = getBot(ds.larkAppId).config; + const effectiveCliId = sessionCliId(ds, botCfg); + return buildSessionCard( + ds.session.sessionId, + sessionAnchorId(ds), + '', // Manage-only: this backend intentionally has no Web Terminal URL. + ds.session.title || getCliDisplayName(effectiveCliId), + effectiveCliId, + true, + !!ds.adoptedFrom, + localeForBot(ds.larkAppId), + isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + ); +} + +/** + * DM a control card to the bot's owner(s) for a substitute-mode session. + * ZMX receives a manage-only card because it has no Web Terminal surface. * Guards against duplicate sends via `session.substituteControlCardSent`. */ export async function deliverSubstituteControlCard(ds: DaemonSession): Promise<SubstituteControlCardDelivery> { if (ds.session.substituteControlCardSent) return { sent: 0, total: 0 }; - const cardJson = buildWritableTerminalCard(ds); + const cardJson = buildSubstituteControlCard(ds); if (!cardJson) { logger.warn(`[${tag(ds)}] substitute control card skipped: terminal not ready`); return { sent: 0, total: 0 }; @@ -1346,6 +1403,7 @@ export function ensureClaudeFolderTrust(workingDir: string, stateJsonPath: strin export function killWorker(ds: DaemonSession): void { restartCoordinator.cancelSession(ds.session.sessionId); clearUsageLimitState(ds); + ds.workerReady = false; ds.localProcessAttestation = undefined; // A managed-turn capability belongs to one concrete worker generation. // Retiring (or observing the absence of) that generation must revoke the @@ -1427,7 +1485,7 @@ function destroyLivePaneBeforeRestart(ds: DaemonSession): void { const killOnce = (): void => { try { - killPersistentBackendTarget(target); + killPersistentBackendTarget(target, ds.session.sessionId); } catch (err) { // The primitives normally swallow their own errors; this only catches a // truly unexpected throw (e.g. target resolution). Non-fatal — the probe @@ -1534,7 +1592,7 @@ function destroyOrphanedBackingSession(ds: DaemonSession): void { const backendType = getSessionPersistentBackendType(ds); if (!backendType) return; try { - killPersistentBackendTarget(persistentBackendTargetForSession(ds)!); + killPersistentBackendTarget(persistentBackendTargetForSession(ds)!, ds.session.sessionId); logger.info(`[${tag(ds)}] killWorker: no live worker — destroyed orphaned ${backendType} backing session`); } catch (err) { logger.warn(`[${tag(ds)}] killWorker: failed to destroy orphaned ${backendType} backing session: ${err}`); @@ -1557,6 +1615,7 @@ function reclaimParkedCrashDiagnostic(ds: DaemonSession): void { export function suspendWorker(ds: DaemonSession, reason = 'suspended_idle'): boolean { if (!ds.worker || ds.worker.killed) { + ds.workerReady = false; // There is no live generation that can still own this capability. ds.managedTurnOrigin = undefined; return false; @@ -1572,6 +1631,7 @@ export function suspendWorker(ds: DaemonSession, reason = 'suspended_idle'): boo armWorkerKillBackstop(w, tag(ds)); ds.worker = null; + ds.workerReady = false; ds.localProcessAttestation = undefined; ds.workerPort = null; ds.workerToken = null; @@ -1725,6 +1785,42 @@ function isLegacyApiDocSubscription(managedBy: 'subscribe-lark-doc' | 'watch-com return managedBy === undefined || managedBy === 'subscribe-lark-doc'; } +/** + * Perform the close-time teardown that must be proven before callers mutate + * the active registry or persisted Session row. + * + * ZMX is deliberately fail-closed: killManagedSession verifies the managed + * UUID/generation and throws when ownership is ambiguous. Most close paths go + * through closeSession(), but repo replacement reuses the live DaemonSession + * object and therefore cannot call closeSession() without deleting the routing + * generation it is about to repopulate. Those paths call this synchronous seam + * first, while every bit of old-session state is still intact. + * + * Other backends retain their existing worker-side graceful teardown. Adopted, + * queued, and already-closed rows never own a ZMX process to destroy. + */ +export function teardownAuthoritativePersistentBackingBeforeClose( + target: DaemonSession | Session, +): void { + const ds = 'session' in target ? (target as DaemonSession) : undefined; + const session = ds?.session ?? (target as Session); + const backendType = ds ? getSessionPersistentBackendType(ds) : session.backendType; + if ( + backendType !== 'zmx' + || ds?.initConfig?.adoptMode + || ds?.adoptedFrom + || session.adoptedFrom + || session.queued + || session.status === 'closed' + ) return; + + killPersistentSession( + 'zmx', + persistentSessionName('zmx', session.sessionId), + session.sessionId, + ); +} + /** * Idempotent close: kill worker if alive, mark Session status='closed' + closedAt, * publish session.exited (if a live worker was killed) and session.update @@ -1737,13 +1833,17 @@ export async function closeSession( sessionId: string, ): Promise<{ ok: true; alreadyClosed: boolean; known: boolean }> { const ds = findActiveBySessionId(sessionId); + const stored = sessionStore.getOwnedSession(sessionId); + // Prove fail-closed ZMX teardown before any registry/store mutation. Repo + // replacement paths reuse the same helper before their own state transition. + const teardownTarget = ds ?? stored; + if (teardownTarget) teardownAuthoritativePersistentBackingBeforeClose(teardownTarget); let killedLive = false; // 会话关闭即可回收其崩溃重启计数;否则每个曾崩溃过的 session 会在 daemon // 生命周期内永久占位(restartCounts 此前无任何 delete)。 restartCounts.delete(sessionId); // Snapshot ownership + transition state before mutating the live object: // sessionStore commonly holds the very same Session reference as `ds`. - const stored = sessionStore.getOwnedSession(sessionId); const known = !!ds || !!stored; const wasOpen = !!stored && stored.status !== 'closed'; const storedHadDocCommentTargets = Object.keys(stored?.docCommentTargets ?? {}).length > 0; @@ -1904,7 +2004,7 @@ export function destroyUnregisteredPersistentBacking( if (!isSuspendableBackendType(backendType)) return false; const backendName = persistentSessionName(backendType, session.sessionId); try { - kill(backendType, backendName); + kill(backendType, backendName, session.sessionId); logger.info(`[${session.sessionId.substring(0, 8)}] Closed unregistered ${backendType} backing ${backendName}`); return true; } catch (err) { @@ -2415,6 +2515,7 @@ export function forkWorker( const workerPath = join(__dirname, '..', 'worker.js'); const t = tag(ds); ds.localProcessAttestation = undefined; + ds.workerReady = false; let resume = false; let initTurnId: string | undefined; @@ -2506,6 +2607,7 @@ export function forkWorker( try { ds.worker.send({ type: 'close' } as DaemonToWorker); } catch { /* ignore */ } try { ds.worker.kill(); } catch { /* ignore */ } ds.worker = null; + ds.workerReady = false; ds.workerPort = null; ds.workerToken = null; ds.workerViewToken = null; @@ -2747,6 +2849,16 @@ export function forkWorker( ...(ds.session.runnerBuildId ? { persistedRunnerBuildId: ds.session.runnerBuildId } : {}), ...(restartAttemptId ? { restartAttemptId } : {}), }; + // A persisted port from an older ZMX implementation must never revive the + // removed Web TUI or get forwarded as a preferred listen port. The worker + // will signal lifecycle readiness with port=0 instead. + if (!backendSupportsWebTerminal(initMsg.backendType)) { + initMsg.webPort = undefined; + if (ds.session.webPort !== undefined) { + ds.session.webPort = undefined; + sessionStore.updateSession(ds.session); + } + } worker.send(initMsg); ds.initConfig = initMsg; @@ -2976,6 +3088,14 @@ function setupWorkerHandlers( const showTakeover = false; worker.on('message', async (msg: WorkerToDaemon) => { + // Every IPC message is scoped to the child generation that emitted it. + // A replaced worker can drain queued messages after the new child has been + // installed; never let those stale events mutate the replacement's cards, + // tokens, readiness, transcript metadata, or durable turn state. + if (ds.worker !== worker) { + logger.debug(`[${t}] Ignored stale worker message: ${msg.type}`); + return; + } const effectiveCliId = sessionCliId(ds, botCfg); switch (msg.type) { case 'persistent_backend_target': { @@ -3015,7 +3135,6 @@ function setupWorkerHandlers( // unlike .botmux-cli-pids it cannot be forged or deleted by the CLI. // Bind it to the daemon's current worker generation so a late message // from a replaced worker never attests the replacement process. - if (ds.worker !== worker) break; ds.localProcessAttestation = { backendType: msg.backendType, credentialIsolated: msg.credentialIsolated, @@ -3029,33 +3148,41 @@ function setupWorkerHandlers( } case 'ready': { startupState.ready = true; - ds.workerPort = msg.port; - ds.workerToken = msg.token; - ds.workerViewToken = msg.viewToken ?? null; - // Persist port so it can be reused after daemon restart - ds.session.webPort = msg.port; + ds.workerReady = true; + const webPort = Number.isInteger(msg.port) && msg.port > 0 ? msg.port : null; + ds.workerPort = webPort; + ds.workerToken = webPort ? msg.token : null; + ds.workerViewToken = webPort ? (msg.viewToken ?? null) : null; + // Persist a real port only. port=0 is the explicit ready-without-Web- + // Terminal sentinel used by backends whose output is not raw ANSI. + ds.session.webPort = webPort ?? undefined; // Dashboard「复现命令」:worker 上报本次冷启的近似复现命令。只存内存字段、 // 绝不落盘(含凭证);warm reattach 时 worker 不重算,故为空——这是有意的 // (reattach 不代表本次新算命令真的执行了)。worker 每次 ready 会重报。 ds.spawnCommand = msg.spawnCommand; sessionStore.updateSession(ds.session); - const readOnlyUrl = buildTerminalUrl(ds); - const writeUrl = buildTerminalUrl(ds, { write: true }); - logger.info(`[${t}] Worker ready, terminal at ${readOnlyUrl.replace(/\?.*$/, '?viewToken=[redacted]')}`); + const readOnlyUrl = readableTerminalUrlFor(ds); + if (readOnlyUrl) { + logger.info(`[${t}] Worker ready, terminal at ${readOnlyUrl.replace(/\?.*$/, '?viewToken=[redacted]')}`); + } else { + logger.info(`[${t}] Worker ready (Web Terminal unavailable for this backend)`); + } if (ds.usageLimit) { ds.lastScreenStatus = 'limited'; armUsageLimitRetryTimer(ds); } - // Dashboard: surface the new xterm port so the live terminal link works. + // Dashboard: surface the xterm port, or explicitly clear it for a + // ready backend with no Web Terminal capability. dashboardEventBus.publish({ type: 'session.update', body: { sessionId: ds.session.sessionId, - patch: { webPort: msg.port }, + patch: { webPort }, }, }); - // Substitute-mode control card: DM owner(s) a writable terminal + manage buttons. + // Substitute-mode control card: DM owner(s) writable-terminal controls + // when supported, or manage-only controls for no-Web backends. // Consumed before any early break below so avatar-style (card-off) sessions // still deliver the owner control card. if (ds.pendingSubstituteControlCard) { @@ -3390,13 +3517,10 @@ function setupWorkerHandlers( } case 'screen_update': { - // Wait for `ready` (workerPort) before any card work — the read link - // is the LOCAL log terminal for every backend including riff - // (Web终端=日志页), so a port-less POST would render - // `http://host:undefined`. riff's early markPromptReady screen_update - // simply drops here; the `ready` handler posts the initial card with - // the real port, and riffAccessUrl rides the pending-patch flow. - if (!ds.workerPort) break; + // Wait for worker init, independently of Web Terminal availability. + // ZMX intentionally reports ready with port=0, but its plain-history + // screenshots and idle/status transitions must keep flowing. + if (!startupState.ready && !workerHasInitialized(ds)) break; const prevStatus = ds.lastScreenStatus; updateUsageLimitState(ds, msg.usageLimit); ds.lastScreenContent = msg.content; @@ -3460,7 +3584,7 @@ function setupWorkerHandlers( // user turn clears the flag. Dashboard SSE above still reflects status. if (ds.suppressRecoveryCard) break; - const readUrl = buildTerminalUrl(ds); + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const mode: DisplayMode = ds.displayMode ?? 'hidden'; @@ -3566,8 +3690,8 @@ function setupWorkerHandlers( persistStreamCardState(ds); if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) break; if ((ds.displayMode ?? 'hidden') !== 'screenshot') break; - if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !ds.workerPort) break; - const readUrl = buildTerminalUrl(ds); + if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !workerHasInitialized(ds)) break; + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const cardJson = buildStreamingCard( ds.session.sessionId, @@ -3842,8 +3966,8 @@ function setupWorkerHandlers( if (ds.adoptedFrom) { logger.info(`[${t}] Adopted session ended`); // Freeze the streaming card - if (!suppressExitUi && ds.streamCardId && (ds.workerPort || ds.riffAccessUrl)) { - const readUrl = buildTerminalUrl(ds); + if (!suppressExitUi && ds.streamCardId && workerHasInitialized(ds)) { + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const frozenCard = buildStreamingCard( ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, @@ -3880,11 +4004,11 @@ function setupWorkerHandlers( if (rc.count > 3) { logger.warn(`[${t}] ${getCliDisplayName(effectiveCliId)} crashed ${rc.count} times in 1 min, not auto-restarting`); const keepDiagnosticWorker = !!msg.canParkDiagnostic && !!ds.worker && !ds.worker.killed; - // Freeze the last streaming card so it doesn't stay at "working" forever. - // 读链接严格要求 workerPort(riffAccessUrl 是写能力且 worker 退出后不清, - // 用它放行会构造 host:undefined 的坏读链接)。 - if (!suppressExitUi && ds.streamCardId && ds.workerPort) { - const readUrl = buildTerminalUrl(ds); + // Freeze the last streaming card so it doesn't stay at "working" + // forever. Backends without a Web Terminal pass an empty read URL; + // the card keeps snapshot/manage controls and omits terminal links. + if (!suppressExitUi && ds.streamCardId && workerHasInitialized(ds)) { + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const frozenCard = buildStreamingCard( ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, @@ -4285,6 +4409,7 @@ function setupWorkerHandlers( if (ds.worker === worker) { restartCoordinator.failSession(ds.session.sessionId); ds.worker = null; + ds.workerReady = false; ds.workerPort = null; ds.managedTurnOrigin = undefined; // This worker generation is gone. Invalidate any stuck-warning card it @@ -4861,6 +4986,7 @@ export function adoptSandboxBlocked( export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata?: boolean; prompt?: string; turnId?: string }): void { if (!canForkRegisteredSession(ds)) return; + ds.workerReady = false; const cb = requireCallbacks(); const workerPath = join(__dirname, '..', 'worker.js'); const t = tag(ds); @@ -4904,6 +5030,7 @@ export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata try { ds.worker.send({ type: 'close' } as DaemonToWorker); } catch {} try { ds.worker.kill(); } catch {} ds.worker = null; + ds.workerReady = false; ds.workerPort = null; ds.workerToken = null; ds.workerViewToken = null; @@ -5290,10 +5417,39 @@ function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr' | 'zmx', const activeNames = new Set( activeSessions_.filter(belongsToBackend).map(s => backend.sessionName(s.sessionId)), ); - const ownedNames = new Set([ - ...storedSessions.filter(belongsToBackend).map(s => backend.sessionName(s.sessionId)), - ...activeNames, - ]); + const ownedSessions = [ + ...storedSessions.filter(belongsToBackend), + ...activeSessions_.filter(belongsToBackend), + ]; + const ownedIdsByName = new Map<string, Set<string>>(); + for (const session of ownedSessions) { + const name = backend.sessionName(session.sessionId); + const ids = ownedIdsByName.get(name) ?? new Set<string>(); + ids.add(session.sessionId); + ownedIdsByName.set(name, ids); + } + const ownedNames = new Set(ownedIdsByName.keys()); + const killOwnedBackendSession = (name: string, exactSessionId?: string): void => { + if (backendType !== 'zmx') { + backend.killSession(name); + return; + } + const candidates = exactSessionId + ? new Set([exactSessionId]) + : ownedIdsByName.get(name); + if (!candidates || candidates.size !== 1) { + logger.warn( + `Refusing ambiguous name-only ZMX cleanup for ${name}; ` + + `matching stored session ids=${candidates?.size ?? 0}`, + ); + return; + } + try { + ZmxBackend.killManagedSession(name, [...candidates][0]!); + } catch (err) { + logger.warn(`Refusing unsafe ZMX cleanup for ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + }; if (!multiBot && lastCliId && lastCliId !== currentCliId) { logger.info(`CLI_ID changed (${lastCliId} → ${currentCliId}), killing all ${backendType} sessions`); @@ -5303,7 +5459,7 @@ function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr' | 'zmx', // ownership credential. Another checkout/data root can legitimately own // a bmx-* daemon, so never kill a name absent from this bot's store/map. if (backendType === 'zmx' && !ownedNames.has(name)) continue; - backend.killSession(name); + killOwnedBackendSession(name); } // Machine-wide Herdr agents are not separate bmx-* sessions. Tear down // each persisted managed target precisely so a CLI switch cannot reattach @@ -5316,7 +5472,7 @@ function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr' | 'zmx', for (const name of backend.listBotmuxSessions()) { if (ownedNames.has(name) && !activeNames.has(name)) { logger.info(`Killing orphaned ${backendType} session: ${name}`); - backend.killSession(name); + killOwnedBackendSession(name); } } for (const session of activeSessions_) { @@ -5335,7 +5491,7 @@ function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr' | 'zmx', ? `${target.sessionName}/${target.agentName}` : target.sessionName; logger.info(`CLI mismatch for ${session.sessionId.substring(0, 8)} (session=${sessionCliId}, bot=${botCliId}), killing ${backendType} ${label}`); - killPersistentBackendTarget(target); + killPersistentBackendTarget(target, session.sessionId); } } } diff --git a/src/daemon.ts b/src/daemon.ts index d5a19c72f..cdf650d42 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -127,6 +127,9 @@ import { ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, + workerHasInitialized, + sessionSupportsWebTerminal, + readableTerminalUrlFor, findActiveBySessionId, getDaemonBootId, type WorkerSessionReplyOptions, @@ -638,7 +641,7 @@ function vcMeetingBootBackingMissing(sessionId: string, destroy: boolean): boole ); const name = target.sessionName; if (destroy) { - try { killPersistentBackendTarget(target); } + try { killPersistentBackendTarget(target, sessionId); } catch (err) { logger.warn( `[vc-delivery] boot recovery backing kill failed backend=${backendType} ` @@ -799,7 +802,14 @@ type VcMeetingRuntimeLeaseRecoveryDeps = { resolveMissingPersistentScope: (sessionId: string) => VcMeetingRuntimePersistentScope; resolvePersistentTarget?: (sessionId: string, ds?: DaemonSession) => PersistentBackendTarget | undefined; backendAvailable: (backendType: PersistentBackendType) => boolean; - killPersistent: (backendType: PersistentBackendType, sessionName: string, target?: PersistentBackendTarget) => void; + /** `sessionId` is mandatory: ZMX destruction is identity-verified and refuses + * a name-only kill. `target` carries Herdr's agent-scoped addressing. */ + killPersistent: ( + backendType: PersistentBackendType, + sessionName: string, + sessionId: string, + target?: PersistentBackendTarget, + ) => void; probePersistent: (backendType: PersistentBackendType, sessionName: string, target?: PersistentBackendTarget) => 'exists' | 'missing' | 'unknown'; warn: (message: string) => void; error: (message: string) => void; @@ -883,7 +893,7 @@ function createVcMeetingRuntimeLeaseRecovery(deps: VcMeetingRuntimeLeaseRecovery persistedTarget, ); const name = target.sessionName; - try { deps.killPersistent(backendType, name, target); } + try { deps.killPersistent(backendType, name, fence.receiverSessionId, target); } catch (err) { deps.warn( `[vc-delivery] runtime lease backing kill failed backend=${backendType} ` @@ -1162,9 +1172,9 @@ const vcMeetingRuntimeLeaseRecovery = createVcMeetingRuntimeLeaseRecovery({ ?? sessionStore.getSession(sessionId)?.persistentBackendTarget; }, backendAvailable: vcMeetingPersistentBackendAvailable, - killPersistent: (backendType, sessionName, target) => target - ? killPersistentBackendTarget(target) - : killPersistentSession(backendType, sessionName), + killPersistent: (backendType, sessionName, sessionId, target) => target + ? killPersistentBackendTarget(target, sessionId) + : killPersistentSession(backendType, sessionName, sessionId), probePersistent: (backendType, sessionName, target) => target ? probePersistentBackendTarget(target) : probePersistentSession(backendType, sessionName), @@ -3885,8 +3895,8 @@ function beginNewTurn(ds: DaemonSession, title: string): void { ds.streamingCardForced = undefined; const previousUsageLimit = ds.usageLimit; const previousStatus = ds.lastScreenStatus === 'limited' && previousUsageLimit ? 'limited' : 'idle'; - if (ds.streamCardId && ds.workerPort) { - const readUrl = buildTerminalUrl(ds); + if (ds.streamCardId && workerHasInitialized(ds)) { + const readUrl = readableTerminalUrlFor(ds); const dsBotCfg = getBot(ds.larkAppId).config; const prevTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(dsBotCfg.cliId); const prevMode = ds.displayMode ?? 'hidden'; @@ -18185,7 +18195,9 @@ export async function startDaemon(botIndex?: number): Promise<void> { host: config.web.host, resolvePort: (sessionId) => { for (const ds of activeSessions.values()) { - if (ds.session.sessionId === sessionId && ds.workerPort) return ds.workerPort; + if (ds.session.sessionId === sessionId && sessionSupportsWebTerminal(ds) && ds.workerPort) { + return ds.workerPort; + } } return undefined; }, diff --git a/src/dashboard/session-card-model.ts b/src/dashboard/session-card-model.ts index 54115593d..e2c963cb0 100644 --- a/src/dashboard/session-card-model.ts +++ b/src/dashboard/session-card-model.ts @@ -10,6 +10,7 @@ */ import type { SessionRow } from '../core/dashboard-rows.js'; +import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js'; import type { ButtonState, PaginationMeta, @@ -237,13 +238,15 @@ export function composeDetail(row: SessionRow, _nowMs?: number): SessionDetailDt const isClosed = row.status === 'closed'; const isStarting = row.status === 'starting'; const canCloseNow = !isClosed && !isStarting; + const supportsWebTerminal = row.backendType === undefined + || backendSupportsWebTerminal(row.backendType); // Closed sessions can still carry a stale // webPort (closeSession / dashboard close don't null the field), so a // simple `webPort != null` check would surface a dead terminal link on // the closed detail card. Gate openTerminal on status too — terminals // are only meaningful on a live worker. const canOpenTerminal = - !isClosed && row.webPort !== null && row.webPort !== undefined; + supportsWebTerminal && !isClosed && row.webPort !== null && row.webPort !== undefined; const locateMode: LocateMode = row.scope === 'chat' ? 'openChat' : 'openTopic'; const actions: SessionActionMatrix = { @@ -252,7 +255,12 @@ export function composeDetail(row: SessionRow, _nowMs?: number): SessionDetailDt enabled: false, reasonKey: isStarting ? 'sessions.action.close.starting' : 'sessions.action.close.alreadyClosed', }, - openTerminal: canOpenTerminal ? { enabled: true } : { enabled: false, reasonKey: 'sessions.action.terminal.noPort' }, + openTerminal: canOpenTerminal ? { enabled: true } : { + enabled: false, + reasonKey: supportsWebTerminal + ? 'sessions.action.terminal.noPort' + : 'sessions.action.terminal.unsupported', + }, locate: { enabled: true }, locateMode, }; diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index cd35c6e94..6df6b0b06 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1240,9 +1240,9 @@ const zh: DashboardMessages = { 'settings.openTerminalInFeishu': '流式卡片 Web 终端按钮在飞书侧栏打开', 'settings.openTerminalInFeishuHelp': '默认关闭。将 Web Terminal 链接包装为飞书侧栏打开;写权限仍由终端 token 控制。', 'settings.enableLocalCliOpen': '启用本机 CLI 直开', - 'settings.enableLocalCliOpenHelp': '默认关闭,仅 daemon 运行在 macOS 时生效。附加模式支持当前 tmux / Herdr / ZMX 会话,尽量保持同一路 I/O/历史和飞书连续性;resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', + 'settings.enableLocalCliOpenHelp': '默认关闭,仅 daemon 运行在 macOS 时生效。附加模式支持当前 tmux / Herdr / ZMX 会话并进入同一个 CLI;ZMX 本地 attach 使用原生终端,飞书侧仍为纯文本流。resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', 'settings.localCliOpenMode': '本机 CLI 打开模式', - 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr / ZMX 走精确 attach,adopt 会话只在已有可靠目标时附加;direct resume 适合明确接受飞书连续性风险的本机调试。', + 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr / ZMX 走精确 attach,adopt 会话只在已有可靠目标时附加;ZMX 的原生 attach 不代表 botmux 提供 Web TUI。direct resume 适合明确接受飞书连续性风险的本机调试。', 'settings.localCliOpenModeAttach': '附加当前会话(推荐)', 'settings.localCliOpenModeResume': '另起 CLI resume', 'settings.sectionExperimental': '实验性配置', @@ -1743,7 +1743,7 @@ const zh: DashboardMessages = { 'botDefaults.readIsolationStaleOn': '(已开启,但当前环境无法强制执行——会话会 fail-close 拒启动,建议关闭以恢复)', 'botDefaults.sectionBackend': '会话后端', 'botDefaults.backendLabel': '后端类型', - 'botDefaults.backendHelp': '该 bot 新会话使用的会话后端;改动实时生效于新会话,运行中的会话仍用其启动时的后端、不受影响。tmux 支持 adopt / Web 终端;herdr / zmx 托管持久会话;zellij 实验性;pty 最轻量但不跨 daemon 重启存活。“自动”跟随全局默认。', + 'botDefaults.backendHelp': '该 bot 新会话使用的会话后端;改动实时生效于新会话,运行中的会话仍用其启动时的后端、不受影响。tmux 支持 adopt / Web 终端;herdr 托管持久会话;zmx >= 0.7.1 使用 tail 变化信号 + history 权威纯文本屏幕 + send 输入(无 Web TUI);zellij 实验性;pty 最轻量但不跨 daemon 重启存活。“自动”跟随全局默认。', 'botDefaults.backendAuto': '自动(跟随全局默认)', 'botDefaults.backendTmux': 'tmux', 'botDefaults.backendHerdr': 'herdr', @@ -3239,9 +3239,9 @@ const en: DashboardMessages = { 'settings.openTerminalInFeishu': 'Open streaming-card Web terminals in the Feishu sidebar', 'settings.openTerminalInFeishuHelp': 'Off by default. Wraps Web Terminal links so Feishu opens them in the sidebar. Write access is still controlled by the terminal token.', 'settings.enableLocalCliOpen': 'Enable native CLI opening', - 'settings.enableLocalCliOpenHelp': 'Off by default and effective only when the daemon runs on macOS. Attach mode supports current tmux / Herdr / ZMX sessions and keeps the same I/O/history when possible; resume mode starts a separate CLI resume process and may stop Feishu from following the session.', + 'settings.enableLocalCliOpenHelp': 'Off by default and effective only when the daemon runs on macOS. Attach mode enters the same CLI in current tmux / Herdr / ZMX sessions; a ZMX local attach uses the native terminal while Lark remains a plain-text stream. Resume mode starts a separate CLI resume process and may stop Feishu from following the session.', 'settings.localCliOpenMode': 'Native CLI open mode', - 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr / ZMX use exact attach, adopted sessions attach only when a reliable existing target is known. Direct resume is for local debugging when you accept the Feishu continuity risk.', + 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr / ZMX use exact attach, and adopted sessions attach only when a reliable existing target is known. A native ZMX attach does not mean botmux provides a Web TUI. Direct resume is for local debugging when you accept the Feishu continuity risk.', 'settings.localCliOpenModeAttach': 'Attach current session (recommended)', 'settings.localCliOpenModeResume': 'Start CLI resume', 'settings.sectionExperimental': 'Experimental', @@ -3742,7 +3742,7 @@ const en: DashboardMessages = { 'botDefaults.readIsolationStaleOn': "(enabled, but it can't be enforced here — sessions will fail-close; turn it off to recover)", 'botDefaults.sectionBackend': 'Session backend', 'botDefaults.backendLabel': 'Backend type', - 'botDefaults.backendHelp': 'The session backend for this bot\'s new sessions. Changes apply live to new sessions; running sessions keep the backend they started on and are unaffected. tmux supports adopt / Web Terminal; herdr / zmx manage persistent sessions; zellij is experimental; pty is lightest but does not survive daemon restarts. "Auto" follows the global default.', + 'botDefaults.backendHelp': 'The session backend for this bot\'s new sessions. Changes apply live to new sessions; running sessions keep the backend they started on and are unaffected. tmux supports adopt / Web Terminal; herdr manages persistent sessions; zmx >= 0.7.1 uses tail change signals + an authoritative plain-text history screen + send input (no Web TUI); zellij is experimental; pty is lightest but does not survive daemon restarts. "Auto" follows the global default.', 'botDefaults.backendAuto': 'Auto (follow global default)', 'botDefaults.backendTmux': 'tmux', 'botDefaults.backendHerdr': 'herdr', diff --git a/src/global-config.ts b/src/global-config.ts index 3eaf4359f..a9ffc4047 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -178,8 +178,9 @@ export interface DashboardGlobalConfig { openTerminalInFeishu?: boolean; /** Opt in to native "Open <CLI>" buttons on supported desktop hosts. * Default false. When enabled, localCliOpenMode defaults to 'attach' so a - * botmux-managed persistent backend attaches to the same I/O/history and - * preserves Feishu continuity; 'resume' starts a separate CLI resume process + * botmux-managed persistent backend enters the same underlying CLI and + * preserves Feishu continuity. ZMX uses its native local terminal while + * Feishu remains plain text; 'resume' starts a separate CLI resume process * and may break that continuity. */ enableLocalCliOpen?: boolean; /** How native "Open <CLI>" buttons launch on macOS. Missing defaults to diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1776e1961..505b0edba 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -56,6 +56,7 @@ export const messages: Record<string, string> = { 'card.usage_limit.retry_at': '⚠️ {cliName} usage limit has been reached. Try again after {retryLabel}.', 'card.usage_limit.retry_ready': '✅ {cliName} usage limit should have reset. Retry the last task, or send a new message.', 'card.private.snapshot_note': '🔒 Private static snapshot (visible only to you, not live-updating). Tap Open Web Terminal for the live view.', + 'card.private.snapshot_note_no_terminal': '🔒 Private static snapshot (visible only to you, not live-updating). This backend does not provide a Web Terminal.', // ─── Repo select card ──────────────────────────────────────────────────── 'card.repo.title': '📁 Project Repository', @@ -196,6 +197,7 @@ export const messages: Record<string, string> = { 'cmd.card.operator_only': '⚠️ Only authorized users (allowedUsers) can use /card.', 'cmd.term.operator_only': '⚠️ Only authorized users (allowedUsers) can use /term to get the operable terminal link.', 'cmd.term.no_session': 'No active session in this topic — /term needs a running session.', + 'cmd.term.unsupported': 'This session backend does not provide a Web Terminal. For ZMX, run botmux list or zmx attach locally.', 'cmd.term.not_ready': '🔒 Terminal isn’t ready yet; send /term again once the session is up.', 'cmd.term.failed': '⚠️ Failed to send the operable link (both the private card and DM failed); check the daemon logs.', 'cmd.term.sent_dm': '🔑 Sent the operable terminal link to your DM (kept out of the group).', @@ -262,6 +264,7 @@ export const messages: Record<string, string> = { 'cmd.repo.card_already_consumed': '✅ Repo already selected — please ignore the old card', 'cmd.repo.worktree_created_not_switched': '🌿 Worktree created: `{path}` (branch `{branch}`), but the session changed meanwhile — not switched automatically. Use `/repo {path}` to open it.', 'cmd.repo.worktree_switch_failed': '⚠️ Worktree created at `{path}`, but switching to it failed: {error}\nUse `/repo {path}` to open it manually.', + 'cmd.repo.switch_close_failed': '⚠️ Could not safely close the current session; repository was not switched: {error}', // Used when 「default-directory-only」mode has「auto-create worktree」on, at new // session start (shared by interactive new topic / dashboard create / webhook). 'worktree.auto_creating': '🌿 Creating an isolated worktree for this session (includes a git fetch, may take a few seconds)…', @@ -688,8 +691,9 @@ export const messages: Record<string, string> = { 'card.voice.user_message': 'Generate a voice summary', 'card.action.takeover_retired': '⚠️ The old "Take Over" button is retired. In bridge mode, botmux bridges the original CLI so replies still come back to Lark — no takeover needed. Full takeover (`/adopt --takeover`) is on the roadmap.', 'card.action.terminal_not_ready': '⚠️ Terminal is not ready yet, please try again shortly.', + 'card.action.terminal_unsupported': '⚠️ This session backend does not provide a Web Terminal. For ZMX, run `botmux list` or `zmx attach` locally.', 'card.action.local_terminal_opened': '💻 Requested opening local {cliName}.', - 'card.action.local_terminal_unsupported': '⚠️ This {cliName} session cannot be opened locally yet. Use Web Terminal instead.', + 'card.action.local_terminal_unsupported': '⚠️ This {cliName} session cannot be opened locally yet. Use a terminal access method supported by this backend.', 'card.action.local_cli_missing': '⚠️ Could not find the local {cliName} executable ({executable}). Install it or configure PATH / cliPathOverride.', 'card.action.local_terminal_failed': '⚠️ Failed to open local CLI: {reason}', 'card.action.local_terminal_no_permission': '🔒 You do not have operate permission, so you cannot open the local CLI.', @@ -839,6 +843,7 @@ export const messages: Record<string, string> = { 'card.dashboard.sessions.confirm.resume.title': 'Resume this session?', 'card.dashboard.sessions.confirm.resume.text': 'Resume will recreate the worker and continue the session. Session: {title}', 'card.dashboard.sessions.terminal.disabled.noPort': 'Web Terminal port is not available (not started or already closed)', + 'card.dashboard.sessions.terminal.disabled.unsupported': 'This backend does not provide a Web Terminal', 'card.dashboard.sessions.resume.disabled.onlyClosed': 'Only closed sessions can be resumed', // schedules card (PR3 slice 1) @@ -1022,9 +1027,9 @@ export const messages: Record<string, string> = { 'settings.openTerminalInFeishu': 'Open Web Terminal in Feishu', 'settings.openTerminalInFeishuHelp': 'Terminal links open in the Feishu in-app webview by default.', 'settings.enableLocalCliOpen': 'Enable native CLI opening', - 'settings.enableLocalCliOpenHelp': 'Off by default and available only when the daemon runs on macOS. Attach mode supports current tmux / Herdr / ZMX sessions and keeps the same I/O/history when possible; resume mode starts a separate CLI resume process and may stop Feishu from following the session.', + 'settings.enableLocalCliOpenHelp': 'Off by default and available only when the daemon runs on macOS. Attach mode enters the same CLI in current tmux / Herdr / ZMX sessions; a ZMX local attach uses the native terminal while Lark remains a plain-text stream. Resume mode starts a separate CLI resume process and may stop Feishu from following the session.', 'settings.localCliOpenMode': 'Native CLI open mode', - 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr / ZMX use exact attach, adopted sessions attach only when a reliable existing target is known. Direct resume is for local debugging when you accept the Feishu continuity risk.', + 'settings.localCliOpenModeHelp': 'Defaults to attaching the current session: managed tmux / Herdr / ZMX use exact attach, and adopted sessions attach only when a reliable existing target is known. A native ZMX attach does not mean botmux provides a Web TUI. Direct resume is for local debugging when you accept the Feishu continuity risk.', 'settings.localCliOpenModeAttach': 'Attach current session (recommended)', 'settings.localCliOpenModeResume': 'Start CLI resume', 'settings.autoUpdate': 'Daily auto-update', @@ -1090,6 +1095,10 @@ export const messages: Record<string, string> = { // Worker-side submit / notify messages 'worker.submit_impossible': '⚠️ Your last message was NOT delivered to {cliName}: the current keybinding config can’t auto-submit from the terminal.\nReason: {reason}\nAdjust the Claude Code Chat keybinding, then resend.\nStart: {preview}', 'worker.submit_unconfirmed': '⚠️ Your last message was sent to {cliName} but submission couldn’t be confirmed (after retrying Enter and waiting {secs}s, no new entry showed up in {transcriptLabel}). It may be stuck in the input box — check the Web terminal and press Enter manually or resend.\nStart: {preview}', + 'worker.submit_unconfirmed_zmx': '⚠️ Your last message could not be confirmed as written to {cliName} after {secs}s. ZMX has no Web Terminal; resend it, or run botmux list locally and inspect the session.\nStart: {preview}', + 'worker.interrupt_unconfirmed': '⚠️ Interrupt key {key} could not be delivered to {cliName} after two attempts. The CLI may still be running, and the card will not claim it stopped. {recovery}', + 'worker.interrupt_recovery_zmx': 'Retry, or run botmux list locally, enter the ZMX session, and interrupt it there.', + 'worker.interrupt_recovery_web': 'Retry, or open the Web Terminal and interrupt it manually.', 'worker.skill_delivery_failed': '⚠️ This bot’s Skill delivery config blocked the new session: {reason}\nSet skills.delivery to auto/prompt, or switch to a CLI that supports native skill delivery, then retry.', 'worker.coco_session_dir_gone': '⚠️ The current CoCo session directory was deleted (e2e cleanup or a manual rm). Content written to events.jsonl lands on a stale inode the bridge can’t read. Restart CoCo and run /adopt again.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 714d4d085..469e8b0a9 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -59,6 +59,7 @@ export const messages: Record<string, string> = { 'card.usage_limit.retry_at': '⚠️ 当前已达到 {cliName} 使用限额。请在 {retryLabel} 后再试。', 'card.usage_limit.retry_ready': '✅ {cliName} 限额预计已刷新。你可以重发上一条任务,或直接发送新消息。', 'card.private.snapshot_note': '🔒 仅你可见的静态快照(不会实时刷新)。点「打开 Web 终端」查看实时画面。', + 'card.private.snapshot_note_no_terminal': '🔒 仅你可见的静态快照(不会实时刷新)。当前后端不提供 Web 终端。', // ─── Repo select card ──────────────────────────────────────────────────── 'card.repo.title': '📁 项目仓库管理', @@ -199,6 +200,7 @@ export const messages: Record<string, string> = { 'cmd.card.operator_only': '⚠️ 仅授权用户(allowedUsers)可以使用 /card。', 'cmd.term.operator_only': '⚠️ 仅授权用户(allowedUsers)可以用 /term 获取可操作终端链接。', 'cmd.term.no_session': '当前话题没有活跃会话,/term 需要一个跑着的会话。', + 'cmd.term.unsupported': '当前会话后端不提供 Web 终端;ZMX 会话请在本机运行 botmux list 或 zmx attach。', 'cmd.term.not_ready': '🔒 终端还没就绪,等会话起好后再发 /term。', 'cmd.term.failed': '⚠️ 可操作链接发送失败(私密卡与 DM 均失败),看 daemon 日志。', 'cmd.term.sent_dm': '🔑 可操作终端链接已私信发你(不在群里暴露)。', @@ -265,6 +267,7 @@ export const messages: Record<string, string> = { 'cmd.repo.card_already_consumed': '✅ 仓库已选定,请忽略旧的选仓卡片', 'cmd.repo.worktree_created_not_switched': '🌿 worktree 已创建:`{path}`(分支 `{branch}`),但会话状态已变化,未自动切换。需要时可用 `/repo {path}` 打开。', 'cmd.repo.worktree_switch_failed': '⚠️ worktree 已创建:`{path}`,但自动切换失败:{error}\n可用 `/repo {path}` 手动打开。', + 'cmd.repo.switch_close_failed': '⚠️ 无法安全关闭当前会话,仓库未切换:{error}', // 「仅默认目录」模式开启「自动创建 worktree」后,新会话启动时用(daemon 交互新话题 / // dashboard 建会话 / webhook 外部事件三条路复用)。 'worktree.auto_creating': '🌿 正在为本会话创建独立 worktree(含 git fetch,可能需要几秒)…', @@ -691,8 +694,9 @@ export const messages: Record<string, string> = { 'card.voice.user_message': '生成语音总结', 'card.action.takeover_retired': '⚠️ 旧版"接管"按钮已停用。bridge 模式下原 CLI 由 botmux 桥接,无需接管即可在飞书中收到回答。如需 /resume 完整接管能力,请等待 /adopt --takeover 命令上线。', 'card.action.terminal_not_ready': '⚠️ 终端尚未就绪,请稍后再试。', + 'card.action.terminal_unsupported': '⚠️ 当前会话后端不提供 Web 终端;ZMX 会话请在本机运行 `botmux list` 或 `zmx attach`。', 'card.action.local_terminal_opened': '💻 已请求打开本机 {cliName}', - 'card.action.local_terminal_unsupported': '⚠️ 当前 {cliName} 会话暂不支持本机直开,请使用 Web 终端。', + 'card.action.local_terminal_unsupported': '⚠️ 当前 {cliName} 会话暂不支持本机直开,请使用该后端支持的终端接入方式。', 'card.action.local_cli_missing': '⚠️ 本机未找到 {cliName} 可执行文件({executable}),请先安装或配置 PATH / cliPathOverride。', 'card.action.local_terminal_failed': '⚠️ 打开本机 CLI 失败:{reason}', 'card.action.local_terminal_no_permission': '🔒 没有操作权限,无法打开本机 CLI', @@ -842,6 +846,7 @@ export const messages: Record<string, string> = { 'card.dashboard.sessions.confirm.resume.title': '确认恢复会话?', 'card.dashboard.sessions.confirm.resume.text': '恢复后将重新创建工作进程并继续会话。会话:{title}', 'card.dashboard.sessions.terminal.disabled.noPort': '会话尚无 Web 终端端口(未启动或已关闭)', + 'card.dashboard.sessions.terminal.disabled.unsupported': '当前后端不提供 Web 终端', 'card.dashboard.sessions.resume.disabled.onlyClosed': '仅可恢复已关闭的会话', // schedules card (PR3 slice 1) @@ -1025,9 +1030,9 @@ export const messages: Record<string, string> = { 'settings.openTerminalInFeishu': '飞书内打开 Web 终端', 'settings.openTerminalInFeishuHelp': '终端链接默认在飞书 webview 打开。', 'settings.enableLocalCliOpen': '启用本机 CLI 直开', - 'settings.enableLocalCliOpenHelp': '默认关闭,仅 macOS daemon 可用。附加模式支持当前 tmux / Herdr / ZMX 会话,尽量保持同一路 I/O/历史和飞书连续性;resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', + 'settings.enableLocalCliOpenHelp': '默认关闭,仅 macOS daemon 可用。附加模式支持当前 tmux / Herdr / ZMX 会话并进入同一个 CLI;ZMX 本地 attach 使用原生终端,飞书侧仍为纯文本流。resume 模式会另起 CLI resume 进程,可能使飞书侧不再跟随该会话。', 'settings.localCliOpenMode': '本机 CLI 打开模式', - 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr / ZMX 走精确 attach,adopt 会话只在已有可靠目标时附加;direct resume 适合明确接受飞书连续性风险的本机调试。', + 'settings.localCliOpenModeHelp': '默认附加当前会话:managed tmux / Herdr / ZMX 走精确 attach,adopt 会话只在已有可靠目标时附加;ZMX 的原生 attach 不代表 botmux 提供 Web TUI。direct resume 适合明确接受飞书连续性风险的本机调试。', 'settings.localCliOpenModeAttach': '附加当前会话(推荐)', 'settings.localCliOpenModeResume': '另起 CLI resume', 'settings.autoUpdate': '每日自动更新', @@ -1093,6 +1098,10 @@ export const messages: Record<string, string> = { // Worker-side submit / notify messages 'worker.submit_impossible': '⚠️ 刚才那条消息没有写入 {cliName},因为当前按键配置无法从终端自动提交。\n原因:{reason}\n请调整 Claude Code Chat keybinding 后重发。\n开头:{preview}', 'worker.submit_unconfirmed': '⚠️ 刚才那条消息发给 {cliName} 后没能确认提交(重试 Enter 后等了 {secs}s 仍未在{transcriptLabel}里看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:{preview}', + 'worker.submit_unconfirmed_zmx': '⚠️ 刚才那条消息没能确认写入 {cliName}(等待 {secs}s 后仍无提交证据)。ZMX 不提供 Web 终端;请重发,或在本机运行 botmux list 进入该会话检查。\n开头:{preview}', + 'worker.interrupt_unconfirmed': '⚠️ 中断键 {key} 连续两次未送达 {cliName},CLI 可能仍在运行;卡片不会把这次操作标成已停止。{recovery}', + 'worker.interrupt_recovery_zmx': '请重试;也可在本机运行 botmux list,进入该 ZMX 会话后手动中断。', + 'worker.interrupt_recovery_web': '请重试,或打开 Web 终端后手动中断。', 'worker.skill_delivery_failed': '⚠️ 当前 bot 的 Skill delivery 配置阻止了新会话启动:{reason}\n请把 skills.delivery 改为 auto/prompt,或改用支持 native skill delivery 的 CLI 后再重试。', 'worker.coco_session_dir_gone': '⚠️ 当前 CoCo 进程的会话目录已被删除(可能是 e2e 测试清理或手动 rm),写到 events.jsonl 的内容会落到一个失效 inode 上,桥接读不到。请重启 CoCo 后重新 /adopt。', diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index 51ae0be10..9be48af18 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -297,23 +297,26 @@ export function buildSessionCard( const cliName = getCliDisplayName(cliId ?? 'claude-code'); const effectiveCliId = cliId ?? 'claude-code'; const actionBase = { root_id: rootId, session_id: sessionId, cli_id: effectiveCliId }; - const actions: any[] = [ - { + const actions: any[] = []; + if (terminalUrl) { + actions.push({ tag: 'button', text: { tag: 'plain_text', content: t(showManageButtons ? 'card.btn.open_writable_terminal' : 'card.btn.open_terminal', undefined, locale) }, type: 'primary', multi_url: terminalMultiUrl(terminalUrl), - }, - ]; + }); + } if (!showManageButtons) { const localBtn = cliId ? localCliButton(effectiveCliId, actionBase, locale, localCliReady) : undefined; if (localBtn) actions.push(localBtn); - actions.push({ - tag: 'button', - text: { tag: 'plain_text', content: t('card.btn.get_write_link', undefined, locale) }, - type: 'default', - value: { action: 'get_write_link', ...actionBase }, - }); + if (terminalUrl) { + actions.push({ + tag: 'button', + text: { tag: 'plain_text', content: t('card.btn.get_write_link', undefined, locale) }, + type: 'default', + value: { action: 'get_write_link', ...actionBase }, + }); + } } if (showManageButtons && !adoptMode) { actions.push({ @@ -745,12 +748,14 @@ export function buildStreamingCard( value: { action: 'refresh_screenshot', ...actionBase }, }); } - headerActions.push({ - tag: 'button', - text: { tag: 'plain_text', content: t('card.btn.open_terminal', undefined, locale) }, - type: 'primary', - multi_url: terminalMultiUrl(terminalUrl), - }); + if (terminalUrl) { + headerActions.push({ + tag: 'button', + text: { tag: 'plain_text', content: t('card.btn.open_terminal', undefined, locale) }, + type: 'primary', + multi_url: terminalMultiUrl(terminalUrl), + }); + } const localBtn = cliId ? localCliButton(effectiveCliId, actionBase, locale, localCliReady) : undefined; if (localBtn) headerActions.push(localBtn); if (status === 'limited' && usageLimit?.retryReady) { @@ -761,12 +766,14 @@ export function buildStreamingCard( value: { action: 'retry_last_task', ...actionBase }, }); } - headerActions.push({ - tag: 'button', - text: { tag: 'plain_text', content: t('card.btn.get_write_link', undefined, locale) }, - type: 'default', - value: { action: 'get_write_link', ...actionBase }, - }); + if (terminalUrl) { + headerActions.push({ + tag: 'button', + text: { tag: 'plain_text', content: t('card.btn.get_write_link', undefined, locale) }, + type: 'default', + value: { action: 'get_write_link', ...actionBase }, + }); + } if (adoptMode) { if (showTakeover) { headerActions.push({ @@ -852,9 +859,8 @@ export function buildStreamingCard( * Build a static "private snapshot" card for `/card` in private mode — sent via * the ephemeral API to one user at a time. Unlike {@link buildStreamingCard} it * is **never PATCH-updated** (ephemeral cards can't be), so it carries only a - * one-shot snapshot of the terminal screenshot plus three buttons: - * • read-only "open terminal" link (a plain URL button — no callback); - * • "get write link", whose callback DMs the writable link to the clicker; + * one-shot snapshot of the terminal screenshot plus controls: + * • when available, a read-only "open terminal" link and "get write link"; * • "close session", whose callback kills the session and (in private mode) * sends the "closed" card ephemeral to the owner audience too — so the * session title / CLI name / workingDir on it don't leak to the group. @@ -910,9 +916,9 @@ export function buildPrivateSnapshotCard( } } - elements.push({ - tag: 'action', - actions: [ + const actions: any[] = []; + if (terminalUrl) { + actions.push( { tag: 'button', text: { tag: 'plain_text', content: t('card.btn.open_terminal', undefined, locale) }, @@ -925,17 +931,21 @@ export function buildPrivateSnapshotCard( type: 'default', value: { action: 'get_write_link', ...actionBase }, }, - { - tag: 'button', - text: { tag: 'plain_text', content: t('card.btn.close_session', undefined, locale) }, - type: 'danger', - value: { action: 'close', ...actionBase }, - }, - ], + ); + } + actions.push({ + tag: 'button', + text: { tag: 'plain_text', content: t('card.btn.close_session', undefined, locale) }, + type: 'danger', + value: { action: 'close', ...actionBase }, }); + elements.push({ tag: 'action', actions }); elements.push({ tag: 'note', - elements: [{ tag: 'lark_md', content: t('card.private.snapshot_note', undefined, locale) }], + elements: [{ + tag: 'lark_md', + content: t(terminalUrl ? 'card.private.snapshot_note' : 'card.private.snapshot_note_no_terminal', undefined, locale), + }], }); const card = { diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 2ce7cb1cb..85d3aa762 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -69,7 +69,7 @@ import { ttadkConfigModelChoices } from '../../setup/cli-selection.js'; import { logger } from '../../utils/logger.js'; import * as sessionStore from '../../services/session-store.js'; import { loadFrozenCards, saveFrozenCards } from '../../services/frozen-card-store.js'; -import { forkWorker, sendWorkerInput, killWorker, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart } from '../../core/worker-pool.js'; +import { forkWorker, sendWorkerInput, killWorker, closeSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart } from '../../core/worker-pool.js'; import { getSessionWorkingDir, buildNewTopicCliInput, getAvailableBots, persistStreamCardState, resumeSession, rememberLastCliInput, ensureSessionWhiteboard } from '../../core/session-manager.js'; import { markInitialUserTurnPending } from '../../core/initial-user-turn.js'; import { publishAttentionPatch, announcePendingRepoSession } from '../../core/session-activity.js'; @@ -581,6 +581,22 @@ export async function commitRepoSelection( return; } else { // Mid-session repo switch — close old session, start fresh. + // ZMX close is identity/generation verified and may refuse. Prove teardown + // before claiming the card or mutating any old-session state; on refusal + // the current session and card remain fully retryable. + try { + teardownAuthoritativePersistentBackingBeforeClose(ds); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logger.warn(`[${tag(ds)}] Repo switch refused because backing teardown was not proven: ${reason}`); + try { + await sessionReply(rootId, t('cmd.repo.switch_close_failed', { error: reason }, locTarget)); + } catch (replyErr) { + logger.warn(`[${tag(ds)}] Repo-switch teardown failure reply failed: ${replyErr instanceof Error ? replyErr.message : replyErr}`); + } + return; + } + // Claim the current card BEFORE killWorker / any await. A concurrent click // on the same Feishu card must not pass a second kill+fork while the first // switch is still awaiting confirm replies. Correctness does not depend on @@ -1810,8 +1826,17 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe // Build the closed card BEFORE killWorker/closeSession — it reads the // live session's identity off `ds`. const card = buildClosedSessionCard(ds, localeForBot(ds.larkAppId)); - killWorker(ds); - sessionStore.closeSession(ds.session.sessionId); + try { + await closeSession(ds.session.sessionId); + } catch (err) { + logger.error(`[${tag(ds)}] Refused close because backing teardown was not verified: ${err}`); + return { + toast: { + type: 'warning', + content: `会话关闭失败:${err instanceof Error ? err.message : String(err)}`, + }, + }; + } activeSessions.delete(sKey); // The closed card carries session title / CLI name / workingDir / resume // command. In private-card mode those must not leak to the group — send the @@ -1901,8 +1926,8 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe persistStreamCardState(ds); let cardJson: string | undefined; - if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL && ds.workerPort) { - const readUrl = buildTerminalUrl(ds); + if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL && workerHasInitialized(ds)) { + const readUrl = readableTerminalUrlFor(ds); cardJson = buildStreamingCard( ds.session.sessionId, sessionAnchorId(ds), @@ -2167,10 +2192,20 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe } if (actionType === 'get_write_link' && ds && operatorOpenId) { - const botCfg = getBot(ds.larkAppId).config; const effectiveCliId = sessionCliId(ds); const locDs = localeForBot(ds.larkAppId); - if (ds.riffAccessUrl || (ds.workerPort && ds.workerToken)) { + if (!sessionSupportsWebTerminal(ds)) { + // Old cards can retain a get_write_link callback and stale port/token + // fields after the session is restored onto ZMX. This is a permanent + // backend capability boundary, not a transient startup delay. + const unsupportedCard = JSON.stringify({ + config: { wide_screen_mode: true }, + elements: [{ tag: 'markdown', content: t('card.action.terminal_unsupported', undefined, locDs) }], + }); + await deliverEphemeralOrReply(ds, operatorOpenId, unsupportedCard, 'interactive', () => sessionReply(rootId, unsupportedCard, 'interactive')); + return; + } + if (sessionSupportsWebTerminal(ds) && (ds.riffAccessUrl || (ds.workerPort && ds.workerToken))) { const writeUrl = buildTerminalUrl(ds, { write: true }); const cardJson = buildSessionCard( ds.session.sessionId, @@ -2230,8 +2265,8 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe if (ds.worker) { ds.worker.send({ type: 'set_display_mode', mode: next } as DaemonToWorker); } - if (cardMessageId && ds.workerPort) { - const readUrl = buildTerminalUrl(ds); + if (cardMessageId && workerHasInitialized(ds)) { + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const cardJson = buildStreamingCard( ds.session.sessionId, @@ -2274,7 +2309,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe ds.worker.send({ type: 'set_display_mode', mode: next } as DaemonToWorker); } const effectiveCliId = sessionCliId(ds); - const readUrl = ds.workerPort ? buildTerminalUrl(ds) : ''; + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const cardJson = buildStreamingCard( ds.session.sessionId, @@ -2314,8 +2349,8 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe if (ds.worker) { ds.worker.send({ type: 'set_display_mode', mode: next } as DaemonToWorker); } - if (ds.streamCardId && ds.workerPort) { - const readUrl = buildTerminalUrl(ds); + if (ds.streamCardId && workerHasInitialized(ds)) { + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const cardJson = buildStreamingCard( ds.session.sessionId, @@ -2378,10 +2413,10 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe // Return the current card JSON so Feishu doesn't revert the displayed // image to the originally-POSTed initial frame while waiting for the // fresh screenshot PATCH (~1s). - if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL && ds.workerPort) { + if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL && workerHasInitialized(ds)) { const botCfg = getBot(ds.larkAppId).config; const effectiveCliId = sessionCliId(ds); - const readUrl = buildTerminalUrl(ds); + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const cardJson = buildStreamingCard( ds.session.sessionId, @@ -2419,10 +2454,10 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe ds.worker.send({ type: 'term_action', key } as DaemonToWorker); logger.info(`[${tag(ds)}] term_action: ${key}`); } - if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL && ds.workerPort) { + if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL && workerHasInitialized(ds)) { const botCfg = getBot(ds.larkAppId).config; const effectiveCliId = sessionCliId(ds); - const readUrl = buildTerminalUrl(ds); + const readUrl = readableTerminalUrlFor(ds); const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId); const cardJson = buildStreamingCard( ds.session.sessionId, diff --git a/src/im/lark/sessions-card.ts b/src/im/lark/sessions-card.ts index a9e24f259..9878e5f5e 100644 --- a/src/im/lark/sessions-card.ts +++ b/src/im/lark/sessions-card.ts @@ -568,6 +568,8 @@ function mapTerminalDisabledReason(reasonKey: string | undefined): string | unde switch (reasonKey) { case 'sessions.action.terminal.noPort': return 'card.dashboard.sessions.terminal.disabled.noPort'; + case 'sessions.action.terminal.unsupported': + return 'card.dashboard.sessions.terminal.disabled.unsupported'; default: return undefined; } diff --git a/src/services/backend-availability.ts b/src/services/backend-availability.ts index 5eda9e2bf..a6d99c5a0 100644 --- a/src/services/backend-availability.ts +++ b/src/services/backend-availability.ts @@ -58,7 +58,7 @@ export async function ensureBackendAvailable( ok: false, backendType, reason: result.reason, - manualCommand: 'macOS: brew install neurosnap/tap/zmx;Linux: 安装 ZMX 官方 release binary(>= 0.6.0)', + manualCommand: '等待包含 PR #202 send 行为的 ZMX >= 0.7.1 正式版;发布后 macOS 可用 Homebrew、Linux 可用官方 release binary 安装', }; } diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index be51dac79..296bfa164 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -88,7 +88,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'canTalkDaemonCommands', configKey: 'canTalkDaemonCommands', kind: 'stringList', effect: 'immediate', clearable: true, parseList: parseCanTalkDaemonCommandsInput, hint: '把列出的 daemon 命令权限从 canOperate(仅管理员)降到 canTalk(对话放行即可用),如 /status /help;仅认 daemon 命令,透传命令无效;unset 回全部仅管理员' }, { key: 'startupCommands', configKey: 'startupCommands', kind: 'stringList', effect: 'next-session', clearable: true, parseList: parseStartupCommandsInput, hint: '开会话后、首条消息前自动发给 CLI 的命令(逗号/换行分隔,可带参数,如 /effort ultracode);unset 回不发' }, { key: 'env', configKey: 'env', kind: 'json', effect: 'next-session', clearable: true, hint: 'per-bot 环境变量 JSON(如 {"ANTHROPIC_BASE_URL":"…","ANTHROPIC_AUTH_TOKEN":"…"} 让本 bot 走 GLM/第三方服务商,或设 HTTPS_PROXY);注入到本 bot 的 CLI 进程,下个会话生效;值不显示(脱敏);unset 清除' }, - { key: 'backendType', configKey: 'backendType', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['pty', 'tmux', 'herdr', 'zellij', 'zmx', 'riff'], hint: '会话后端类型:pty=本地 PTY 子进程(默认)|tmux=tmux 会话|herdr=herdr 终端复用|zellij=zellij 多路复用|zmx=ZMX 持久会话|riff=远程 riff agent 服务;选 riff 时需配置 riff 字段;unset 回 pty' }, + { key: 'backendType', configKey: 'backendType', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['pty', 'tmux', 'herdr', 'zellij', 'zmx', 'riff'], hint: '会话后端类型:pty=本地 PTY 子进程(默认)|tmux=tmux 会话|herdr=herdr 终端复用|zellij=zellij 多路复用|zmx=ZMX >=0.7.1 纯文本持久会话(无 Web TUI)|riff=远程 riff agent 服务;选 riff 时需配置 riff 字段;unset 回 pty' }, { key: 'riff', configKey: 'riff', kind: 'json', effect: 'next-session', clearable: true, hint: 'riff 后端配置 JSON(baseUrl/agent/model/jwt 等),仅 backendType=riff 时生效;unset 清除' }, ]; diff --git a/src/setup/ensure-zmx.ts b/src/setup/ensure-zmx.ts index 9f6cc6ea7..a39913caa 100644 --- a/src/setup/ensure-zmx.ts +++ b/src/setup/ensure-zmx.ts @@ -47,7 +47,7 @@ export function zmxEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv }; } -export function probeZmxFunctional(): { ok: true; version: string } | { ok: false; reason: string } { +export function probeZmxVersion(): { ok: true; version: string } | { ok: false; reason: string } { let version: string; try { version = execFileSync('zmx', ['version'], { @@ -64,13 +64,20 @@ export function probeZmxFunctional(): { ok: true; version: string } | { ok: fals if (!parsedVersion) { return { ok: false, reason: `无法解析 zmx 版本:${version.split('\n')[0] || '(empty)'}` }; } - if (compareVersion(parsedVersion, [0, 6, 0]) < 0) { + if (compareVersion(parsedVersion, [0, 7, 1]) < 0) { return { ok: false, - reason: `zmx >= 0.6.0 才受支持(当前 ${parsedVersion.join('.')})`, + reason: `zmx >= 0.7.1 才受支持(当前 ${parsedVersion.join('.')};需要包含 PR #202 的 send 行为,输出由 history 获取)`, }; } + return { ok: true, version }; +} + +export function probeZmxFunctional(): { ok: true; version: string } | { ok: false; reason: string } { + const versionProbe = probeZmxVersion(); + if (!versionProbe.ok) return versionProbe; + try { execFileSync('zmx', ['list'], { encoding: 'utf-8', @@ -83,7 +90,7 @@ export function probeZmxFunctional(): { ok: true; version: string } | { ok: fals return { ok: false, reason: stderr || 'zmx list 失败' }; } - return { ok: true, version }; + return versionProbe; } export function parseZmxVersion(output: string): [number, number, number] | null { diff --git a/src/types.ts b/src/types.ts index 53e02ac95..6ce21839b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -662,7 +662,18 @@ export type DaemonToWorker = /** Messages sent from Worker to Daemon */ export type WorkerToDaemon = - | { type: 'ready'; port: number; token: string; viewToken?: string; spawnCommand?: string; replyAlreadySent?: boolean; turnId?: string; dispatchAttempt?: number } + | { + type: 'ready'; + /** Bound Web Terminal port, or 0 when the worker is ready but this + * backend intentionally has no raw-terminal Web UI capability. */ + port: number; + token: string; + viewToken?: string; + spawnCommand?: string; + replyAlreadySent?: boolean; + turnId?: string; + dispatchAttempt?: number; + } | { type: 'persistent_backend_target'; target?: PersistentBackendTarget } /** The exact inbound turn is now durably owned by this worker generation's * CLI input queue. The daemon persists a root-bound receipt only after this diff --git a/src/worker.ts b/src/worker.ts index 54cc8613d..f774fb499 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -185,6 +185,7 @@ import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionId import { sessionReadyHookCommand } from './adapters/hook-command.js'; import { mtrSessionIdForBotmuxSession } from './adapters/cli/mtr.js'; import type { CliAdapter, PtyHandle, SubmitRecheckResult, CliId } from './adapters/cli/types.js'; +import { strictInputHandle } from './adapters/cli/strict-input-handle.js'; import { PtyBackend } from './adapters/backend/pty-backend.js'; import { HerdrBackend, type HerdrWebTerminalCursor } from './adapters/backend/herdr-backend.js'; import { TmuxBackend } from './adapters/backend/tmux-backend.js'; @@ -192,6 +193,14 @@ import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js'; import { ZellijBackend, ZELLIJ_CONFIG_KDL } from './adapters/backend/zellij-backend.js'; import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.js'; import { ZmxBackend } from './adapters/backend/zmx-backend.js'; +import { + isCriticalInterruptKey, + sendCriticalControlKey, +} from './adapters/backend/critical-control-key.js'; +import { + backendCliCompatibilityError, + backendSupportsWebTerminal, +} from './adapters/backend/capabilities.js'; import { zellijEnv } from './setup/ensure-zellij.js'; import { isObserveBackend, type ObserveBackend } from './adapters/backend/types.js'; import { @@ -224,7 +233,7 @@ import { } from './platform/device-paths.js'; import type { BackendType, SessionBackend, SessionProbe } from './adapters/backend/types.js'; import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js'; -import { probeZmxFunctional } from './setup/ensure-zmx.js'; +import { probeZmxVersion } from './setup/ensure-zmx.js'; import { tmuxRestartJitterMs } from './core/tmux-recovery.js'; import { IdleDetector } from './utils/idle-detector.js'; import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js'; @@ -298,6 +307,12 @@ scrubClaudeSessionMarkerEnv(process.env); let cliAdapter: CliAdapter | null = null; let backend: SessionBackend | null = null; +let backendScreenRevision = 0; +let idleScreenSettleTask: { + backend: SessionBackend; + revision: number; + promise: Promise<{ proceed: boolean; degraded: boolean }>; +} | null = null; let intentionalRestartBackend: SessionBackend | null = null; // Hybrid codex RPC input mode (opt-in per bot, cfg.codexRpcInput). When active, // user input is delivered to codex via the app-server JSON-RPC channel @@ -351,9 +366,11 @@ function persistentPaneInfo(backendType: string, sessionId: string): { name: str if (backendType === 'tmux') name = TmuxBackend.sessionName(sessionId); else if (backendType === 'herdr') name = HerdrBackend.sessionName(sessionId); else if (backendType === 'zellij') name = ZellijBackend.sessionName(sessionId); + else if (backendType === 'zmx') name = ZmxBackend.sessionName(sessionId); if (!name) return null; const live = backendType === 'tmux' ? TmuxBackend.hasSession(name) : backendType === 'zellij' ? ZellijBackend.hasSession(name) + : backendType === 'zmx' ? ZmxBackend.probeManagedSession(name, sessionId).state === 'compatible' : HerdrBackend.hasSession(name); return { name, live }; } @@ -362,7 +379,29 @@ function persistentPaneLive(backendType: PersistentBackendType, name: string): b if (backendType === 'tmux') return TmuxBackend.hasSession(name); if (backendType === 'zellij') return ZellijBackend.hasSession(name); if (backendType === 'herdr') return HerdrBackend.hasSession(name); - return false; + return ZmxBackend.hasSession(name); +} + +function probeOwnedZmxSession( + name: string, + sessionId: string, + expectedPid?: number, +): { probe: SessionProbe; pid?: number; reason?: string } { + const managed = ZmxBackend.probeManagedSession(name, sessionId); + if (managed.state === 'missing') return { probe: 'missing' }; + if (managed.state === 'unknown') return { probe: 'unknown', reason: managed.reason }; + if (managed.state === 'incompatible') { + return { + probe: 'unknown', + reason: managed.reason === 'transport-label' + ? `ZMX 会话 ${name} 缺少 botmux 传输标签(tail 信号 + history 屏幕 + send 输入);请手动关闭旧会话后重试` + : `ZMX 会话 ${name} 属于另一个完整 botmux session`, + }; + } + if (expectedPid !== undefined && managed.pid !== expectedPid) { + return { probe: 'unknown', reason: `ZMX 会话 ${name} 的 PTY root PID 已变化` }; + } + return { probe: 'exists', pid: managed.pid }; } /** Kill a persistent session and VERIFY it is gone (bounded retry). killSession @@ -371,9 +410,13 @@ function persistentPaneLive(backendType: PersistentBackendType, name: string): b * pane would then be reattached against the NEW app-server's fresh port, * re-triggering the exact P0 freeze. Returns true only when the session is * confirmed absent (Codex delta point 1). */ -async function killPersistentSessionVerified(backendType: PersistentBackendType, name: string): Promise<boolean> { +async function killPersistentSessionVerified( + backendType: PersistentBackendType, + name: string, + sessionId?: string, +): Promise<boolean> { return killAndVerifyPersistentPane(name, { - kill: (resolvedName) => killPersistentSession(backendType, resolvedName), + kill: (resolvedName) => killPersistentSession(backendType, resolvedName, sessionId), isLive: (resolvedName) => persistentPaneLive(backendType, resolvedName), wait: delay, }); @@ -1403,16 +1446,24 @@ async function sendRawCommandLine(be: NonNullable<typeof backend>, content: stri const cmd = content.trim(); const typed = cmd.includes(' ') ? cmd : `${cmd} `; for (const ch of typed) { - (be as any).sendText(ch); + if ((be as any).sendText(ch) === false) { + throw new Error('backend rejected command text input'); + } await new Promise(r => setTimeout(r, COCO_SLASH_TYPE_THROTTLE_MS)); } await new Promise(r => setTimeout(r, 200)); - (be as any).sendSpecialKeys('Enter'); + if ((be as any).sendSpecialKeys('Enter') === false) { + throw new Error('backend rejected command submit key'); + } return; } - (be as any).sendText(content); + if ((be as any).sendText(content) === false) { + throw new Error('backend rejected command text input'); + } await new Promise(r => setTimeout(r, 200)); - (be as any).sendSpecialKeys('Enter'); + if ((be as any).sendSpecialKeys('Enter') === false) { + throw new Error('backend rejected command submit key'); + } } else { // PtyBackend has no sendText/sendSpecialKeys, so write the keystrokes // directly — but still beat between the text and the Enter. Writing @@ -4531,7 +4582,18 @@ function exitTmuxScrollMode(): void { tmuxScrolledHalfPages = 0; } -function handleTermAction(key: TermActionKey): void { +function sendTermActionOnce(target: SessionBackend, key: TermActionKey): void | boolean { + if ('sendSpecialKeys' in target && TMUX_KEY_MAP[key]) { + return (target as any).sendSpecialKeys(TMUX_KEY_MAP[key]); + } + if (PTY_SEQ_MAP[key]) { + target.write(PTY_SEQ_MAP[key]); + return true; + } + return false; +} + +async function handleTermAction(key: TermActionKey): Promise<void> { // riff:没有远端终端可驱动——把控制字符 write 进 RiffBackend 会变成一个内容为 // ANSI 序列的 follow-up 任务(^C 也不会 cancel 任务),必须整体拒绝。 if (effectiveBackendType === 'riff') { @@ -4539,11 +4601,12 @@ function handleTermAction(key: TermActionKey): void { return; } if (!backend) return; + const targetBackend = backend; const isHalfPage = key === 'half_page_up' || key === 'half_page_down'; // Tmux copy-mode scroll (works around alternate-buffer scrollback limitation) - if (isHalfPage && 'sendCopyModeCommand' in backend) { - const tb = backend as any; + if (isHalfPage && 'sendCopyModeCommand' in targetBackend) { + const tb = targetBackend as any; try { if (tmuxScrolledHalfPages === 0 && key === 'half_page_up') { tb.enterCopyMode(); @@ -4568,10 +4631,45 @@ function handleTermAction(key: TermActionKey): void { // Any non-scroll key cancels active scroll first so the live view returns. if (tmuxScrolledHalfPages > 0) exitTmuxScrollMode(); - if ('sendSpecialKeys' in backend && TMUX_KEY_MAP[key]) { - (backend as any).sendSpecialKeys(TMUX_KEY_MAP[key]); - } else if (PTY_SEQ_MAP[key]) { - backend.write(PTY_SEQ_MAP[key]); + const criticalInterrupt = isCriticalInterruptKey(key); + let delivered = false; + if (criticalInterrupt) { + delivered = await sendCriticalControlKey(() => { + // Never retry an old interrupt into a replacement CLI generation. + if (backend !== targetBackend) return true; + return sendTermActionOnce(targetBackend, key); + }); + } else { + try { + delivered = sendTermActionOnce(targetBackend, key) !== false; + } catch (err) { + log(`Term action ${key} failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (backend !== targetBackend) return; + if (!delivered) { + log(`Term action ${key} was not delivered${criticalInterrupt ? ' after retry' : ''}`); + if (criticalInterrupt) { + const recovery = t( + effectiveBackendType === 'zmx' + ? 'worker.interrupt_recovery_zmx' + : 'worker.interrupt_recovery_web', + ); + send({ + type: 'user_notify', + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + message: t('worker.interrupt_unconfirmed', { + key: TMUX_KEY_MAP[key] ?? key, + cliName: cliName(), + recovery, + }), + }); + } + // Refresh the card from real output, but do not clear prompt blocking or + // claim that the CLI stopped when the key never reached it. + scheduleOneShotAfterAction(); + return; } // ESC/Ctrl-C/Enter likely ends an active TUI prompt. Un-wedge the blocking // flag here — without this, dismissing an AskUserQuestion dialog via the @@ -4751,7 +4849,7 @@ async function handleTuiTextInput(keys: string[], text: string): Promise<void> { // Step 3: write text via cliAdapter (auto-switches to text mode + submits with Enter) log(`TUI text input: writing "${text.substring(0, 80)}" to PTY (after ${navKeys.length} nav keys)`); try { - await cliAdapter.writeInput(backend, text); + await cliAdapter.writeInput(adapterInputHandle(backend), text); } catch (err: any) { log(`TUI text input write failed: ${err.message}`); } @@ -4870,6 +4968,32 @@ function dismissAidenCodexUpdateDialog(data: string): boolean { return true; } +/** + * Handle startup-only interactions discovered in visible terminal text. + * Both incremental PTY chunks and authoritative observer snapshots pass + * through here so reconnecting a live-only backend cannot strand the CLI on a + * dialog that appeared while its observer was offline. + */ +function handleVisibleStartupInteraction(data: string): boolean { + // Aiden strips the Codex config override because the launcher rejects it. + // Consume only the known startup update picker and choose its non-upgrade + // row; never let its selection marker reach the first-prompt idle detector. + if (dismissAidenCodexUpdateDialog(data)) return true; + + if (trustHandled) return false; + const stripped = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); + if (!TRUST_DIALOG_PATTERN.test(stripped)) return false; + + trustHandled = true; + log('Trust dialog detected, auto-accepting...'); + if (backend && 'sendSpecialKeys' in backend) { + (backend as any).sendSpecialKeys('Enter'); + } else { + backend?.write('\r'); + } + return true; +} + // Codex App runner sends botmux control messages as OSC sequences so they do // not pollute the visible terminal. Strip them before xterm rendering and // translate them back into worker IPC. @@ -5099,9 +5223,57 @@ function maybeReportDeferredTopicMaterialization(data: string): void { // ─── Prompt Detection ──────────────────────────────────────────────────────── +function settleBackendScreenBeforeIdle( + target: SessionBackend, + requestedRevision: number, +): Promise<{ proceed: boolean; degraded: boolean }> { + if (!target.settleCurrentScreen) return Promise.resolve({ proceed: true, degraded: false }); + const existing = idleScreenSettleTask?.backend === target ? idleScreenSettleTask : null; + // A settle round is reusable only for the same (or a newer) authoritative + // screen revision. If another idle edge arrives after more output while the + // old barrier is still running, chain one fresh round behind it; otherwise + // the later edge could finalize from a sample that began before its output. + if (existing && existing.revision >= requestedRevision) return existing.promise; + + const runFreshSettle = async (): Promise<{ proceed: boolean; degraded: boolean }> => { + for (let attempt = 0; attempt < 3; attempt += 1) { + if (backend !== target) return { proceed: false, degraded: false }; + try { + if (await target.settleCurrentScreen!()) return { proceed: true, degraded: false }; + } catch (err) { + log(`Screen settle attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1))); + } + // History is an availability enhancement around an already-successful + // screen cache. Never leave a completed turn stuck forever when the ZMX + // daemon is busy; use the last authoritative snapshot after bounded retry. + return { proceed: true, degraded: true }; + }; + const promise = (async (): Promise<{ proceed: boolean; degraded: boolean }> => { + if (existing) await existing.promise; + if (backend !== target) return { proceed: false, degraded: false }; + return runFreshSettle(); + })(); + idleScreenSettleTask = { backend: target, revision: requestedRevision, promise }; + void promise.finally(() => { + if (idleScreenSettleTask?.promise === promise) idleScreenSettleTask = null; + }); + return promise; +} + +/** Submission writes must surface ZMX's explicit false result, while its + * best-effort navigation/startup keystrokes keep their non-throwing contract. */ +function adapterInputHandle(target: SessionBackend): PtyHandle { + return target instanceof ZmxBackend + ? strictInputHandle(target) + : target; +} + function onPtyData(data: string): void { data = splitCodexAppControl(data); if (data.length === 0) return; + backendScreenRevision += 1; lastPtyActivityAtMs = Date.now(); maybeReportDeferredTopicMaterialization(data); maybeCaptureKiroSessionId(data); @@ -5143,25 +5315,7 @@ function onPtyData(data: string): void { } } - // Aiden strips the Codex config override because the launcher rejects it. - // Consume only the known startup update picker and choose its non-upgrade - // row; never let its selection marker reach the first-prompt idle detector. - if (dismissAidenCodexUpdateDialog(data)) return; - - // Trust dialog auto-accept - if (!trustHandled) { - const stripped = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); - if (TRUST_DIALOG_PATTERN.test(stripped)) { - trustHandled = true; - log('Trust dialog detected, auto-accepting...'); - if (backend && 'sendSpecialKeys' in backend) { - (backend as any).sendSpecialKeys('Enter'); - } else { - backend?.write('\r'); - } - return; - } - } + if (handleVisibleStartupInteraction(data)) return; // Track last PTY output time for the ready-gate quiescence settle (see // settleThenFlush) and delegate idle detection to IdleDetector. @@ -5169,6 +5323,47 @@ function onPtyData(data: string): void { idleDetector?.feed(data); } +/** + * Rebase state derived from a live-only observer after it reconnects. This is + * deliberately separate from onPtyData: `snapshot` is authoritative full + * state, not an incremental chunk, so appending it would duplicate renderer + * and workflow history while still failing to express a reset. + */ +function onBackendScreenResync(snapshot: string): void { + backendScreenRevision += 1; + const now = Date.now(); + lastPtyActivityAtMs = now; + lastPtyOutputAtMs = now; + lastAnalyzerSnapshot = snapshot; + maybeReportDeferredTopicMaterialization(snapshot); + maybeCaptureKiroSessionId(snapshot); + + if (renderer) { + renderer.dispose(); + renderer = new TerminalRenderer(renderCols, renderRows); + renderer.write(snapshot); + } + + if (isWorkflowWorker() && !workflowFinalOutputSent) { + // The append-only PTY replay log has no reset opcode. Appending a full + // history snapshot would duplicate everything already recorded; keep its + // live-byte semantics and rebase only the bounded final-output transcript. + workflowTranscript = snapshot.slice(-WORKFLOW_TRANSCRIPT_MAX); + maybeEmitWorkflowTranscriptOutput(); + } + + if (handleVisibleStartupInteraction(snapshot)) return; + + // A prompt/final marker may have been emitted while tail was offline. Feed + // only the recent screen tail into a reset detector: ZMX history is scrollback, + // so old completion markers must not make a currently-busy CLI look idle. + if (idleDetector) { + idleDetector.reset(); + const idleTail = snapshot.split(/\r?\n/).slice(-20).join('\n').slice(-4000); + if (idleTail) idleDetector.feed(idleTail); + } +} + function releaseRawInputRestartGate(): void { if (!rawInputRestartGate) return; rawInputRestartGate = false; @@ -5498,7 +5693,17 @@ function scheduleSubmitFailureNotify( send({ type: 'user_notify', turnId: turnIdentity?.turnId ?? currentBotmuxTurnId, - message: t('worker.submit_unconfirmed', { cliName: cliName(), secs: Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000), transcriptLabel, preview }), + message: t( + effectiveBackendType === 'zmx' + ? 'worker.submit_unconfirmed_zmx' + : 'worker.submit_unconfirmed', + { + cliName: cliName(), + secs: Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000), + transcriptLabel, + preview, + }, + ), }); } }, SUBMIT_DEFERRED_RECHECK_MS); @@ -5847,13 +6052,17 @@ async function flushPending(): Promise<void> { result = { submitted: true }; } else if (item.codexAppInput && cliAdapter.writeStructuredInput) { result = await cliAdapter.writeStructuredInput( - backend, + adapterInputHandle(backend), msg, item.codexAppInput, { turnId: item.turnId }, ); } else { - result = await cliAdapter.writeInput(backend, msg, { turnId: item.turnId }); + result = await cliAdapter.writeInput( + adapterInputHandle(backend), + msg, + { turnId: item.turnId }, + ); } scheduleBusyPatternIdleProbe(`${cliName()} post-submit`); } catch (err: any) { @@ -6277,7 +6486,11 @@ function seedBackendScreen(source: string, be: Pick<SessionBackend, 'captureCurr try { const initial = be.captureCurrentScreen?.() ?? ''; if (initial.length > 0) { - onPtyData(initial); + if (be instanceof ZmxBackend) { + onBackendScreenResync(initial); + } else { + onPtyData(initial); + } if (be instanceof HerdrBackend) { relayHerdrWebSnapshot(initial); } @@ -6556,7 +6769,16 @@ async function spawnCli( // sessions; ZMX validates its version plus full-list control plane; Herdr // uses a non-destructive version check. let effectiveBackend = cfg.backendType; + const backendCliError = backendCliCompatibilityError(effectiveBackend, cfg.cliId as CliId); + if (backendCliError) { + throw new Error( + `⚠️ ${cfg.cliId} 当前不能使用 ZMX 后端:其完成事件依赖不可见 OSC 控制消息,` + + `而 ZMX 的纯文本 history 不保留该通道。请将 backendType 改为 tmux / pty。\n` + + `原因:${backendCliError}`, + ); + } let resolvedZmxSessionProbe: SessionProbe | undefined; + let resolvedZmxSessionPid: number | undefined; { let available = true; let reason = ''; @@ -6578,17 +6800,29 @@ async function spawnCli( reason = 'zellij 功能性探针失败(需 zellij >= 0.44)'; } } else if (effectiveBackend === 'zmx') { - // The full list is tri-state: per-session `err=` rows and malformed - // output are inconclusive, never permission to create a duplicate CLI. - resolvedZmxSessionProbe = ZmxBackend.probeSession(ZmxBackend.sessionName(cfg.sessionId)); - hasExistingSession = resolvedZmxSessionProbe === 'exists'; - if (resolvedZmxSessionProbe === 'unknown') { + // The local controller version is a protocol requirement even when the + // backing session already exists: 0.6 has the old send semantics. Only a + // transient functional probe may be exempted for a verified live session. + const version = probeZmxVersion(); + if (!version.ok) { available = false; - reason = 'zmx 会话列表探针结果不确定'; - } else if (!hasExistingSession) { - const probe = probeZmxFunctional(); - available = probe.ok; - if (!probe.ok) reason = probe.reason; + reason = version.reason; + resolvedZmxSessionProbe = 'unknown'; + } else { + const resolved = probeOwnedZmxSession( + ZmxBackend.sessionName(cfg.sessionId), + cfg.sessionId, + ); + resolvedZmxSessionProbe = resolved.probe; + resolvedZmxSessionPid = resolved.pid; + hasExistingSession = resolved.probe === 'exists'; + if (resolved.probe === 'unknown') { + available = false; + reason = resolved.reason ?? 'zmx 会话所有权探针结果不确定'; + // Do not let decideBackendGate's live-session exemption override an + // incompatible/unknown ownership result. + hasExistingSession = false; + } } } else if (effectiveBackend === 'herdr') { // herdr's isAvailable() is a cheap, non-destructive `herdr --version` @@ -6875,11 +7109,25 @@ async function spawnCli( // still the isolated process). This lets isolated bots use tmux/zellij/herdr. if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') { const persistentTarget = selectedBackend.persistentBackendTarget; + // ZMX ownership is verified against the frozen PID, not just the name — a + // same-named session may belong to the user or to a newer generation. + const zmxOwnedProbe = effectiveBackendType === 'zmx' + ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid) + : undefined; // An unverifiable pane must never be treated as absent: falling through to // "no pane" would cold-spawn a second CLI beside a live unisolated one. - const paneProbe = persistentTarget - ? probePersistentBackendTarget(persistentTarget) - : 'missing'; + const paneProbe = zmxOwnedProbe?.probe + ?? (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing'); + if ( + effectiveBackendType === 'zmx' + && resolvedZmxSessionProbe !== 'exists' + && paneProbe === 'exists' + ) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + 'ZMX session appeared after the frozen launch probe', + ); + } if (paneProbe === 'unknown') { throw new Error( `[read-isolation] refusing to start session ${cfg.sessionId}: ` + @@ -6901,10 +7149,26 @@ async function spawnCli( // Missing/legacy marker → pane predates the current policy and may retain // obsolete permissions. Kill it before publishing any new capability. log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`); + // Capture the name before re-selection: `persistentSessionName` is + // reassigned from the new selection below and widens back to + // `string | undefined`, but the backing name we are tearing down is + // this one and does not change. + const staleSessionName = persistentSessionName; try { - const persistentTarget = selectedBackend.persistentBackendTarget; - if (persistentTarget) killPersistentBackendTarget(persistentTarget); - else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName); + // ZMX keeps its own call here rather than going through the target + // helper: only this path holds the frozen PID, which makes the + // ownership check stricter than the name+label check. + if (effectiveBackendType === 'zmx') { + ZmxBackend.killManagedSession( + persistentSessionName, + cfg.sessionId, + resolvedZmxSessionPid, + ); + } else { + const persistentTarget = selectedBackend.persistentBackendTarget; + if (persistentTarget) killPersistentBackendTarget(persistentTarget, cfg.sessionId); + else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); + } selectedBackend = selectBackend(); isTmuxMode = selectedBackend.isTmuxMode; isPipeMode = selectedBackend.isPipeMode; @@ -6915,6 +7179,22 @@ async function spawnCli( } catch (e) { throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${(e as Error).message})`); } + const postKillProbe = effectiveBackendType === 'zmx' + ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe + : probePersistentSession( + effectiveBackendType as PersistentBackendType, + staleSessionName, + ); + if (postKillProbe !== 'missing') { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not confirm stale ${effectiveBackendType} pane termination`, + ); + } + if (effectiveBackendType === 'zmx') { + resolvedZmxSessionProbe = postKillProbe; + resolvedZmxSessionPid = undefined; + } } } } @@ -6923,9 +7203,19 @@ async function spawnCli( const persistentTarget = selectedBackend.persistentBackendTarget; // Fail closed on an inconclusive probe: treating 'unknown' as "no pane" // would cold-spawn a second CLI next to a live one holding MCP state. - const paneProbe = persistentTarget - ? probePersistentBackendTarget(persistentTarget) - : 'missing'; + const paneProbe = effectiveBackendType === 'zmx' + ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid).probe + : (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing'); + if ( + effectiveBackendType === 'zmx' + && resolvedZmxSessionProbe !== 'exists' + && paneProbe === 'exists' + ) { + throw new Error( + `[mcp-gateway] refusing to start session ${cfg.sessionId}: ` + + 'ZMX session appeared after the frozen launch probe', + ); + } if (paneProbe === 'unknown') { throw new Error( `[mcp-gateway] refusing to start session ${cfg.sessionId}: ` + @@ -6940,15 +7230,27 @@ async function spawnCli( // worker/daemon replacement. Cold-resume the CLI so its MCP client gets a // fresh relay socket instead of reattaching to a dead connection. log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state — cold-resuming with a fresh host`); + const persistentBackendType = effectiveBackendType as PersistentBackendType; const persistentTarget = selectedBackend.persistentBackendTarget; - if (persistentTarget) killPersistentBackendTarget(persistentTarget); - else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName); + if (effectiveBackendType === 'zmx') { + ZmxBackend.killManagedSession( + persistentSessionName, + cfg.sessionId, + resolvedZmxSessionPid, + ); + } else if (persistentTarget) { + killPersistentBackendTarget(persistentTarget, cfg.sessionId); + } else { + killPersistentSession(persistentBackendType, persistentSessionName, cfg.sessionId); + } // Confirm the stale pane is really gone before re-selecting. Re-selection // below decides reattach-vs-fresh from this probe, so an unconfirmed kill // would let the new backend reattach to the pane we just tried to remove. - const postKillProbe = persistentTarget - ? probePersistentBackendTarget(persistentTarget) - : probePersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName); + const postKillProbe = effectiveBackendType === 'zmx' + ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId).probe + : (persistentTarget + ? probePersistentBackendTarget(persistentTarget) + : probePersistentSession(persistentBackendType, persistentSessionName)); if (postKillProbe !== 'missing') { throw new Error( `[mcp-gateway] refusing to start session ${cfg.sessionId}: ` + @@ -6957,6 +7259,7 @@ async function spawnCli( } if (effectiveBackendType === 'zmx') { resolvedZmxSessionProbe = postKillProbe; + resolvedZmxSessionPid = undefined; } selectedBackend = selectBackend(); isTmuxMode = selectedBackend.isTmuxMode; @@ -8333,8 +8636,23 @@ async function spawnCli( // quiescence, repeatedly triggering markPromptReady() and duplicate cards. if (effectiveBackendType !== 'riff') { idleDetector = new IdleDetector(cliAdapter); - idleDetector.onIdle((evidenceSource) => { + idleDetector.onIdle(async (evidenceSource) => { log('Prompt detected (idle)'); + // Snapshot-only backends (ZMX) must complete one authoritative refresh + // before a turn is declared idle, or a final burst lands after finalize. + const idleBackend = backend; + const revisionBeforeSettle = backendScreenRevision; + if (idleBackend?.settleCurrentScreen) { + const settle = await settleBackendScreenBeforeIdle(idleBackend, revisionBeforeSettle); + if (!settle.proceed || backend !== idleBackend) return; + if (backendScreenRevision !== revisionBeforeSettle) { + log('Authoritative screen changed during idle settle; deferring completion to the re-armed detector'); + return; + } + if (settle.degraded) { + log('Screen settle barrier degraded after bounded retries; finalizing from the last successful snapshot'); + } + } drainBridgesThenMarkReady(evidenceSource); }); } @@ -8353,6 +8671,11 @@ async function spawnCli( } }); } + backend.onScreenResync?.((snapshot) => { + if (observedBackend !== backend) return; + log(`${effectiveBackendType} observer recovered — rebasing screen state from history`); + onBackendScreenResync(snapshot); + }); backend.onAccessUrl?.((url) => { send({ type: 'riff_access_url', @@ -9259,38 +9582,6 @@ var term=new Terminal({ fontSize:14,fontFamily:"'JetBrains Mono','Fira Code',monospace", cursorBlink:!isTouch,scrollback:50000,allowProposedApi:true }); -// ZMX's attach transport has one headless terminal in the worker that answers -// DA/DSR/CPR/mode/status queries even when no browser exists. A normal xterm.js -// tab would answer the same queries again (N tabs => N extra replies), so for -// this backend only, consume reply-producing query families in every browser. -// They are zero-width control requests; returning true suppresses xterm's -// built-in response without changing rendered output. Other backends keep the -// default browser responder because they do not own one server-side. -var _serverTerminalResponder=${effectiveBackendType === 'zmx'}; -if(_serverTerminalResponder&&term.parser){ - var _queryParam0=function(p){ - var v=p&&p.length?p[0]:0; - return Array.isArray(v)?(v[0]||0):(v||0); - }; - term.parser.registerCsiHandler({final:'c'},function(p){return _queryParam0(p)<=0}); - term.parser.registerCsiHandler({prefix:'>',final:'c'},function(p){return _queryParam0(p)<=0}); - term.parser.registerCsiHandler({final:'n'},function(p){var v=_queryParam0(p);return v===5||v===6}); - term.parser.registerCsiHandler({prefix:'?',final:'n'},function(p){return _queryParam0(p)===6}); - term.parser.registerCsiHandler({intermediates:'$',final:'p'},function(){return true}); - term.parser.registerCsiHandler({prefix:'?',intermediates:'$',final:'p'},function(){return true}); - term.parser.registerCsiHandler({final:'t'},function(p){return _queryParam0(p)===18}); - term.parser.registerDcsHandler({intermediates:'$',final:'q'},function(){return true}); - var _oscColorQuery=function(ident,data){ - if(ident!==4)return data.split(';').indexOf('?')!==-1; - var parts=data.split(';'); - if(parts.length<2||parts.length%2!==0)return false; - for(var i=0;i<parts.length;i+=2){if(/^\d+$/.test(parts[i])&&parts[i+1]==='?')return true;} - return false; - }; - [4,10,11,12].forEach(function(ident){ - term.parser.registerOscHandler(ident,function(data){return _oscColorQuery(ident,data)}); - }); -} var fit=new FitAddon.FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon.WebLinksAddon()); @@ -10093,17 +10384,26 @@ process.on('message', async (raw: unknown) => { publishSandboxRelayCapability(); } let port = 0; + const requestedBackendType = msg.backendType ?? config.daemon.backendType; + const webTerminalEnabled = backendSupportsWebTerminal(requestedBackendType); if (!isWorkflowWorker()) { - port = await startWebServer(config.web.workerHost, msg.webPort); + if (webTerminalEnabled) { + port = await startWebServer(config.web.workerHost, msg.webPort); + } else { + log(`Web terminal disabled for ${requestedBackendType} backend (no raw ANSI transport)`); + } startScreenUpdates(); startStuckDetector(); } else { - // Workflow attempts still expose a read-only web terminal so the - // workflow dashboard can observe in-flight subagents. Keep the - // chat-side features disabled: no screen cards, no analyzer, no - // sessionStore writes. - port = await startWebServer(config.web.workerHost, msg.webPort); - log('Workflow worker mode: web terminal enabled; skipping screen updates and screen analyzer'); + // Workflow attempts expose a read-only web terminal only when the + // backend has a raw terminal stream. Keep chat-side features + // disabled: no screen cards, no analyzer, no sessionStore writes. + if (webTerminalEnabled) { + port = await startWebServer(config.web.workerHost, msg.webPort); + log('Workflow worker mode: web terminal enabled; skipping screen updates and screen analyzer'); + } else { + log(`Workflow worker mode: web terminal disabled for ${requestedBackendType} backend`); + } } // Hybrid codex RPC input (opt-in): bind the pane to a botmux-owned // app-server thread; input flows via JSON-RPC instead of a drop-prone @@ -10126,7 +10426,11 @@ process.on('message', async (raw: unknown) => { rpcPluginGenerationPrepared = true; }, engage: () => engageCodexRpc(msg), - killVerify: (name) => killPersistentSessionVerified(rpcBackendType as PersistentBackendType, name), + killVerify: (name) => killPersistentSessionVerified( + rpcBackendType as PersistentBackendType, + name, + msg.sessionId, + ), teardownEngine: () => stopCodexRpcEngine(), log: (m) => log(m), notify: (m) => send({ type: 'user_notify', message: m, turnId: msg.turnId }), @@ -10337,7 +10641,7 @@ process.on('message', async (raw: unknown) => { // ingest path is unaffected because mark already happened // above (codexBridgeMarkPendingTurn / bridgeMarkPendingTurn). try { - const result = await cliAdapter.writeInput(backend as unknown as PtyHandle, content); + const result = await cliAdapter.writeInput(adapterInputHandle(backend), content); if (result?.cliSessionId) { persistCliSessionId(result.cliSessionId); codexBridgeNotifyCliSessionId(result.cliSessionId); @@ -10712,7 +11016,7 @@ process.on('message', async (raw: unknown) => { } case 'term_action': { - handleTermAction(msg.key); + await handleTermAction(msg.key); break; } @@ -10874,6 +11178,12 @@ function teardownSandboxBestEffort(): void { // the guard before any further stdout writes (log() writes to process.stdout). installStdioEpipeGuard(); process.on('exit', () => { + // `zmx tail` can remain blocked with PPID=1 after an abrupt Node exit because + // it notices a broken stdout pipe only when new session output arrives. + // Detach the observer synchronously; never destroy the persistent CLI here. + if (backend instanceof ZmxBackend) { + try { backend.kill(); } catch { /* best-effort crash teardown */ } + } stopNativeSessionTitleSync(); teardownSandboxBestEffort(); stopCodexRpcEngine(); diff --git a/test/backend-availability.test.ts b/test/backend-availability.test.ts index d80fc65e2..1fa3ec424 100644 --- a/test/backend-availability.test.ts +++ b/test/backend-availability.test.ts @@ -6,7 +6,7 @@ function deps(overrides: Partial<BackendAvailabilityDeps> = {}): BackendAvailabi ensureTmux: vi.fn(async () => ({ installed: true, version: 'tmux 3.5', freshInstall: false, binaryPresent: true })), ensureHerdr: vi.fn(async () => ({ installed: true, version: 'herdr 0.7.3', freshInstall: false })), probeZellijFunctional: vi.fn(() => ({ ok: true, version: 'zellij 0.44.1' })), - probeZmxFunctional: vi.fn(() => ({ ok: true, version: 'zmx 0.6.0' })), + probeZmxFunctional: vi.fn(() => ({ ok: true, version: 'zmx 0.7.1' })), ...overrides, }; } @@ -66,7 +66,7 @@ describe('ensureBackendAvailable', () => { ok: false, backendType: 'zmx', reason: 'zmx 0.5.9 过旧', - manualCommand: 'macOS: brew install neurosnap/tap/zmx;Linux: 安装 ZMX 官方 release binary(>= 0.6.0)', + manualCommand: '等待包含 PR #202 send 行为的 ZMX >= 0.7.1 正式版;发布后 macOS 可用 Homebrew、Linux 可用官方 release binary 安装', }); expect(d.probeZmxFunctional).toHaveBeenCalledTimes(1); expect(d.probeZellijFunctional).not.toHaveBeenCalled(); diff --git a/test/backend-capabilities.test.ts b/test/backend-capabilities.test.ts new file mode 100644 index 000000000..147a3d9a9 --- /dev/null +++ b/test/backend-capabilities.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + backendCliCompatibilityError, + backendSupportsWebTerminal, +} from '../src/adapters/backend/capabilities.js'; +import type { BackendType } from '../src/adapters/backend/types.js'; + +describe('backendSupportsWebTerminal', () => { + it('disables the Web TUI only for the plain-text zmx tail backend', () => { + expect(backendSupportsWebTerminal('zmx')).toBe(false); + + for (const backend of ['pty', 'tmux', 'herdr', 'zellij', 'riff'] satisfies BackendType[]) { + expect(backendSupportsWebTerminal(backend)).toBe(true); + } + }); +}); + +describe('backendCliCompatibilityError', () => { + it('fails closed for runner CLIs whose final/thread events require hidden OSC on zmx', () => { + for (const cliId of ['codex-app', 'mira', 'mir'] as const) { + expect(backendCliCompatibilityError('zmx', cliId)).toContain('hidden OSC'); + expect(backendCliCompatibilityError('tmux', cliId)).toBeUndefined(); + } + expect(backendCliCompatibilityError('zmx', 'codex')).toBeUndefined(); + }); +}); diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index d2ce6e56a..6f398adbe 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -39,11 +39,23 @@ describe('decideBackendGate (PTY 退役 hard gate)', () => { expect(decideBackendGate({ requested: 'zmx', available: false, hasExistingSession: false }).action).toBe('gate'); }); - it('reattaches a live zmx session despite a transient probe failure', () => { + it('keeps the generic existing-session exemption available for transient probes', () => { expect( decideBackendGate({ requested: 'zmx', available: false, hasExistingSession: true }), ).toEqual({ action: 'spawn' }); }); + + it('requires the ZMX protocol version before considering a managed live session', () => { + const start = workerSource.indexOf("} else if (effectiveBackend === 'zmx') {"); + const end = workerSource.indexOf("} else if (effectiveBackend === 'herdr')", start); + const gate = workerSource.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(gate.indexOf('probeZmxVersion()')).toBeLessThan(gate.indexOf('probeOwnedZmxSession(')); + expect(gate).toContain("resolvedZmxSessionProbe = 'unknown'"); + expect(gate).toContain('hasExistingSession = false'); + }); }); describe('backendGateUserMessage', () => { @@ -55,10 +67,11 @@ describe('backendGateUserMessage', () => { expect(msg).toContain('BACKEND_TYPE=pty'); }); - it('includes the supported ZMX version and install path', () => { + it('includes the supported ZMX version and unreleased upstream prerequisite', () => { const msg = backendGateUserMessage('zmx', 'zmx 二进制不在 PATH 上'); - expect(msg).toContain('zmx >= 0.6.0'); - expect(msg).toContain('neurosnap/tap/zmx'); + expect(msg).toContain('zmx >= 0.7.1'); + expect(msg).toContain('PR #202'); + expect(msg).toContain('官方 0.6.0 尚不满足'); }); }); @@ -123,3 +136,50 @@ describe('persistent backend cold-restart ordering', () => { expect(gate).toContain('resolvedZmxSessionProbe = postKillProbe'); }); }); + +describe('ZMX observer crash cleanup', () => { + it('detaches zmx tail from the synchronous worker exit hook without destroying the session', () => { + const start = workerSource.indexOf("process.on('exit'"); + const end = workerSource.indexOf("process.on('uncaughtException'", start); + const exitHook = workerSource.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(exitHook).toContain('backend instanceof ZmxBackend'); + expect(exitHook).toContain('backend.kill()'); + expect(exitHook).not.toContain('destroySession'); + }); +}); + +describe('live-only observer screen rebase', () => { + it('registers the authoritative snapshot callback and refreshes idle/card state', () => { + const handlerStart = workerSource.indexOf('function onBackendScreenResync('); + const handlerEnd = workerSource.indexOf('function releaseRawInputRestartGate', handlerStart); + const handler = workerSource.slice(handlerStart, handlerEnd); + const registration = workerSource.indexOf('backend.onScreenResync?.('); + + expect(handlerStart).toBeGreaterThan(-1); + expect(registration).toBeGreaterThan(handlerStart); + expect(handler).toContain('lastPtyActivityAtMs = now'); + expect(handler).toContain('lastAnalyzerSnapshot = snapshot'); + expect(handler).toContain('idleDetector.reset()'); + expect(handler).toContain('idleDetector.feed(idleTail)'); + expect(handler).toContain('workflowTranscript = snapshot.slice'); + expect(handler).toContain('handleVisibleStartupInteraction(snapshot)'); + }); + + it('shares update and trust dialog handling with incremental PTY output', () => { + const helperStart = workerSource.indexOf('function handleVisibleStartupInteraction('); + const helperEnd = workerSource.indexOf('// Codex App runner sends', helperStart); + const helper = workerSource.slice(helperStart, helperEnd); + const ptyStart = workerSource.indexOf('function onPtyData('); + const ptyEnd = workerSource.indexOf('function onBackendScreenResync(', ptyStart); + const ptyHandler = workerSource.slice(ptyStart, ptyEnd); + + expect(helperStart).toBeGreaterThan(-1); + expect(helperEnd).toBeGreaterThan(helperStart); + expect(helper).toContain('dismissAidenCodexUpdateDialog(data)'); + expect(helper).toContain('TRUST_DIALOG_PATTERN.test(stripped)'); + expect(helper).toContain("sendSpecialKeys('Enter')"); + expect(ptyHandler).toContain('handleVisibleStartupInteraction(data)'); + }); +}); diff --git a/test/card-builder.test.ts b/test/card-builder.test.ts index 40bf6f544..f6eda6f31 100644 --- a/test/card-builder.test.ts +++ b/test/card-builder.test.ts @@ -289,6 +289,17 @@ describe('buildSessionCard', () => { expect(linkBtn.value.session_id).toBe(SID); }); + it('keeps native attach and close but hides Web Terminal controls without a URL', () => { + enableLocalCliOpen(); + const card = parse(buildSessionCard(SID, ROOT, '', TITLE, 'codex', false, false, 'en', true)); + const actions = findActions(card); + + expect(actions.some((a: any) => a.multi_url)).toBe(false); + expect(actions.some((a: any) => a.value?.action === 'get_write_link')).toBe(false); + expect(actions.some((a: any) => a.value?.action === 'open_local_cli')).toBe(true); + expect(actions.some((a: any) => a.value?.action === 'close')).toBe(true); + }); + it('includes Open Codex beside Web Terminal for codex sessions only', () => { enableLocalCliOpen(); const card = parse(buildSessionCard(SID, ROOT, URL, TITLE, 'codex', false, false, 'en', true)); @@ -708,6 +719,22 @@ describe('buildStreamingCard', () => { expect(linkBtn.value.session_id).toBe(SID); }); + it('hides Web Terminal controls when the backend has no terminal URL', () => { + enableLocalCliOpen(); + const card = parse(buildStreamingCard( + SID, ROOT, '', TITLE, '', 'idle', 'codex', 'hidden', + undefined, undefined, false, false, 'en', undefined, undefined, true, + )); + const actions = findActions(card); + + expect(actions.some((a: any) => a.multi_url)).toBe(false); + expect(actions.some((a: any) => a.value?.action === 'get_write_link')).toBe(false); + // Native local opening (for example `zmx attach`) stays available. + expect(actions.some((a: any) => a.value?.action === 'open_local_cli')).toBe(true); + expect(actions.some((a: any) => a.value?.action === 'toggle_display')).toBe(true); + expect(actions.some((a: any) => a.value?.action === 'close')).toBe(true); + }); + it('should include close button with danger type', () => { const card = parse(buildStreamingCard(SID, ROOT, URL, TITLE, '', 'idle')); const actions = findActions(card); @@ -1445,6 +1472,19 @@ describe('buildPrivateSnapshotCard', () => { expect(btns.map((b: any) => b.value?.action ?? 'url')).toEqual(['url', 'get_write_link', 'close']); }); + it('keeps the snapshot and close control but hides terminal links when unavailable', () => { + const card = parse(buildPrivateSnapshotCard( + '', 'my session', 'idle', 'codex', undefined, 'plain zmx history', + 'sess-9', 'om_anchor', 'en', + )); + const btns = allButtons(card); + + expect(btns.map((b: any) => b.value?.action ?? 'url')).toEqual(['close']); + expect(JSON.stringify(card)).toContain('plain zmx history'); + expect(JSON.stringify(card)).toContain('static snapshot'); + expect(JSON.stringify(card)).not.toContain('Open Web Terminal'); + }); + it('callback buttons carry root_id/session_id/cli_id for handler resolution', () => { const card = build({ screen: 'x' }); const btns = allButtons(card); diff --git a/test/card-handler-repo-select.test.ts b/test/card-handler-repo-select.test.ts index 0d88bb99d..bb4e8adc0 100644 --- a/test/card-handler-repo-select.test.ts +++ b/test/card-handler-repo-select.test.ts @@ -64,6 +64,7 @@ vi.mock('../src/services/session-store.js', () => ({ vi.mock('../src/core/worker-pool.js', () => ({ forkWorker: vi.fn(), killWorker: vi.fn(), + teardownAuthoritativePersistentBackingBeforeClose: vi.fn(), scheduleCardPatch: vi.fn(), parkStreamCard: vi.fn(), clearUsageLimitState: vi.fn(), @@ -124,7 +125,7 @@ vi.mock('@larksuiteoapi/node-sdk', () => ({ // ─── Imports ────────────────────────────────────────────────────────────── import { handleCardAction, type CardHandlerDeps } from '../src/im/lark/card-handler.js'; -import { forkWorker, killWorker, deliverEphemeralOrReply, deliverWriteLinkCard } from '../src/core/worker-pool.js'; +import { forkWorker, killWorker, teardownAuthoritativePersistentBackingBeforeClose, deliverEphemeralOrReply, deliverWriteLinkCard } from '../src/core/worker-pool.js'; import { buildNewTopicCliInput, getAvailableBots, getSessionWorkingDir } from '../src/core/session-manager.js'; import { getBot } from '../src/bot-registry.js'; import { createSession, closeSession, updateSession } from '../src/services/session-store.js'; @@ -237,6 +238,7 @@ function deferred<T>() { beforeEach(() => { vi.clearAllMocks(); vi.mocked(deleteMessage).mockReset().mockResolvedValue(true); + vi.mocked(teardownAuthoritativePersistentBackingBeforeClose).mockImplementation(() => undefined); vi.mocked(getBot).mockImplementation(() => ({ config: { larkAppId: APP_ID, larkAppSecret: 'secret', cliId: 'claude-code' }, resolvedAllowedUsers: [], @@ -765,6 +767,31 @@ describe('repo select card — plain switch', () => { expect(ds.consumedRepoCardMessageIds).toContain('om_card'); }); + it('keeps the old ZMX session and repo card active when authoritative teardown is refused', async () => { + const ds = makeDs(); + ds.session.backendType = 'zmx'; + ds.session.workingDir = '/repos/gamma'; + const oldSession = ds.session; + const { deps, sessionReply } = makeDeps(ds); + vi.mocked(teardownAuthoritativePersistentBackingBeforeClose).mockImplementationOnce(() => { + throw new Error('ownership probe unavailable'); + }); + + await handleCardAction(makeSelectEvent('repo_switch', '/repos/beta'), deps, APP_ID); + + expect(teardownAuthoritativePersistentBackingBeforeClose).toHaveBeenCalledWith(ds); + expect(killWorker).not.toHaveBeenCalled(); + expect(closeSession).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + expect(forkWorker).not.toHaveBeenCalled(); + expect(ds.session).toBe(oldSession); + expect(ds.session.status).toBe('active'); + expect(ds.session.workingDir).toBe('/repos/gamma'); + expect(ds.repoCardMessageId).toBe('om_card'); + expect(ds.consumedRepoCardMessageIds).toBeUndefined(); + expect(sessionReply.mock.calls.map(c => c[1]).join()).toContain('ownership probe unavailable'); + }); + it('mid-session claims the card before confirm await so a second click cannot double kill/fork', async () => { // Regression: mid-session used to mark consumed only after sessionReply. // Park the "已切换" reply; a second click on the same card must toast and diff --git a/test/card-integration.test.ts b/test/card-integration.test.ts index 9e1cb6315..c18b1644d 100644 --- a/test/card-integration.test.ts +++ b/test/card-integration.test.ts @@ -104,6 +104,7 @@ vi.mock('../src/bot-registry.js', () => ({ })), getAllBots: vi.fn(() => []), getBotClient: vi.fn(), + getBotBrand: vi.fn(() => 'feishu'), })); vi.mock('../src/config.js', () => ({ @@ -118,6 +119,7 @@ vi.mock('../src/services/session-store.js', () => ({ closeSession: vi.fn(), updateSession: vi.fn(), createSession: vi.fn(), + getOwnedSession: vi.fn(), // Resume action's permission gate falls back to a store lookup when the // session is no longer in activeSessions. Tests override the implementation // per-scenario via vi.mocked(getSession).mockReturnValueOnce(...). @@ -166,11 +168,12 @@ vi.mock('@larksuiteoapi/node-sdk', () => ({ // ─── Imports ────────────────────────────────────────────────────────────── import { handleCardAction, type CardHandlerDeps } from '../src/im/lark/card-handler.js'; -import { scheduleCardPatch } from '../src/core/worker-pool.js'; -import { killWorker, forkWorker, requestSessionRestart } from '../src/core/worker-pool.js'; +import { scheduleCardPatch, setActiveSessionsRegistry, forkWorker, requestSessionRestart } from '../src/core/worker-pool.js'; import { sessionKey } from '../src/core/types.js'; import type { DaemonSession } from '../src/core/types.js'; import { buildStreamingCard } from '../src/im/lark/card-builder.js'; +import * as sessionStore from '../src/services/session-store.js'; +import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; // ─── Helpers ────────────────────────────────────────────────────────────── @@ -193,7 +196,14 @@ function makeDaemonSession(overrides?: Partial<DaemonSession>): DaemonSession { chatType: 'group', scope: 'chat', }, - worker: { killed: false, send: vi.fn() } as any, + worker: { + killed: false, + send: vi.fn(), + once: vi.fn(), + kill: vi.fn(), + exitCode: null, + signalCode: null, + } as any, workerPort: 8080, workerToken: 'tok_secret', larkAppId: APP_ID, @@ -218,6 +228,13 @@ function makeDaemonSession(overrides?: Partial<DaemonSession>): DaemonSession { function makeDeps(activeSessions: Map<string, DaemonSession>): CardHandlerDeps { sessionReplyCallIndex = 0; + // Card actions and worker-pool closeSession share this exact registry in + // production. Model that identity here so close teardown mutates the same + // object the card handler subsequently removes. + setActiveSessionsRegistry(activeSessions); + vi.mocked(sessionStore.getOwnedSession).mockImplementation((sessionId: string) => + [...activeSessions.values()].find(ds => ds.session.sessionId === sessionId)?.session, + ); return { activeSessions, sessionReply: vi.fn(async () => { @@ -505,7 +522,8 @@ describe('Card integration: full event flow', () => { await handleCardAction(makeCloseEvent(ROOT_ID), deps, APP_ID); - expect(killWorker).toHaveBeenCalledWith(ds); + expect(sessionStore.closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(ds.session.status).toBe('closed'); expect(sessions.has(sKey)).toBe(false); // Closed reply is an interactive card with a Resume button, delivered // ephemeral to the clicker (group chat + operator open_id); the mocked @@ -516,6 +534,39 @@ describe('Card integration: full event flow', () => { expect(deps.sessionReply).not.toHaveBeenCalled(); }); + it('keeps a ZMX session active and returns a warning when verified teardown is refused', async () => { + const clientMod = await import('../src/im/lark/client.js'); + const ds = makeDaemonSession(); + ds.session.backendType = 'zmx'; + ds.initConfig = { backendType: 'zmx' } as DaemonSession['initConfig']; + const workerSend = ds.worker!.send as Mock; + const sessions = new Map<string, DaemonSession>(); + const sKey = sessionKey(ROOT_ID, APP_ID); + sessions.set(sKey, ds); + const deps = makeDeps(sessions); + const kill = vi.spyOn(ZmxBackend, 'killManagedSession') + .mockImplementationOnce(() => { throw new Error('ownership probe unavailable'); }); + + try { + const result = await handleCardAction(makeCloseEvent(ROOT_ID), deps, APP_ID); + + expect(kill).toHaveBeenCalledWith('bmx-uuid-int', ds.session.sessionId); + expect(result?.toast).toEqual(expect.objectContaining({ + type: 'warning', + content: expect.stringContaining('ownership probe unavailable'), + })); + expect(sessions.get(sKey)).toBe(ds); + expect(ds.session.status).toBe('active'); + expect(ds.worker).not.toBeNull(); + expect(workerSend).not.toHaveBeenCalled(); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); + expect(vi.mocked(clientMod.sendEphemeralCard)).not.toHaveBeenCalled(); + expect(deps.sessionReply).not.toHaveBeenCalled(); + } finally { + kill.mockRestore(); + } + }); + it('close in private mode sends the closed card ephemeral to owners, not the group', async () => { const clientMod = await import('../src/im/lark/client.js'); const botRegMod = await import('../src/bot-registry.js'); @@ -535,7 +586,8 @@ describe('Card integration: full event flow', () => { await handleCardAction(makeCloseEvent(ROOT_ID, 'ou_owner'), deps, APP_ID); - expect(killWorker).toHaveBeenCalledWith(ds); + expect(sessionStore.closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(ds.session.status).toBe('closed'); expect(sessions.has(sKey)).toBe(false); // Closed card goes ephemeral to the owner … expect(vi.mocked(clientMod.sendEphemeralCard)).toHaveBeenCalledWith( @@ -576,7 +628,8 @@ describe('Card integration: full event flow', () => { await handleCardAction(makeCloseEvent(ROOT_ID, 'ou_owner', 'private'), deps, APP_ID); - expect(killWorker).toHaveBeenCalledWith(ds); + expect(sessionStore.closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(ds.session.status).toBe('closed'); // Closed card still goes ephemeral to the owner … expect(vi.mocked(clientMod.sendEphemeralCard)).toHaveBeenCalledWith( APP_ID, ds.chatId, 'ou_owner', expect.stringContaining('"type":"closed"'), @@ -809,7 +862,8 @@ describe('Card integration: full event flow', () => { await handleCardAction(makeCloseEvent(ROOT_ID), deps, APP_ID); - expect(killWorker).toHaveBeenCalledWith(ds); + expect(sessionStore.closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(ds.session.status).toBe('closed'); expect(sessions.has(sKey)).toBe(false); expect(vi.mocked(clientMod.sendEphemeralCard)).not.toHaveBeenCalled(); expect(deps.sessionReply).toHaveBeenCalledWith( @@ -924,6 +978,32 @@ describe('Card integration: full event flow', () => { ); expect(deps.sessionReply).not.toHaveBeenCalled(); }); + + it('reports Web Terminal as unsupported for stale ZMX cards even if old terminal state remains', async () => { + const clientMod = await import('../src/im/lark/client.js'); + const ds = makeDaemonSession({ + session: { + ...makeDaemonSession().session, + backendType: 'zmx', + webPort: 9090, + }, + workerPort: 9090, + workerToken: 'stale-write-token', + riffAccessUrl: 'https://stale-riff.example', + }); + const sessions = new Map<string, DaemonSession>(); + sessions.set(sessionKey(ROOT_ID, APP_ID), ds); + const deps = makeDeps(sessions); + + const res = await handleCardAction(makeGetWriteLinkEvent(ROOT_ID, 'ou_user'), deps, APP_ID); + + expect(res?.toast?.type).not.toBe('success'); + expect(fakeLark.dms).toHaveLength(0); + expect(vi.mocked(clientMod.sendEphemeralCard)).toHaveBeenCalledWith( + APP_ID, ds.chatId, 'ou_user', expect.stringContaining('不提供 Web 终端'), + ); + expect(vi.mocked(clientMod.sendEphemeralCard).mock.calls.at(-1)?.[3]).not.toContain('尚未就绪'); + }); }); // ── Scenario 6: edge cases ──────────────────────────────────────────── diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 25530afb7..0c9a5c3e2 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -270,6 +270,7 @@ vi.mock('../src/utils/logger.js', () => ({ vi.mock('../src/core/worker-pool.js', () => ({ killWorker: vi.fn(), + teardownAuthoritativePersistentBackingBeforeClose: vi.fn(), suspendWorker: vi.fn(() => false), forkWorker: vi.fn(), forkAdoptWorker: vi.fn(), @@ -468,7 +469,7 @@ import { sessionKey } from '../src/core/types.js'; import { setTerminalProxyPort } from '../src/core/terminal-url.js'; import type { DaemonSession } from '../src/core/types.js'; import type { LarkMessage, Session } from '../src/types.js'; -import { killWorker, suspendWorker, forkWorker, getCurrentCliVersion, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart } from '../src/core/worker-pool.js'; +import { closeSession, killWorker, teardownAuthoritativePersistentBackingBeforeClose, suspendWorker, forkWorker, getCurrentCliVersion, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart } from '../src/core/worker-pool.js'; import { getOwnerOpenId } from '../src/bot-registry.js'; import { canOperate } from '../src/im/lark/event-dispatcher.js'; import { getSessionWorkingDir, buildNewTopicPrompt, buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots } from '../src/core/session-manager.js'; @@ -1076,6 +1077,7 @@ describe('parseForceTopicInvocation', () => { describe('handleCommand', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(teardownAuthoritativePersistentBackingBeforeClose).mockImplementation(() => undefined); vi.mocked(getBot).mockImplementation(defaultGetBot as any); vi.mocked(listCodexAppThreads).mockResolvedValue([]); vi.mocked(resolveUserToken).mockResolvedValue(null); @@ -1391,14 +1393,17 @@ describe('handleCommand', () => { // ─── /close ───────────────────────────────────────────────────────────── describe('/close', () => { - it('should kill worker and remove session when session exists', async () => { + it('closes through the authoritative worker-pool lifecycle and removes the session', async () => { const ds = makeDaemonSession(); const deps = makeDeps(ds); await handleCommand('/close', ROOT_ID, makeLarkMessage('/close'), deps, LARK_APP_ID); - expect(killWorker).toHaveBeenCalledWith(ds); - expect(sessionStore.closeSession).toHaveBeenCalledWith('sess-001'); + expect(closeSession).toHaveBeenCalledWith('sess-001'); + // The command must not bypass closeSession's verified ZMX teardown by + // mutating worker/store state directly. + expect(killWorker).not.toHaveBeenCalled(); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); expect(deps.activeSessions.has(sessionKey(ROOT_ID, LARK_APP_ID))).toBe(false); // The「会话已关闭」card is delivered「仅自己可见」-first: it routes through // deliverEphemeralOrReply targeting the user who ran /close (message.senderId), @@ -1427,6 +1432,28 @@ describe('handleCommand', () => { expect(cardJson).toContain('"action":"resume"'); }); + it('keeps the active session and reports a visible failure when teardown is refused', async () => { + const ds = makeDaemonSession(); + const deps = makeDeps(ds); + vi.mocked(closeSession).mockRejectedValueOnce(new Error('ZMX ownership probe unavailable')); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close'), deps, LARK_APP_ID); + + expect(closeSession).toHaveBeenCalledWith('sess-001'); + expect(deps.activeSessions.get(sessionKey(ROOT_ID, LARK_APP_ID))).toBe(ds); + expect(killWorker).not.toHaveBeenCalled(); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); + expect(deliverEphemeralOrReply).not.toHaveBeenCalled(); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('会话关闭失败'), + undefined, + LARK_APP_ID, + 'msg_001', + ); + expect(vi.mocked(deps.sessionReply).mock.calls[0]?.[1]).toContain('ZMX ownership probe unavailable'); + }); + it('keeps ttadk non-interactive flags in the closed-card resume command', async () => { // A ttadk × Claude bot: the manual resume command on the closed card must // carry `-m <model> --skip-check`, else copy-pasting it hits ttadk's model @@ -1952,6 +1979,31 @@ describe('handleCommand', () => { expect(forkWorker).toHaveBeenCalledWith(ds, '', false); }); + it('keeps the old ZMX session active when repo-switch teardown is refused', async () => { + const oldSession = makeSession({ backendType: 'zmx', workingDir: '/home/testuser/project-a' }); + const ds = makeDaemonSession({ pendingRepo: false, session: oldSession, repoCardMessageId: 'om_repo_card' }); + const deps = makeDeps(ds); + deps.lastRepoScan.set(CHAT_ID, [ + { name: 'project-b', path: '/home/testuser/project-b', branch: 'dev' }, + ]); + vi.mocked(teardownAuthoritativePersistentBackingBeforeClose).mockImplementationOnce(() => { + throw new Error('zmx generation changed'); + }); + + await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo 1'), deps, LARK_APP_ID); + + expect(teardownAuthoritativePersistentBackingBeforeClose).toHaveBeenCalledWith(ds); + expect(killWorker).not.toHaveBeenCalled(); + expect(sessionStore.closeSession).not.toHaveBeenCalled(); + expect(sessionStore.createSession).not.toHaveBeenCalled(); + expect(forkWorker).not.toHaveBeenCalled(); + expect(ds.session).toBe(oldSession); + expect(ds.session.status).toBe('active'); + expect(ds.session.workingDir).toBe('/home/testuser/project-a'); + expect(ds.repoCardMessageId).toBe('om_repo_card'); + expect(vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join()).toContain('zmx generation changed'); + }); + it('mid-session chat switch preserves scope and the original message root', async () => { const originalRoot = 'om_original_chat_start'; const ds = makeDaemonSession({ @@ -4718,6 +4770,14 @@ describe('/term — operable terminal slash command (operator / canOperate)', () expect(reply).toContain('终端还没就绪'); }); + it('backend without Web Terminal → unsupported notice', async () => { + vi.mocked(deliverWritableTerminalCardTo).mockResolvedValue('unsupported'); + const deps = makeDeps(makeDaemonSession({ backendType: 'zmx' })); + await handleCommand('/term', ROOT_ID, ownerMsg(), deps, LARK_APP_ID); + const reply = (deps.sessionReply as ReturnType<typeof vi.fn>).mock.calls[0][1] as string; + expect(reply).toContain('不提供 Web 终端'); + }); + it('delivery failure → failure notice', async () => { vi.mocked(deliverWritableTerminalCardTo).mockResolvedValue('failed'); const deps = makeDeps(makeDaemonSession({ workerPort: 41000, workerToken: 'wtok' })); diff --git a/test/critical-control-key.test.ts b/test/critical-control-key.test.ts new file mode 100644 index 000000000..028b2e090 --- /dev/null +++ b/test/critical-control-key.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { + isCriticalInterruptKey, + sendCriticalControlKey, +} from '../src/adapters/backend/critical-control-key.js'; + +const workerSource = readFileSync(new URL('../src/worker.ts', import.meta.url), 'utf8'); + +describe('critical terminal control delivery', () => { + it('classifies only interrupt keys as critical', () => { + expect(isCriticalInterruptKey('ctrlc')).toBe(true); + expect(isCriticalInterruptKey('esc')).toBe(true); + for (const key of ['enter', 'tab', 'up', 'down', 'left', 'right']) { + expect(isCriticalInterruptKey(key)).toBe(false); + } + }); + + it('retries one rejected interrupt after a bounded delay', async () => { + const sendOnce = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + const wait = vi.fn(async () => {}); + + await expect(sendCriticalControlKey(sendOnce, wait)).resolves.toBe(true); + expect(sendOnce).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledOnce(); + expect(wait).toHaveBeenCalledWith(100); + }); + + it('fails after exactly two rejected or throwing attempts', async () => { + const rejected = vi.fn(() => false); + await expect(sendCriticalControlKey(rejected, async () => {})).resolves.toBe(false); + expect(rejected).toHaveBeenCalledTimes(2); + + const throwing = vi.fn(() => { throw new Error('transport down'); }); + await expect(sendCriticalControlKey(throwing, async () => {})).resolves.toBe(false); + expect(throwing).toHaveBeenCalledTimes(2); + }); + + it('does not delay or retry an accepted interrupt', async () => { + const sendOnce = vi.fn(() => undefined); + const wait = vi.fn(async () => {}); + + await expect(sendCriticalControlKey(sendOnce, wait)).resolves.toBe(true); + expect(sendOnce).toHaveBeenCalledOnce(); + expect(wait).not.toHaveBeenCalled(); + }); +}); + +describe('worker interrupt action integration', () => { + it('awaits reliable delivery, notifies on failure, and exits before clearing TUI state', () => { + const start = workerSource.indexOf('async function handleTermAction'); + const end = workerSource.indexOf('/** Key name → ANSI escape sequence', start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const region = workerSource.slice(start, end); + + const retry = region.indexOf('await sendCriticalControlKey'); + const notify = region.indexOf("type: 'user_notify'"); + const failedReturn = region.indexOf('scheduleOneShotAfterAction();\n return;', notify); + const clearBlocking = region.indexOf('tuiPromptBlocking = false'); + expect(retry).toBeGreaterThanOrEqual(0); + expect(notify).toBeGreaterThan(retry); + expect(failedReturn).toBeGreaterThan(notify); + expect(clearBlocking).toBeGreaterThan(failedReturn); + }); + + it('awaits the async action in the worker IPC switch', () => { + const start = workerSource.indexOf("case 'term_action':"); + expect(workerSource.slice(start, start + 160)).toContain('await handleTermAction(msg.key)'); + }); +}); diff --git a/test/dashboard-i18n-c5.test.ts b/test/dashboard-i18n-c5.test.ts index 71513cec6..254c86660 100644 --- a/test/dashboard-i18n-c5.test.ts +++ b/test/dashboard-i18n-c5.test.ts @@ -67,6 +67,7 @@ const REQUIRED_KEYS: string[] = [ 'card.dashboard.sessions.confirm.resume.title', 'card.dashboard.sessions.confirm.resume.text', 'card.dashboard.sessions.terminal.disabled.noPort', + 'card.dashboard.sessions.terminal.disabled.unsupported', 'card.dashboard.sessions.resume.disabled.onlyClosed', // ─── schedules card (PR3 slice 1) ─────────────────────────────────── diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 4399cd463..53e01e97a 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -671,10 +671,15 @@ describe('POST /api/sessions/:sessionId/rename', () => { } as any; findSpy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue(active); - handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); - const res = await fetch(`http://127.0.0.1:${handle.port}/api/sessions/${session.sessionId}/rename`, { + setIpcAuthSecret(TEST_IPC_SECRET); + handle = await startIpcServer({ port: 0, host: '127.0.0.1', authRequired: true }); + const renamePath = `/api/sessions/${session.sessionId}/rename`; + const res = await fetch(`http://127.0.0.1:${handle.port}${renamePath}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + ...trustedHostHeaders('POST', renamePath, handle.port), + }, body: JSON.stringify({ title: ' New\tTitle\u001b ' }), }); @@ -1202,6 +1207,23 @@ describe('GET /api/sessions/:sessionId/write-link', () => { spy.mockRestore(); }); + it('reports Web Terminal as unsupported for the zmx backend', async () => { + setIpcAuthSecret(TEST_IPC_SECRET); + const spy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue({ + session: { sessionId: 's-zmx', backendType: 'zmx', webPort: 4321 }, + workerPort: 4321, + workerToken: 'stale-secret', + riffAccessUrl: 'https://stale-riff.example', + } as any); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const res = await fetch(`http://127.0.0.1:${handle.port}/api/sessions/s-zmx/write-link`, { + headers: tokenAuthHeaders(), + }); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe('terminal_unsupported'); + spy.mockRestore(); + }); + it('returns 200 with a token-bearing url for a live session', async () => { setIpcAuthSecret(TEST_IPC_SECRET); const spy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue({ diff --git a/test/device-isolation-daemon.test.ts b/test/device-isolation-daemon.test.ts index 95551c14f..0d342142a 100644 --- a/test/device-isolation-daemon.test.ts +++ b/test/device-isolation-daemon.test.ts @@ -135,30 +135,45 @@ describe('device-isolation daemon transaction', () => { expect(currentDeviceIsolationFreezeLease(NOW)).toBeNull(); }); - it('blocks adopted and unattested detached sessions before exposing a lease', () => { + it('blocks adopted and unattested detached ZMX sessions before exposing a lease', () => { const sessions: DeviceIsolationRuntimeSession[] = [{ sessionId: 'adopted', adopted: true, frozenBackend: 'tmux', workerPresent: false, }, { - sessionId: 'detached', + sessionId: 'abcdefgh-detached', adopted: false, - frozenBackend: 'tmux', + frozenBackend: 'zmx', workerPresent: false, }]; + const probes: Array<{ backendType: string; name: string }> = []; setDeviceIsolationDaemonDependenciesForTest({ dataDir: () => '/tmp/data', listSessions: () => sessions, processStart: pid => pid === process.pid ? 'daemon-start' : undefined, - probePersistent: () => 'exists', + probePersistent: (backendType, name) => { + probes.push({ backendType, name }); + return 'exists'; + }, }); const inventory = buildDeviceIsolationInventory(); expect(inventory.blockers).toEqual([ + { sessionId: 'abcdefgh-detached', blocker: 'unattested_worker' }, { sessionId: 'adopted', blocker: 'adopted_session' }, - { sessionId: 'detached', blocker: 'unattested_worker' }, ]); + expect(inventory.entries[0]).toMatchObject({ + sessionId: 'abcdefgh-detached', + backendType: 'zmx', + disposition: 'blocked', + persistent: { + backendType: 'zmx', + name: 'bmx-abcdefgh', + probe: 'exists', + }, + }); + expect(probes).toEqual([{ backendType: 'zmx', name: 'bmx-abcdefgh' }]); const result = prepareDeviceIsolationActivation({ activationVersion: 1, nonce: NONCE }); expect(result).toMatchObject({ status: 409, @@ -167,6 +182,94 @@ describe('device-isolation daemon transaction', () => { expect(currentDeviceIsolationFreezeLease(NOW)).toBeNull(); }); + it('terminates an attested live ZMX session before committing activation', async () => { + let marker = pendingMarker(); + let backingExists = true; + let sessions: DeviceIsolationRuntimeSession[] = [{ + sessionId: 'abcdefgh-owned', + adopted: false, + frozenBackend: 'zmx', + workerPresent: true, + workerGeneration: 9, + worker: { pid: 3001, procStart: 'zmx-worker-start' }, + attestation: { + backendType: 'zmx', + credentialIsolated: false, + cli: { pid: 3002, procStart: 'zmx-cli-start' }, + workerGeneration: 9, + }, + }]; + const live = new Map<number, string>([ + [process.pid, 'daemon-start'], + [3001, 'zmx-worker-start'], + [3002, 'zmx-cli-start'], + ]); + const killed: Array<{ backendType: string; name: string; sessionId: string }> = []; + let closeCalls = 0; + setDeviceIsolationDaemonDependenciesForTest({ + now: () => NOW, + dataDir: () => '/tmp/data', + listSessions: () => sessions, + processStart: pid => live.get(pid), + processExists: pid => live.has(pid), + readMarker: () => marker, + probePersistent: (backendType, name) => { + expect({ backendType, name }).toEqual({ + backendType: 'zmx', + name: 'bmx-abcdefgh', + }); + return backingExists ? 'exists' : 'missing'; + }, + killPersistent: (backendType, name, sessionId) => { + killed.push({ backendType, name, sessionId }); + backingExists = false; + }, + closeWorker: () => { + closeCalls += 1; + live.delete(3001); + live.delete(3002); + sessions = [{ + sessionId: 'abcdefgh-owned', + adopted: false, + frozenBackend: 'zmx', + workerPresent: false, + }]; + }, + sleep: async () => {}, + }); + + const prepared = prepareDeviceIsolationActivation({ activationVersion: 1, nonce: NONCE }); + expect(prepared.status).toBe(200); + expect(prepared.body.inventory).toEqual([ + expect.objectContaining({ + sessionId: 'abcdefgh-owned', + backendType: 'zmx', + disposition: 'owned_local', + persistent: { + backendType: 'zmx', + name: 'bmx-abcdefgh', + probe: 'exists', + }, + }), + ]); + + const committed = await commitDeviceIsolationActivation({ + activationVersion: 1, + nonce: NONCE, + leaseId: prepared.body.leaseId, + markerSha256: digest(marker), + }); + + expect(committed).toMatchObject({ status: 200, body: { phase: 'committed' } }); + expect(closeCalls).toBe(1); + expect(killed).toEqual([{ + backendType: 'zmx', + name: 'bmx-abcdefgh', + sessionId: 'abcdefgh-owned', + }]); + expect(backingExists).toBe(false); + }); + it('allows abort only before commit and retains the committed freeze', async () => { let marker = pendingMarker(); setDeviceIsolationDaemonDependenciesForTest({ diff --git a/test/kill-worker-orphaned-backend.test.ts b/test/kill-worker-orphaned-backend.test.ts index 5804cf0a8..671bab981 100644 --- a/test/kill-worker-orphaned-backend.test.ts +++ b/test/kill-worker-orphaned-backend.test.ts @@ -43,7 +43,7 @@ vi.mock('../src/adapters/backend/zellij-backend.js', () => ({ vi.mock('../src/adapters/backend/zmx-backend.js', () => ({ ZmxBackend: { sessionName: (id: string) => `bmx-${id.slice(0, 8)}`, - killSession: zmxKill, + killManagedSession: zmxKill, listBotmuxSessions: vi.fn(() => []), }, })); @@ -72,7 +72,7 @@ vi.mock('../src/utils/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, })); -import { killWorker } from '../src/core/worker-pool.js'; +import { killWorker, teardownAuthoritativePersistentBackingBeforeClose } from '../src/core/worker-pool.js'; const SID = 'abcd1234-0000-0000-0000-000000000000'; const EXPECTED_NAME = 'bmx-abcd1234'; @@ -138,7 +138,7 @@ describe('killWorker — orphaned backing session teardown (no live worker)', () it('destroys the zmx backing session', () => { killWorker(ds({}, { backendType: 'zmx' })); - expect(zmxKill).toHaveBeenCalledWith(EXPECTED_NAME); + expect(zmxKill).toHaveBeenCalledWith(EXPECTED_NAME, SID); expect(tmuxKill).not.toHaveBeenCalled(); }); @@ -184,3 +184,34 @@ describe('killWorker — with a live worker (unchanged path)', () => { expect(d.managedTurnOrigin).toBeUndefined(); }); }); + +describe('teardownAuthoritativePersistentBackingBeforeClose', () => { + it('synchronously proves ZMX teardown before callers mutate close state', () => { + const d = ds({ worker: { killed: false } as any }, { backendType: 'zmx' }); + + teardownAuthoritativePersistentBackingBeforeClose(d); + + expect(zmxKill).toHaveBeenCalledWith(EXPECTED_NAME, SID); + expect(d.worker).not.toBeNull(); + expect(d.session.status).toBeUndefined(); + }); + + it('propagates ZMX ownership refusal without mutating the session', () => { + const refusal = new Error('ownership probe unavailable'); + zmxKill.mockImplementationOnce(() => { throw refusal; }); + const d = ds({ worker: { killed: false } as any }, { backendType: 'zmx' }); + + expect(() => teardownAuthoritativePersistentBackingBeforeClose(d)).toThrow(refusal); + expect(d.worker).not.toBeNull(); + expect(d.session.status).toBeUndefined(); + }); + + it('is a no-op for non-ZMX, queued, and adopted sessions', () => { + teardownAuthoritativePersistentBackingBeforeClose(ds({}, { backendType: 'tmux' })); + teardownAuthoritativePersistentBackingBeforeClose(ds({ session: { sessionId: SID, queued: true } as any }, { backendType: 'zmx' })); + teardownAuthoritativePersistentBackingBeforeClose(ds({ adoptedFrom: { source: 'tmux' } as any }, { backendType: 'zmx' })); + + expect(zmxKill).not.toHaveBeenCalled(); + expect(tmuxKill).not.toHaveBeenCalled(); + }); +}); diff --git a/test/persistent-backend-type.test.ts b/test/persistent-backend-type.test.ts index a4fde73ca..9527cd034 100644 --- a/test/persistent-backend-type.test.ts +++ b/test/persistent-backend-type.test.ts @@ -24,6 +24,7 @@ import { killPersistentBackendTarget, managedTargetsForCliChange, probePersistentBackendTarget, + killPersistentSession, probePersistentSessions, resolvePersistentBackendTarget, resolvePairedSpawnBackendType, @@ -204,3 +205,17 @@ describe('probePersistentSessions', () => { probe.mockRestore(); }); }); + +describe('killPersistentSession ZMX ownership fence', () => { + it('refuses name-only deletion and delegates the complete UUID to the managed kill', () => { + expect(() => killPersistentSession('zmx', 'bmx-abcdef12')).toThrow(/name-only ZMX kill/); + + const kill = vi.spyOn(ZmxBackend, 'killManagedSession').mockImplementation(() => {}); + killPersistentSession('zmx', 'bmx-abcdef12', 'abcdef12-1111-2222-3333-444444444444'); + expect(kill).toHaveBeenCalledWith( + 'bmx-abcdef12', + 'abcdef12-1111-2222-3333-444444444444', + ); + kill.mockRestore(); + }); +}); diff --git a/test/raw-input-followup-atomicity.test.ts b/test/raw-input-followup-atomicity.test.ts index e3e9c325c..1362f996d 100644 --- a/test/raw-input-followup-atomicity.test.ts +++ b/test/raw-input-followup-atomicity.test.ts @@ -137,7 +137,7 @@ describe('worker command-line write mutex', () => { }); describe('worker sendRawCommandLine helper', () => { - const helper = caseRegion(workerSrc, 'async function sendRawCommandLine', 2200); + const helper = caseRegion(workerSrc, 'async function sendRawCommandLine', 3000); it('generic CLIs: literal text → 200ms beat → Enter in order (slash-picker safe)', () => { const textIdx = helper.indexOf('sendText(content)'); @@ -172,6 +172,33 @@ describe('worker sendRawCommandLine helper', () => { expect(returnIdx).toBeGreaterThan(cocoEnterIdx); expect(returnIdx).toBeLessThan(genericTextIdx); }); + + it('fails before Enter when a backend explicitly rejects the generic text write', () => { + const textIdx = helper.indexOf('sendText(content)'); + const rejectionIdx = helper.indexOf("throw new Error('backend rejected command text input')", textIdx); + const beatIdx = helper.indexOf('setTimeout(r, 200)', textIdx); + const enterIdx = helper.indexOf("sendSpecialKeys('Enter')", textIdx); + + expect(helper.slice(textIdx - 30, rejectionIdx)).toContain('=== false'); + expect(rejectionIdx).toBeGreaterThan(textIdx); + expect(rejectionIdx).toBeLessThan(beatIdx); + expect(rejectionIdx).toBeLessThan(enterIdx); + }); + + it('stops CoCo typing immediately on rejection and also checks the submit key', () => { + const cocoIdx = helper.indexOf("cliId === 'coco'"); + const charIdx = helper.indexOf('sendText(ch)', cocoIdx); + const charRejectionIdx = helper.indexOf("throw new Error('backend rejected command text input')", charIdx); + const throttleIdx = helper.indexOf('COCO_SLASH_TYPE_THROTTLE_MS', charIdx); + const enterIdx = helper.indexOf("sendSpecialKeys('Enter')", throttleIdx); + const enterRejectionIdx = helper.indexOf("throw new Error('backend rejected command submit key')", enterIdx); + + expect(helper.slice(charIdx - 30, charRejectionIdx)).toContain('=== false'); + expect(charRejectionIdx).toBeGreaterThan(charIdx); + expect(charRejectionIdx).toBeLessThan(throttleIdx); + expect(helper.slice(enterIdx - 30, enterRejectionIdx)).toContain('=== false'); + expect(enterRejectionIdx).toBeGreaterThan(enterIdx); + }); }); describe('daemon prompt_ready dispatch', () => { diff --git a/test/restart-worker-null-reattach.test.ts b/test/restart-worker-null-reattach.test.ts index f3852d40b..c852fd352 100644 --- a/test/restart-worker-null-reattach.test.ts +++ b/test/restart-worker-null-reattach.test.ts @@ -88,7 +88,7 @@ describe('P1 requestSessionRestart wiring', () => { const body = workerPoolSource.slice(fn, fn + 2800); expect(body).toContain('shouldDestroyPaneBeforeRestart(ds)'); expect(body).toContain('persistentBackendTargetForSession(ds)'); - expect(body).toContain('killPersistentBackendTarget(target)'); + expect(body).toContain('killPersistentBackendTarget(target, ds.session.sessionId)'); // No target (e.g. PTY/riff/legacy-unstamped) → no-op, never throws. expect(body).toContain('if (!target) return;'); diff --git a/test/restore-zombie-close.test.ts b/test/restore-zombie-close.test.ts index 88c403212..947484dc1 100644 --- a/test/restore-zombie-close.test.ts +++ b/test/restore-zombie-close.test.ts @@ -174,6 +174,7 @@ vi.mock('../src/adapters/backend/zmx-backend.js', () => ({ hasSession: vi.fn(() => probe.result === 'exists'), serverState: vi.fn(() => server.state), killSession: vi.fn(), + killManagedSession: vi.fn(), }, })); @@ -198,6 +199,7 @@ import { announceSessionRow } from '../src/core/session-activity.js'; import * as sessionStore from '../src/services/session-store.js'; import { sessionKey } from '../src/core/types.js'; import type { DaemonSession } from '../src/core/types.js'; +import { logger } from '../src/utils/logger.js'; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'restore-zombie-test-')); @@ -215,6 +217,7 @@ beforeEach(() => { vi.mocked(forkWorker).mockClear(); vi.mocked(announceSessionRow).mockClear(); vi.mocked(ZmxBackend.probeSessions).mockClear(); + vi.mocked(ZmxBackend.killManagedSession).mockReset(); }); afterEach(() => { @@ -260,6 +263,28 @@ describe('restoreActiveSessions — persistent-backend zombie-close decision', ( expect(forkWorker).toHaveBeenCalledWith(expect.objectContaining({ session: s }), '', true); }); + it('isolates a collision-loser close refusal and continues restoring later rows', async () => { + const blocked = makeActivePersistentSession('om_collision_blocked', 'zmx'); + const survivor = makeActivePersistentSession('om_collision_survivor', 'tmux'); + const map = new Map<string, DaemonSession>(); + wp.registry = map; + vi.mocked(setActiveSessionSafe).mockResolvedValueOnce(false); + vi.mocked(closeSession).mockRejectedValueOnce(new Error('ownership probe unavailable')); + + await expect(restoreActiveSessions(map)).resolves.toBeUndefined(); + + expect(closeSession).toHaveBeenCalledWith(blocked.sessionId); + expect(sessionStore.getSession(blocked.sessionId)?.status).toBe('active'); + expect(map.get(sessionKey('om_collision_blocked', 'app_test'))).toBeUndefined(); + expect(map.get(sessionKey('om_collision_survivor', 'app_test'))?.session.sessionId).toBe(survivor.sessionId); + expect(forkWorker).toHaveBeenCalledWith( + expect.objectContaining({ session: expect.objectContaining({ sessionId: survivor.sessionId }) }), + '', + true, + ); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('ownership probe unavailable')); + }); + it('"missing" → closes the zombie (Map eviction + store closed), does not fork', async () => { probe.result = 'missing'; const s = makeActivePersistentSession('om_missing'); @@ -352,6 +377,67 @@ describe('restoreActiveSessions — persistent-backend zombie-close decision', ( expect(forkWorker).not.toHaveBeenCalled(); }); + it('isolates a failed ZMX mismatch teardown and continues closing later sessions', async () => { + const blocked = makeActivePersistentSession('om_zmx_mismatch_blocked', 'zmx'); + blocked.cliId = 'codex'; + sessionStore.updateSession(blocked); + const closable = makeActivePersistentSession('om_zmx_mismatch_closable', 'zmx'); + closable.cliId = 'codex'; + sessionStore.updateSession(closable); + vi.mocked(ZmxBackend.killManagedSession) + .mockImplementationOnce(() => { throw new Error('ownership probe unavailable'); }) + .mockImplementationOnce(() => undefined); + const map = new Map<string, DaemonSession>(); + wp.registry = map; + + await expect(restoreActiveSessions(map)).resolves.toBeUndefined(); + + expect(ZmxBackend.killManagedSession).toHaveBeenNthCalledWith( + 1, + `bmx-${blocked.sessionId.slice(0, 8)}`, + blocked.sessionId, + ); + expect(ZmxBackend.killManagedSession).toHaveBeenNthCalledWith( + 2, + `bmx-${closable.sessionId.slice(0, 8)}`, + closable.sessionId, + ); + expect(sessionStore.getSession(blocked.sessionId)?.status).toBe('active'); + expect(sessionStore.getSession(closable.sessionId)?.status).toBe('closed'); + expect(closeSession).toHaveBeenCalledWith(closable.sessionId); + expect(closeSession).not.toHaveBeenCalledWith(blocked.sessionId); + // The uncertain row remains retryable on disk but is not registered or + // reattached into the live map with its now-mismatched CLI. + expect(map.size).toBe(0); + expect(forkWorker).not.toHaveBeenCalled(); + }); + + it('keeps a deferred ZMX row active when teardown is uncertain and restores later rows', async () => { + const hidden = makeActivePersistentSession('om_deferred_zmx_blocked', 'zmx'); + hidden.deferredScheduleRun = { + taskId: 'task-hidden', + turnId: 'schedule:task-hidden:run-1', + routingAnchor: 'schedule-run:task-hidden:run-1', + createdAt: '2026-07-21T00:00:00.000Z', + }; + sessionStore.updateSession(hidden); + const survivor = makeActivePersistentSession('om_restore_after_blocked'); + sessionStore.updateSession(survivor); + vi.mocked(ZmxBackend.killManagedSession) + .mockImplementationOnce(() => { throw new Error('kill confirmation timed out'); }); + const map = new Map<string, DaemonSession>(); + wp.registry = map; + + await expect(restoreActiveSessions(map)).resolves.toBeUndefined(); + + expect(sessionStore.getSession(hidden.sessionId)?.status).toBe('active'); + expect(closeSession).not.toHaveBeenCalledWith(hidden.sessionId); + expect(map.get(sessionKey('om_deferred_zmx_blocked', 'app_test'))).toBeUndefined(); + expect(sessionStore.getSession(survivor.sessionId)?.status).toBe('active'); + expect(map.get(sessionKey('om_restore_after_blocked', 'app_test'))?.session.sessionId).toBe(survivor.sessionId); + expect(forkWorker).toHaveBeenCalledTimes(1); + }); + it('wrapper mismatch on restore (same cliId) → closes the active record', async () => { // 'aiden x claude' and bare claude-code share cliId='claude-code' but are // distinct launch choices (selectionKeyForBot keys on cliId+wrapperCli). @@ -679,6 +765,29 @@ describe('closeCliMismatchedSessionsForBot — runtime CLI hot-switch sweep', () expect(TmuxBackend.killSession).not.toHaveBeenCalled(); }); + it('isolates a failed ZMX teardown during a runtime sweep and continues', async () => { + const blocked = makeActivePersistentSession('om_rt_zmx_blocked', 'zmx'); + blocked.cliId = 'codex'; + sessionStore.updateSession(blocked); + registerDs(blocked); + const closable = makeActivePersistentSession('om_rt_zmx_closable', 'zmx'); + closable.cliId = 'codex'; + sessionStore.updateSession(closable); + registerDs(closable); + vi.mocked(ZmxBackend.killManagedSession) + .mockImplementationOnce(() => { throw new Error('ownership probe unavailable'); }) + .mockImplementationOnce(() => undefined); + + await expect(closeCliMismatchedSessionsForBot('app_test')).resolves.toBe(1); + + expect(sessionStore.getSession(blocked.sessionId)?.status).toBe('active'); + expect(wp.registry!.get(sessionKey('om_rt_zmx_blocked', 'app_test'))?.session.sessionId).toBe(blocked.sessionId); + expect(sessionStore.getSession(closable.sessionId)?.status).toBe('closed'); + expect(wp.registry!.get(sessionKey('om_rt_zmx_closable', 'app_test'))).toBeUndefined(); + expect(closeSession).toHaveBeenCalledWith(closable.sessionId); + expect(closeSession).not.toHaveBeenCalledWith(blocked.sessionId); + }); + it('returns 0 when the registry is not initialized', async () => { wp.registry = null; expect(await closeCliMismatchedSessionsForBot('app_test')).toBe(0); diff --git a/test/session-card-model.test.ts b/test/session-card-model.test.ts index b849d6139..a77c5a5f9 100644 --- a/test/session-card-model.test.ts +++ b/test/session-card-model.test.ts @@ -139,6 +139,13 @@ describe('session-card-model · composeDetail action matrix (M5 extended)', () = // webPort=null → openTerminal=false const noPort = composeDetail(makeRow({ status: 'working', webPort: null })); expect(noPort.actions.openTerminal.enabled).toBe(false); + expect(noPort.actions.openTerminal.reasonKey).toBe('sessions.action.terminal.noPort'); + + // ZMX intentionally has no Web Terminal. Even a stale persisted webPort + // must be classified as unsupported rather than temporarily unavailable. + const zmx = composeDetail(makeRow({ status: 'working', backendType: 'zmx', webPort: 7100 })); + expect(zmx.actions.openTerminal.enabled).toBe(false); + expect(zmx.actions.openTerminal.reasonKey).toBe('sessions.action.terminal.unsupported'); // scope='chat' → locateMode='openChat' const chatScope = composeDetail(makeRow({ scope: 'chat' })); diff --git a/test/sessions-card.test.ts b/test/sessions-card.test.ts index 1bc84cb74..3982a8994 100644 --- a/test/sessions-card.test.ts +++ b/test/sessions-card.test.ts @@ -584,6 +584,24 @@ describe('buildSessionsDetailCard (slice 2a)', () => { expect(json).toContain('Web 终端端口'); }); + it('terminal: ZMX → button disabled with an unsupported reason, not a temporary no-port hint', () => { + const detail = detailFor({ + sessionId: 'sess_zmx', + status: 'idle', + backendType: 'zmx', + webPort: 7891, + }); + const json = buildSessionsDetailCard(detail, baseOpts); + const parsed = JSON.parse(json); + const actionRow = (parsed.elements as any[]).find((e: any) => e.tag === 'action'); + const terminalBtn = (actionRow.actions as any[])[1]; + + expect(terminalBtn.disabled).toBe(true); + expect(terminalBtn.multi_url).toBeUndefined(); + expect(json).toContain('当前后端不提供 Web 终端'); + expect(json).not.toContain('Web 终端端口'); + }); + it('terminal: has webPort → button has multi_url, NOT disabled', () => { const detail = detailFor({ sessionId: 'sess_term', status: 'idle' }); const json = buildSessionsDetailCard(detail, { diff --git a/test/strict-input-handle.test.ts b/test/strict-input-handle.test.ts new file mode 100644 index 000000000..290cc39f5 --- /dev/null +++ b/test/strict-input-handle.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { strictInputHandle } from '../src/adapters/cli/strict-input-handle.js'; +import type { PtyHandle } from '../src/adapters/cli/types.js'; + +class FakeHandle implements PtyHandle { + cliPid = 7; + readonly writes: string[] = []; + readonly textAttempts: string[] = []; + readonly keyAttempts: string[][] = []; + + write(data: string): void { + this.writes.push(data); + } + + sendText(text: string): boolean { + this.textAttempts.push(text); + return false; + } + + sendSpecialKeys(...keys: string[]): boolean { + this.keyAttempts.push(keys); + return false; + } + + describe(): string { + return `pid=${this.cliPid}`; + } +} + +describe('strictInputHandle', () => { + it('turns explicit sendText and sendSpecialKeys rejection into submission errors', () => { + const raw = new FakeHandle(); + const strict = strictInputHandle(raw); + + expect(() => strict.sendText!('prompt')).toThrow(/rejected sendText/); + expect(() => strict.sendSpecialKeys!('Enter')).toThrow(/rejected sendSpecialKeys/); + expect(raw.textAttempts).toEqual(['prompt']); + expect(raw.keyAttempts).toEqual([['Enter']]); + }); + + it('forwards ordinary methods and properties to the original handle', () => { + const raw = new FakeHandle(); + const strict = strictInputHandle(raw); + + strict.write('best-effort navigation'); + expect(raw.writes).toEqual(['best-effort navigation']); + expect(strict.describe()).toBe('pid=7'); + + strict.cliPid = 42; + expect(raw.cliPid).toBe(42); + expect(strict.cliPid).toBe(42); + }); + + it('returns one stable strict handle for the same backend instance', () => { + const raw = new FakeHandle(); + + expect(strictInputHandle(raw)).toBe(strictInputHandle(raw)); + }); +}); diff --git a/test/substitute-control-card.test.ts b/test/substitute-control-card.test.ts index 1a4a25219..039e94ac5 100644 --- a/test/substitute-control-card.test.ts +++ b/test/substitute-control-card.test.ts @@ -2,7 +2,8 @@ * Integration test: substitute-mode owner control card delivery. * * When a substitute-mode (分身) session's worker reports ready, the worker-pool - * automatically DMs a writable-terminal control card to the bot's owner(s). + * automatically DMs a control card to the bot's owner(s). Web-capable backends + * include a writable terminal; ZMX receives manage-only controls. * * Scenarios covered: * 1. Substitute session → DM sent to each owner with a writable-terminal card. @@ -158,6 +159,28 @@ describe('deliverSubstituteControlCard', () => { expect(updateSessionCalls[0].substituteControlCardSent).toBe(true); }); + it('DMs a manage-only control card for ZMX without a Web Terminal', async () => { + botState.resolvedAllowedUsers = ['ou_owner1']; + const ds = makeDs({ + session: makeSession({ backendType: 'zmx' }), + workerPort: null, + workerToken: null, + }); + + const result = await deliverSubstituteControlCard(ds); + + expect(result).toEqual({ sent: 1, total: 1 }); + expect(sendUserMessageCalls).toHaveLength(1); + expect(JSON.parse(sendUserMessageCalls[0].cardJson)).toMatchObject({ + type: 'session', + url: '', + showManageButtons: true, + adoptMode: false, + }); + expect(ds.session.substituteControlCardSent).toBe(true); + expect(updateSessionCalls).toHaveLength(1); + }); + it('skips when the persistent sent flag is already set', async () => { botState.resolvedAllowedUsers = ['ou_owner1']; const ds = makeDs({ session: makeSession({ substituteControlCardSent: true }) }); diff --git a/test/tmux-reattach-backend.test.ts b/test/tmux-reattach-backend.test.ts index dbc7f0e08..6ce7903fc 100644 --- a/test/tmux-reattach-backend.test.ts +++ b/test/tmux-reattach-backend.test.ts @@ -243,16 +243,20 @@ describe('selectSessionBackend', () => { expect((selected.backend as any).opts).toEqual({ ownsSession: true, isReattach: false }); }); - it('uses zmx attach as a direct PTY backend', () => { + it('uses zmx tail signals, history snapshots, and send as a managed pipe backend', () => { vi.mocked(ZmxBackend.hasSession).mockReturnValue(true); const selected = selectSessionBackend({ sessionId: '9cfa0024-197d-4781-845b-c541dceb8980', backendType: 'zmx' }); expect(selected.isZellijMode).toBe(false); expect(selected.isTmuxMode).toBe(false); - expect(selected.isPipeMode).toBe(false); + expect(selected.isPipeMode).toBe(true); expect(selected.backend.constructor.name).toBe('MockZmxBackend'); - expect((selected.backend as any).opts).toEqual({ ownsSession: true, isReattach: true }); + expect((selected.backend as any).opts).toEqual({ + ownsSession: true, + isReattach: true, + sessionId: '9cfa0024-197d-4781-845b-c541dceb8980', + }); }); }); diff --git a/test/transfer-session.test.ts b/test/transfer-session.test.ts index 193e3219e..c0d652e3a 100644 --- a/test/transfer-session.test.ts +++ b/test/transfer-session.test.ts @@ -872,7 +872,7 @@ describe('closeSession concurrency', () => { const base = makeDs().session; const zmx = { ...base, backendType: 'zmx' as const }; expect(destroyUnregisteredPersistentBacking(zmx, kill)).toBe(true); - expect(kill).toHaveBeenCalledWith('zmx', 'bmx-sess-abc'); + expect(kill).toHaveBeenCalledWith('zmx', 'bmx-sess-abc', 'sess-abc-123'); kill.mockClear(); expect(destroyUnregisteredPersistentBacking({ ...zmx, adoptedFrom: { diff --git a/test/vc-meeting-runtime-lease-recovery.test.ts b/test/vc-meeting-runtime-lease-recovery.test.ts index 6474dfd81..78a6cafad 100644 --- a/test/vc-meeting-runtime-lease-recovery.test.ts +++ b/test/vc-meeting-runtime-lease-recovery.test.ts @@ -91,6 +91,7 @@ function harness(input: { const sent: Array<{ sessionId: string; turnId: string; dispatchAttempt: number }> = []; const killed: string[] = []; const backingKills: string[] = []; + const backingKillCalls: Array<{ backend: string; sessionName: string; sessionId: string }> = []; const probes: string[] = []; const warnings: string[] = []; const errors: string[] = []; @@ -110,8 +111,9 @@ function harness(input: { resolvePersistentScope: (ds: FakeSession) => ds.testPersistentScope ?? 'unknown', resolveMissingPersistentScope: () => input.missingPersistentScope ?? 'unknown', backendAvailable: (backend: 'tmux' | 'herdr' | 'zellij' | 'zmx') => input.backendAvailable?.(backend) ?? true, - killPersistent: (backend: string, sessionName: string) => { + killPersistent: (backend: string, sessionName: string, sessionId: string) => { backingKills.push(`${backend}:${sessionName}`); + backingKillCalls.push({ backend, sessionName, sessionId }); }, probePersistent: (backend: 'tmux' | 'herdr' | 'zellij' | 'zmx', sessionName: string) => { probes.push(`${backend}:${sessionName}`); @@ -120,7 +122,7 @@ function harness(input: { warn: (message: string) => warnings.push(message), error: (message: string) => errors.push(message), } as any); - return { recovery, sessions, sent, killed, backingKills, probes, warnings, errors }; + return { recovery, sessions, sent, killed, backingKills, backingKillCalls, probes, warnings, errors }; } describe('VC meeting runtime lease recovery', () => { @@ -199,6 +201,21 @@ describe('VC meeting runtime lease recovery', () => { expect(h.recovery.snapshot()).toEqual([]); }); + it('passes the full receiver session id to ZMX teardown', () => { + const h = harness({ + sessions: [fakeSession({ worker: false, persistentScope: 'zmx' })], + }); + h.recovery.arm(ref(), 'agent_test'); + + expect(h.backingKillCalls).toEqual([{ + backend: 'zmx', + sessionName: 'bmx-session_', + sessionId: 'session_a', + }]); + expect(h.probes).toEqual(['zmx:bmx-session_']); + expect(h.recovery.snapshot()).toEqual([]); + }); + it('keeps worker-exit replay gated while its persistent pane exists, without blocking another member', async () => { let probeState: 'exists' | 'missing' = 'exists'; const h = harness({ diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index 4ab2de602..c8566d47e 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -36,6 +36,10 @@ describe('worker pipe initial screen ordering', () => { it('runs a busy-pattern idle probe after each submitted input', () => { const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + // The backend is now wrapped by adapterInputHandle() so ZMX can turn an + // explicit false send result into a submission failure. Pin the call site, + // rather than its argument formatting, while preserving the ordering + // contract this test protects. const writeIdx = source.indexOf('result = await cliAdapter.writeStructuredInput('); const probeIdx = source.indexOf('scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);'); const helperIdx = source.indexOf('function scheduleBusyPatternIdleProbe(source: string): void'); @@ -192,6 +196,21 @@ describe('worker pipe initial screen ordering', () => { expect(markReadyIdx).toBeGreaterThan(flushIdx); }); + it('keys overlapping authoritative screen settles by the idle-edge revision', () => { + const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + const settleStart = source.indexOf('function settleBackendScreenBeforeIdle'); + const settleEnd = source.indexOf('/** Submission writes', settleStart); + const settle = source.slice(settleStart, settleEnd); + const idleStart = source.indexOf('idleDetector.onIdle(async () =>'); + const idleEnd = source.indexOf('backend.onData(onPtyData);', idleStart); + const idle = source.slice(idleStart, idleEnd); + + expect(settle).toContain('existing.revision >= requestedRevision'); + expect(settle).toContain('if (existing) await existing.promise;'); + expect(settle).toContain('revision: requestedRevision'); + expect(idle).toContain('settleBackendScreenBeforeIdle(idleBackend, revisionBeforeSettle)'); + }); + it('honors a true ready signal that arrives AFTER the timeout fallback (slow cold start)', () => { // ReadyGate.receive() is one-shot: once the 45s fallback fires, a later // releaseReadyGate from the real signal is skipped entirely. A CLI whose diff --git a/test/worker-ready-display-mode.test.ts b/test/worker-ready-display-mode.test.ts index a51e93875..ede67ca0f 100644 --- a/test/worker-ready-display-mode.test.ts +++ b/test/worker-ready-display-mode.test.ts @@ -83,7 +83,7 @@ vi.mock('../src/core/dashboard-events.js', () => ({ })); vi.mock('../src/core/dashboard-rows.js', () => ({ - composeRowFromActive: vi.fn(), + composeRowFromActive: vi.fn(() => ({ tokenUsage: undefined })), })); vi.mock('../src/skills/installer.js', () => ({ @@ -244,6 +244,73 @@ describe('Worker ready: set_display_mode re-sync', () => { expect(logs).not.toContain('view_cap'); }); + it('treats port=0 as ready without Web Terminal and keeps screen/screenshot state flowing', async () => { + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + session: { ...makeDs().session, backendType: 'zmx' }, + streamCardPending: true, + streamCardId: undefined, + worker: fakeWorker, + displayMode: 'screenshot', + }); + + __testOnly_setupWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'ready', port: 0, token: 'unused', viewToken: 'unused-view' }); + await flush(); + + expect(ds.workerReady).toBe(true); + expect(ds.workerPort).toBeNull(); + expect(ds.workerToken).toBeNull(); + expect(ds.workerViewToken).toBeNull(); + expect(ds.session.webPort).toBeUndefined(); + expect(sessionReplyMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(sessionReplyMock.mock.calls[0][1]).readUrl).toBe(''); + + fakeWorker.emit('message', { + type: 'screen_update', + content: 'plain zmx history', + status: 'idle', + }); + fakeWorker.emit('message', { + type: 'screenshot_uploaded', + imageKey: 'img_zmx_history', + status: 'idle', + }); + await flush(); + + expect(ds.lastScreenContent).toBe('plain zmx history'); + expect(ds.lastScreenStatus).toBe('idle'); + expect(ds.currentImageKey).toBe('img_zmx_history'); + }); + + it('ignores every message from a replaced worker generation', async () => { + const staleWorker = makeFakeWorker(); + const currentWorker = makeFakeWorker(); + const ds = makeDs({ + worker: currentWorker, + workerReady: false, + workerPort: null, + lastScreenContent: 'current generation', + lastScreenStatus: 'working', + }); + + __testOnly_setupWorkerHandlers(ds, staleWorker); + staleWorker.emit('message', { type: 'ready', port: 9999, token: 'stale-token' }); + staleWorker.emit('message', { + type: 'screen_update', + content: 'stale generation', + status: 'idle', + }); + await flush(); + + expect(ds.workerReady).toBe(false); + expect(ds.workerPort).toBeNull(); + expect(ds.workerToken).toBeNull(); + expect(ds.lastScreenContent).toBe('current generation'); + expect(ds.lastScreenStatus).toBe('working'); + expect(sessionReplyMock).not.toHaveBeenCalled(); + }); + it('doc-native session never posts a streaming card to the virtual doc: chat id', async () => { const fakeWorker = makeFakeWorker(); const ds = makeDs({ diff --git a/test/write-link-delivery.test.ts b/test/write-link-delivery.test.ts index 32d93b457..f2dc89d35 100644 --- a/test/write-link-delivery.test.ts +++ b/test/write-link-delivery.test.ts @@ -129,6 +129,20 @@ describe('deliverWriteLinkCard', () => { }); describe('deliverWriteLinkCardToOwners (botmux term-link backend)', () => { + it('distinguishes a backend that never provides Web Terminal from temporary readiness', async () => { + botState.owners = ['ou_owner1']; + const r = await deliverWriteLinkCardToOwners(liveDs({ + session: { + sessionId: 'sess1234abcd', + rootMessageId: 'om_root', + title: 'demo', + cliId: 'claude-code', + backendType: 'zmx', + }, + })); + expect(r).toEqual({ ok: false, error: 'terminal_unsupported', delivered: 0, total: 0, channels: [] }); + }); + it('refuses when the terminal is not ready (no worker token) — never builds a card', async () => { botState.owners = ['ou_owner1']; const r = await deliverWriteLinkCardToOwners(liveDs({ workerToken: null })); @@ -180,6 +194,19 @@ describe('deliverWriteLinkCardToOwners (botmux term-link backend)', () => { }); describe('deliverWritableTerminalCardTo (/term slash command backend)', () => { + it('returns unsupported for a backend with no Web Terminal capability', async () => { + const r = await deliverWritableTerminalCardTo(liveDs({ + session: { + sessionId: 'sess1234abcd', + rootMessageId: 'om_root', + title: 'demo', + cliId: 'claude-code', + backendType: 'zmx', + }, + }), 'ou_owner'); + expect(r).toBe('unsupported'); + }); + it('returns not_ready (and never sends) when the terminal has no token', async () => { const r = await deliverWritableTerminalCardTo(liveDs({ workerToken: null }), 'ou_owner'); expect(r).toBe('not_ready'); diff --git a/test/zmx-backend-helpers.test.ts b/test/zmx-backend-helpers.test.ts index dddda771d..ec977355d 100644 --- a/test/zmx-backend-helpers.test.ts +++ b/test/zmx-backend-helpers.test.ts @@ -11,17 +11,21 @@ vi.mock('node:child_process', async (importOriginal) => { import { execFileSync } from 'node:child_process'; import { buildFreshAttachArgs, - buildReattachArgs, buildZmxLaunchFiles, findSessionPid, + normaliseZmxHistory, parseZmxList, parseZmxShortList, - terminalOscColorQueryReplies, tmuxKeyToBytes, zmxControlEnv, ZmxBackend, } from '../src/adapters/backend/zmx-backend.js'; -import { parseZmxVersion, probeZmxFunctional, zmxEnv } from '../src/setup/ensure-zmx.js'; +import { + parseZmxVersion, + probeZmxFunctional, + probeZmxVersion, + zmxEnv, +} from '../src/setup/ensure-zmx.js'; const execFileSyncMock = vi.mocked(execFileSync); @@ -36,7 +40,7 @@ describe('zmx env/probe helpers', () => { ZMX_SESSION: 'parent', ZMX_SESSION_PREFIX: 'dev-', ZMX_DIR: '/tmp/zmx', - } as any); + } as NodeJS.ProcessEnv); expect(env.ZMX_SESSION).toBeUndefined(); expect(env.ZMX_SESSION_PREFIX).toBeUndefined(); @@ -45,29 +49,44 @@ describe('zmx env/probe helpers', () => { expect(env.PATH).toContain('.local/share/mise/shims'); }); - it('requires both version and list to succeed', () => { - execFileSyncMock.mockReturnValueOnce('zmx 0.6.0\n' as any); - execFileSyncMock.mockReturnValueOnce('' as any); + it('requires the compatible version and a functional list command', () => { + execFileSyncMock.mockReturnValueOnce('zmx 0.7.1\n' as never); + execFileSyncMock.mockReturnValueOnce('' as never); - expect(probeZmxFunctional()).toEqual({ ok: true, version: 'zmx 0.6.0' }); + expect(probeZmxFunctional()).toEqual({ ok: true, version: 'zmx 0.7.1' }); expect(execFileSyncMock).toHaveBeenNthCalledWith(1, 'zmx', ['version'], expect.any(Object)); expect(execFileSyncMock).toHaveBeenNthCalledWith(2, 'zmx', ['list'], expect.any(Object)); }); - it('parses real multiline output and rejects unsupported or malformed versions', () => { + it('can enforce the protocol version without requiring a list probe', () => { + execFileSyncMock.mockReturnValueOnce('zmx 0.7.2\n' as never); + expect(probeZmxVersion()).toEqual({ ok: true, version: 'zmx 0.7.2' }); + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + expect(execFileSyncMock).toHaveBeenCalledWith('zmx', ['version'], expect.any(Object)); + }); + + it('parses versions but rejects releases without the required send contract', () => { expect(parseZmxVersion('zmx\t\t0.6.0\nghostty_vt\tdev\n')).toEqual([0, 6, 0]); expect(parseZmxVersion('zmx 0.7.1')).toEqual([0, 7, 1]); expect(parseZmxVersion('unknown')).toBeNull(); - execFileSyncMock.mockReturnValueOnce('zmx 0.5.9\n' as any); + execFileSyncMock.mockReturnValueOnce('zmx 0.6.99\n' as never); expect(probeZmxFunctional()).toEqual({ ok: false, - reason: 'zmx >= 0.6.0 才受支持(当前 0.5.9)', + reason: 'zmx >= 0.7.1 才受支持(当前 0.6.99;需要包含 PR #202 的 send 行为,输出由 history 获取)', }); expect(execFileSyncMock).toHaveBeenCalledTimes(1); execFileSyncMock.mockReset(); - execFileSyncMock.mockReturnValueOnce('garbage\n' as any); + execFileSyncMock.mockReturnValueOnce('zmx 0.7.0\n' as never); + expect(probeZmxFunctional()).toEqual({ + ok: false, + reason: 'zmx >= 0.7.1 才受支持(当前 0.7.0;需要包含 PR #202 的 send 行为,输出由 history 获取)', + }); + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + + execFileSyncMock.mockReset(); + execFileSyncMock.mockReturnValueOnce('garbage\n' as never); expect(probeZmxFunctional()).toEqual({ ok: false, reason: '无法解析 zmx 版本:garbage', @@ -87,29 +106,16 @@ describe('zmx backend pure helpers', () => { expect(tmuxKeyToBytes('weird')).toBe('weird'); }); - it('answers OSC color queries without treating color setters as queries', () => { - expect(terminalOscColorQueryReplies(10, '?')).toEqual([ - '\x1b]10;rgb:a9a9/b1b1/d6d6\x1b\\', - ]); - expect(terminalOscColorQueryReplies(4, '1;?;255;?')).toEqual([ - '\x1b]4;1;rgb:f7f7/7676/8e8e\x1b\\', - '\x1b]4;255;rgb:eeee/eeee/eeee\x1b\\', - ]); - expect(terminalOscColorQueryReplies(10, '?;?')).toEqual([ - '\x1b]10;rgb:a9a9/b1b1/d6d6\x1b\\', - '\x1b]11;rgb:1a1a/1b1b/2626\x1b\\', - ]); - expect(terminalOscColorQueryReplies(11, '#000000')).toEqual([]); - expect(terminalOscColorQueryReplies(4, '1;?;2;#ffffff')).toEqual([ - '\x1b]4;1;rgb:f7f7/7676/8e8e\x1b\\', - ]); + it('normalises plain history and repeated CR boundaries consistently', () => { + expect(normaliseZmxHistory('one\ntwo\r\nthree')).toBe('one\r\ntwo\r\nthree'); + expect(normaliseZmxHistory('one\r\r\ntwo')).toBe('one\r\ntwo'); }); it('parses session pid from zmx list details', () => { - execFileSyncMock.mockReturnValueOnce('other\nbmx-abcd1234\n' as any); + execFileSyncMock.mockReturnValueOnce('other\nbmx-abcd1234\n' as never); execFileSyncMock.mockReturnValueOnce( ' name=other\tpid=11\tclients=0\n' + - ' name=bmx-abcd1234\tpid=4242\tclients=1\tcmd=codex\n' as any, + ' name=bmx-abcd1234\tpid=4242\tclients=1\tcmd=codex\n' as never, ); expect(findSessionPid('bmx-abcd1234')).toBe(4242); @@ -164,10 +170,10 @@ describe('zmx backend pure helpers', () => { }); it('does not infer a session pid from cwd or argv fields', () => { - execFileSyncMock.mockReturnValueOnce('bmx-other\n' as any); + execFileSyncMock.mockReturnValueOnce('bmx-other\n' as never); execFileSyncMock.mockReturnValueOnce( ' name=bmx-target\terr=Timeout\tcmd=agent --pid=999\n' + - ' name=bmx-other\tpid=42\tcmd=agent bmx-target pid=777\n' as any, + ' name=bmx-other\tpid=42\tcmd=agent bmx-target pid=777\n' as never, ); expect(findSessionPid('bmx-target')).toBeNull(); }); @@ -183,44 +189,97 @@ describe('zmx backend pure helpers', () => { unhealthySessions: [], malformedLines: ['warning: partial response'], }); - execFileSyncMock.mockReturnValueOnce('bmx-good\n' as any); + execFileSyncMock.mockReturnValueOnce('bmx-good\n' as never); execFileSyncMock.mockReturnValueOnce( - 'warning: partial response\n name=bmx-good\tpid=42\tclients=1\n' as any, + 'warning: partial response\n name=bmx-good\tpid=42\tclients=1\n' as never, ); expect(ZmxBackend.probeSession('bmx-other')).toBe('unknown'); }); it('does not classify an errored target as missing', () => { - execFileSyncMock.mockReturnValueOnce('' as any); - execFileSyncMock.mockReturnValueOnce(' name=bmx-timeout\terr=Timeout\n' as any); + execFileSyncMock.mockReturnValueOnce('' as never); + execFileSyncMock.mockReturnValueOnce(' name=bmx-timeout\terr=Timeout\n' as never); expect(ZmxBackend.probeSession('bmx-timeout')).toBe('unknown'); }); - it('does not trust a healthy-looking full row that is absent from --short', () => { - execFileSyncMock.mockReturnValueOnce('bmx-real\n' as any); + it('does not trust a healthy-looking full row absent from --short', () => { + execFileSyncMock.mockReturnValueOnce('bmx-real\n' as never); execFileSyncMock.mockReturnValueOnce( ' name=bmx-real\tpid=11\tclients=0\tcmd=agent --prompt first\n' + - ' name=bmx-forged\tpid=999\tclients=0\n' as any, + ' name=bmx-forged\tpid=999\tclients=0\n' as never, ); expect(ZmxBackend.probeSession('bmx-forged')).toBe('unknown'); }); it('lists botmux sessions from the authoritative short list', () => { - execFileSyncMock.mockReturnValueOnce('bmx-abcd1234\nnotes\nbmx-deadbeef\n' as any); + execFileSyncMock.mockReturnValueOnce('bmx-abcd1234\nnotes\nbmx-deadbeef\n' as never); execFileSyncMock.mockReturnValueOnce( ' name=bmx-abcd1234\tpid=11\tclients=0\n' + ' name=notes\tpid=12\tclients=0\n' + - ' name=bmx-deadbeef\tpid=13\tclients=1\n' as any, + ' name=bmx-deadbeef\tpid=13\tclients=1\n' as never, ); expect(ZmxBackend.listBotmuxSessions()).toEqual(['bmx-abcd1234', 'bmx-deadbeef']); }); - it('builds a race-safe attach command and strips nested-session identity', () => { - expect(buildReattachArgs('bmx-abcd1234')).toEqual([ - 'attach', 'bmx-abcd1234', '/bin/sh', '-c', 'exit 75', - ]); + it('waits through a transient stale socket when confirming a managed kill', () => { + const name = 'bmx-abcd1234'; + const sessionId = 'abcd1234-1111-2222-3333-444444444444'; + let killed = false; + let staleProbe = true; + execFileSyncMock.mockImplementation((_file, argv) => { + const [command, ...args] = argv as string[]; + if (command === 'list' && args[0] === '--short') { + if (killed && staleProbe) { + staleProbe = false; + throw new Error('stale socket'); + } + return killed ? '' : `${name}\n`; + } + if (command === 'list') { + return killed ? '' : ` name=${name}\tpid=4242\tclients=0\tcmd=codex\n`; + } + if (command === 'get' && args[1] === 'botmux.transport') return 'tail-send-v1\n'; + if (command === 'get' && args[1] === 'botmux.session') return `${sessionId}\n`; + if (command === 'kill') { + killed = true; + return `killed session ${name}\n`; + } + throw new Error(`unexpected zmx command: ${argv.join(' ')}`); + }); + + expect(() => ZmxBackend.killManagedSession(name, sessionId, 4242)).not.toThrow(); + expect(killed).toBe(true); + expect(staleProbe).toBe(false); + }); + + it('fails closed when a managed session is replaced during kill confirmation', () => { + const name = 'bmx-abcd1234'; + const sessionId = 'abcd1234-1111-2222-3333-444444444444'; + let killed = false; + execFileSyncMock.mockImplementation((_file, argv) => { + const [command, ...args] = argv as string[]; + if (command === 'list' && args[0] === '--short') return `${name}\n`; + if (command === 'list') { + const pid = killed ? 5252 : 4242; + return ` name=${name}\tpid=${pid}\tclients=0\tcmd=codex\n`; + } + if (command === 'get' && args[1] === 'botmux.transport') return 'tail-send-v1\n'; + if (command === 'get' && args[1] === 'botmux.session') { + return killed ? 'abcd1234-9999-8888-7777-666666666666\n' : `${sessionId}\n`; + } + if (command === 'kill') { + killed = true; + return ''; + } + throw new Error(`unexpected zmx command: ${argv.join(' ')}`); + }); + + expect(() => ZmxBackend.killManagedSession(name, sessionId, 4242)).toThrow(/同名会话替换/); + expect(killed).toBe(true); + }); + it('builds a private file gate and strips nested-session identity', () => { const opts = { cwd: '/tmp/work', cols: 80, @@ -234,8 +293,9 @@ describe('zmx backend pure helpers', () => { }; const bootstrapPath = '/tmp/private/bootstrap.sh'; const payloadPath = '/tmp/private/payload.sh'; - const readyMarker = '\x1b]5150;botmux-zmx-ready=0123456789abcdef0123456789abcdef\x1b\\'; - const completionMarker = '\x1b]5150;botmux-zmx-started=0123456789abcdef0123456789abcdef\x1b\\'; + const readyPath = '/tmp/private/ready'; + const releasePath = '/tmp/private/release'; + const readyNonce = '0123456789abcdef0123456789abcdef'; const releaseToken = 'fedcba9876543210fedcba9876543210'; const argv = buildFreshAttachArgs('bmx-abcd1234', bootstrapPath); const files = buildZmxLaunchFiles( @@ -243,8 +303,9 @@ describe('zmx backend pure helpers', () => { ['--flag', 'private prompt'], opts, payloadPath, - readyMarker, - completionMarker, + readyPath, + readyNonce, + releasePath, releaseToken, ); @@ -254,12 +315,17 @@ describe('zmx backend pure helpers', () => { expect(files.bootstrap).not.toContain('session-secret'); expect(files.bootstrap).not.toContain("yes ' quoted"); expect(files.bootstrap).toContain(payloadPath); - expect(files.bootstrap).toContain(`printf '%s' '${readyMarker}'`); - expect(files.bootstrap).toContain(`printf '%s' '${completionMarker}'`); - expect(files.bootstrap).toContain(`release_line" = '${releaseToken}'`); - expect(files.bootstrap).toContain('bootstrap-watchdog'); - expect(files.bootstrap).toContain('sleep 8'); - expect(files.bootstrap).toContain('stty -echo'); + expect(files.bootstrap).toContain(readyPath); + expect(files.bootstrap).toContain(releasePath); + expect(files.bootstrap).toContain(readyNonce); + expect(files.bootstrap).toContain(releaseToken); + expect(files.bootstrap).toContain('exec </dev/tty'); + expect(files.bootstrap).toContain('cli_pid_path='); + expect(files.bootstrap).toContain('"$$" > "$cli_pid_path"'); + expect(files.bootstrap).toContain('/bin/sh -c '); + expect(files.bootstrap).toContain('\ncli_status=$?\nrm -f -- "$cli_pid_path"\nwhile ! sleep 3; do :; done\n'); + expect(files.bootstrap).not.toContain('botmux-zmx-ready='); + expect(files.bootstrap).not.toContain('stty'); expect(files.payload).toContain('private prompt'); expect(files.payload).toContain('BOTMUX_SESSION_ID=session-secret'); expect(files.payload).toContain("SAFE_FLAG=yes '"); diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index 417132ab1..1078fcc49 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -1,237 +1,554 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync, writeFileSync, writeSync } from 'node:fs'; + +const childMocks = vi.hoisted(() => { + class FakeStream { + private readonly listeners = new Map<string, Array<(value: any) => void>>(); + + on(event: string, cb: (value: any) => void): this { + const callbacks = this.listeners.get(event) ?? []; + callbacks.push(cb); + this.listeners.set(event, callbacks); + return this; + } + + emit(event: string, value: any): void { + for (const cb of this.listeners.get(event) ?? []) cb(value); + } + } -const fakePtys = vi.hoisted(() => [] as FakePty[]); - -class FakePty { - readonly writes: string[] = []; - spawnArgs: string[] = []; - killed = false; - private dataCb: ((data: string) => void) | undefined; - private exitCb: ((event: { exitCode: number; signal?: number }) => void) | undefined; - - write(data: string): void { this.writes.push(data); } - resize(): void {} - kill(): void { this.killed = true; } - onData(cb: (data: string) => void): void { this.dataCb = cb; } - onExit(cb: (event: { exitCode: number; signal?: number }) => void): void { this.exitCb = cb; } - emitData(data: string): void { this.dataCb?.(data); } - emitExit(exitCode: number, signal?: number): void { this.exitCb?.({ exitCode, signal }); } -} + class FakeChild { + readonly stdout = new FakeStream(); + readonly stderr = new FakeStream(); + killed = false; + onDisconnect: (() => void) | null = null; + private disconnected = false; + private readonly onceListeners = new Map<string, (a: any, b?: any) => void>(); + + constructor(readonly kind: 'tail' | 'history') {} + + once(event: string, cb: (a: any, b?: any) => void): this { + this.onceListeners.set(event, cb); + return this; + } + + kill(): boolean { + this.killed = true; + this.disconnect(); + return true; + } + + emitData(value: Buffer | string): void { + this.stdout.emit('data', value); + } + + emitClose(code: number | null = 0, signal: NodeJS.Signals | null = null): void { + this.disconnect(); + this.onceListeners.get('close')?.(code, signal); + } + + private disconnect(): void { + if (this.disconnected) return; + this.disconnected = true; + this.onDisconnect?.(); + } + } + + return { + FakeChild, + execFile: vi.fn(), + execFileSync: vi.fn(), + spawn: vi.fn(), + spawnSync: vi.fn(), + children: [] as FakeChild[], + }; +}); -vi.mock('node-pty', () => ({ - spawn: vi.fn((_file: string, args: string[]) => { - const child = new FakePty(); - child.spawnArgs = args; - fakePtys.push(child); - return child; - }), +const fsMocks = vi.hoisted(() => ({ + readFileSync: vi.fn(), + actualReadFileSync: null as null | ((...args: any[]) => any), })); vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal<typeof import('node:child_process')>(); - return { ...actual, execFileSync: vi.fn() }; + return { + ...actual, + execFile: childMocks.execFile, + execFileSync: childMocks.execFileSync, + spawn: childMocks.spawn, + spawnSync: childMocks.spawnSync, + }; +}); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs')>(); + fsMocks.actualReadFileSync = actual.readFileSync as (...args: any[]) => any; + return { + ...actual, + readFileSync: fsMocks.readFileSync, + }; }); -import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; -const execFileSyncMock = vi.mocked(execFileSync); +const SESSION = 'bmx-test0001'; +const SESSION_ID = 'test0001-1111-2222-3333-444444444444'; +const PRIVATE_VALUE = 'provider-secret'; + +interface FakeZmxState { + exists: boolean; + pid: number; + clients: number; + command: string; + transport: string; + sessionId: string; + launchPid: number; + history: string; + historyStderr: string; + sendInputs: Buffer[]; + failSendAt: number | null; + deferHistory: boolean; + readyPath: string | null; +} + +let state: FakeZmxState; +const backends: ZmxBackend[] = []; -function spawnBackend(): { backend: ZmxBackend; child: FakePty } { - execFileSyncMock.mockReturnValueOnce('' as never); // --short: no pre-existing session - execFileSyncMock.mockReturnValueOnce('' as never); // full list - const backend = new ZmxBackend('bmx-test0001'); +function zmxList(): string { + if (!state.exists) return ''; + return ` name=${SESSION}\tpid=${state.pid}\tclients=${state.clients}\tcmd=${state.command}\n`; +} + +function extractShellAssignment(script: string, name: string): string { + const match = script.match(new RegExp(`^${name}='([^']*)'$`, 'm')); + if (!match) throw new Error(`missing ${name} in bootstrap`); + return match[1]!; +} + +function makeBackend(opts: { reattach?: boolean } = {}): ZmxBackend { + const backend = new ZmxBackend(SESSION, { + ownsSession: true, + isReattach: opts.reattach ?? false, + sessionId: SESSION_ID, + }); + backends.push(backend); + return backend; +} + +function spawnBackend(backend = makeBackend()): ZmxBackend { backend.spawn('/bin/sh', ['-c', 'echo ready'], { cwd: '/tmp', cols: 80, rows: 24, - env: { PATH: '/bin' }, + env: { PATH: '/bin', BOTMUX_SESSION_ID: SESSION_ID }, + injectEnv: { PROVIDER_TEST_TOKEN: PRIVATE_VALUE }, }); - return { backend, child: fakePtys.at(-1)! }; + return backend; } -function launchMarkers(child: FakePty): { ready: string; completion: string; release: string } { - const bootstrapPath = child.spawnArgs.at(-1); - if (!bootstrapPath) throw new Error('missing ZMX bootstrap path'); - const bootstrap = readFileSync(bootstrapPath, 'utf8'); - const nonce = bootstrap.match(/botmux-zmx-ready=([0-9a-f]{32})/)?.[1]; - const release = bootstrap.match(/release_line" = '([0-9a-f]{32})'/)?.[1]; - if (!nonce || !bootstrap.includes(`botmux-zmx-started=${nonce}`)) { - throw new Error('missing ZMX launch markers'); - } - if (!release) throw new Error('missing ZMX private release token'); - const ready = `\x1b]5150;botmux-zmx-ready=${nonce}\x1b\\`; - const completion = `\x1b]5150;botmux-zmx-started=${nonce}\x1b\\`; - return { ready, completion, release }; +function tailChildren(): InstanceType<typeof childMocks.FakeChild>[] { + return childMocks.children.filter(child => child.kind === 'tail'); +} + +function historyChildren(): InstanceType<typeof childMocks.FakeChild>[] { + return childMocks.children.filter(child => child.kind === 'history'); +} + +async function settleAtCurrentTime(): Promise<void> { + // Labels are read through async execFile, then history through a child + // process. Drain both promise/microtask boundaries before advancing timers + // that their completion may have scheduled at the current fake time. + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); +} + +async function advanceAndSettle(ms: number): Promise<void> { + await vi.advanceTimersByTimeAsync(ms); + await settleAtCurrentTime(); } -describe('ZmxBackend recovery transport', () => { +describe('ZmxBackend history-authoritative transport', () => { beforeEach(() => { - vi.useFakeTimers(); - fakePtys.length = 0; - execFileSyncMock.mockReset(); + // Keep Date.now real: fresh-ready/tail handshakes use synchronous + // Atomics.wait polling. Freezing Date would turn a regression into a hung + // test instead of letting its real deadline expire. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + state = { + exists: false, + pid: process.ppid, + clients: 0, + command: '', + transport: '', + sessionId: '', + launchPid: process.pid, + history: '', + historyStderr: '', + sendInputs: [], + failSendAt: null, + deferHistory: false, + readyPath: null, + }; + backends.length = 0; + childMocks.children.length = 0; + childMocks.execFile.mockReset(); + childMocks.execFileSync.mockReset(); + childMocks.spawn.mockReset(); + childMocks.spawnSync.mockReset(); + fsMocks.readFileSync.mockReset(); + fsMocks.readFileSync.mockImplementation((...args: any[]) => fsMocks.actualReadFileSync!(...args)); + + childMocks.execFileSync.mockImplementation((_file: string, argv: string[], options?: any) => { + if (_file === '/usr/bin/ps') { + const pid = Number(argv.at(-1)); + return pid === state.launchPid ? `${state.pid}\n` : ''; + } + const [command, ...args] = argv; + if (command === 'list' && args[0] === '--short') return state.exists ? `${SESSION}\n` : ''; + if (command === 'list') return zmxList(); + if (command === 'get') { + if (args[1] === 'botmux.transport') return state.transport; + if (args[1] === 'botmux.session') return state.sessionId; + if (args[1] === 'botmux.launch_pid') return `${state.launchPid}\n`; + return `botmux.transport=${state.transport}\nbotmux.session=${state.sessionId}\nbotmux.launch_pid=${state.launchPid}\n`; + } + if (command === 'set') { + for (const assignment of args.slice(1)) { + const [key, value = ''] = assignment.split('=', 2); + if (key === 'botmux.transport') state.transport = value; + if (key === 'botmux.session') state.sessionId = value; + if (key === 'botmux.launch_pid') state.launchPid = Number(value); + } + return ''; + } + if (command === 'send') { + state.sendInputs.push(Buffer.from(options?.input ?? '')); + return state.failSendAt === state.sendInputs.length + ? `session ${SESSION} is unresponsive\n` + : ''; + } + if (command === 'kill') { + state.exists = false; + state.clients = 0; + return ''; + } + throw new Error(`unexpected zmx command: ${argv.join(' ')}`); + }); + + childMocks.execFile.mockImplementation((_file: string, argv: string[], _options: unknown, callback: Function) => { + const child = { kill: vi.fn() }; + const [command] = argv; + queueMicrotask(() => { + if (command !== 'get') { + callback(new Error(`unexpected async zmx command: ${argv.join(' ')}`), '', ''); + return; + } + callback( + null, + `botmux.transport=${state.transport}\nbotmux.session=${state.sessionId}\nbotmux.launch_pid=${state.launchPid}\n`, + '', + ); + }); + return child; + }); + + childMocks.spawnSync.mockImplementation((_file: string, argv: string[]) => { + const bootstrapPath = argv.at(-1)!; + const bootstrap = readFileSync(bootstrapPath, 'utf8'); + const readyPath = extractShellAssignment(bootstrap, 'ready_path'); + const cliPidPath = extractShellAssignment(bootstrap, 'cli_pid_path'); + const readyNonce = extractShellAssignment(bootstrap, 'ready_nonce'); + state.readyPath = readyPath; + state.exists = true; + state.command = `/bin/sh ${bootstrapPath}`; + writeFileSync(cliPidPath, `${state.launchPid}\n`, { mode: 0o600 }); + writeFileSync(readyPath, `${readyNonce}\n`, { mode: 0o600 }); + return { + pid: 99, + status: 0, + signal: null, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + } as any; + }); + + childMocks.spawn.mockImplementation((_file: string, argv: string[], options?: any) => { + const kind = argv[0] === 'tail' ? 'tail' : 'history'; + const child = new childMocks.FakeChild(kind); + childMocks.children.push(child); + if (kind === 'tail') { + state.clients += 1; + child.onDisconnect = () => { state.clients = Math.max(0, state.clients - 1); }; + } else { + const fd = options?.stdio?.[1]; + if (typeof fd !== 'number') throw new Error('history stdout must be a private file descriptor'); + writeSync(fd, Buffer.from(state.history, 'utf8')); + if (state.historyStderr) child.stderr.emit('data', state.historyStderr); + if (!state.deferHistory) queueMicrotask(() => child.emitClose(0, null)); + } + return child as any; + }); }); afterEach(() => { + for (const backend of backends) backend.kill(); vi.useRealTimers(); }); - it('keeps queued input FIFO and retries an inconclusive flush probe on a quiet attach', () => { - const { backend, child } = spawnBackend(); - const markers = launchMarkers(child); - backend.write('A'); - child.emitData(`ready:${markers.ready.slice(0, 19)}`); - expect(child.writes).toEqual([]); - child.emitData(`${markers.ready.slice(19)}${markers.completion}\n`); - backend.write('B'); - - // First post-attach probe is inconclusive, then the same silent attach is - // confirmed live. No second data frame should be required to release input. - execFileSyncMock.mockReturnValueOnce('' as never); - execFileSyncMock.mockReturnValueOnce(' name=bmx-test0001\terr=Timeout\n' as never); - execFileSyncMock.mockReturnValueOnce('bmx-test0001\n' as never); - execFileSyncMock.mockReturnValueOnce(' name=bmx-test0001\tpid=42\tclients=1\n' as never); - vi.advanceTimersByTime(150); - expect(child.writes).toEqual([`${markers.release}\r`]); - vi.advanceTimersByTime(300); - expect(child.writes).toEqual([`${markers.release}\r`, 'AB']); - backend.kill(); - vi.advanceTimersByTime(5 * 60_000); - }); - - it('strips the split fresh-ready marker before output and releases the bootstrap once', () => { - const { backend, child } = spawnBackend(); - const markers = launchMarkers(child); + it('waits through transient empty and mismatched fresh-ready reads', () => { + let readyReads = 0; + fsMocks.readFileSync.mockImplementation((path: unknown, ...args: any[]) => { + if (state.readyPath && String(path) === state.readyPath) { + readyReads += 1; + if (readyReads === 1) return ''; + if (readyReads === 2) return 'stale-ready-nonce\n'; + } + return fsMocks.actualReadFileSync!(path, ...args); + }); + + expect(() => spawnBackend()).not.toThrow(); + expect(readyReads).toBe(3); + }); + + it('uses tail only as a change signal and publishes Unicode from history', async () => { + state.history = '你好😀曛\n'; + const backend = spawnBackend(); const output: string[] = []; + const resyncs: string[] = []; backend.onData(data => output.push(data)); + backend.onScreenResync(snapshot => resyncs.push(snapshot)); - child.emitData(`zmx-prefix${markers.ready.slice(0, 11)}`); + const tail = tailChildren()[0]!; + tail.emitData('tail-bytes-that-must-never-reach-worker\n'); expect(output).toEqual([]); - expect(child.writes).toEqual([]); + expect(resyncs).toEqual([]); - child.emitData(`${markers.ready.slice(11)}${markers.completion.slice(0, 13)}`); - expect(child.writes).toEqual([`${markers.release}\r`]); + await settleAtCurrentTime(); expect(output).toEqual([]); + expect(resyncs).toEqual(['你好😀曛\r\n']); + expect(resyncs.join('')).not.toContain('tail-bytes'); + expect(backend.captureCurrentScreen()).toBe('你好😀曛\r\n'); + }); - child.emitData(`${markers.completion.slice(13)}cli-suffix`); - expect(output.join('')).toBe('zmx-prefixcli-suffix'); - expect(output.join('')).not.toContain(markers.ready); - expect(output.join('')).not.toContain(markers.completion); + it('safety-polls pure Chinese output even when tail emits no data', async () => { + const backend = spawnBackend(); + const output: string[] = []; + backend.onData(data => output.push(data)); + await settleAtCurrentTime(); + + state.history = '纯中文没有 tail 事件:你好曛😀\n'; + await advanceAndSettle(250); - child.emitData('later'); - expect(child.writes).toEqual([`${markers.release}\r`]); - expect(output.join('')).toBe('zmx-prefixcli-suffixlater'); - backend.kill(); - vi.advanceTimersByTime(5 * 60_000); + expect(output).toEqual(['纯中文没有 tail 事件:你好曛😀\r\n']); }); - it('quarantines until the unverified fresh session disappears, then exits once', () => { - const { backend, child } = spawnBackend(); + it('re-syncs an unchanged history snapshot when tail reports activity', async () => { + state.history = 'same screen\n'; + const backend = spawnBackend(); + const resyncs: string[] = []; + backend.onScreenResync(snapshot => resyncs.push(snapshot)); + await settleAtCurrentTime(); + + tailChildren()[0]!.emitData(Buffer.from([0xe6, 0x9b, 0x9b])); + await advanceAndSettle(50); + + expect(resyncs).toEqual(['same screen\r\n', 'same screen\r\n']); + }); + + it('emits only an authoritative prefix delta and re-syncs a rewritten history', async () => { + state.history = 'first\n'; + const backend = spawnBackend(); const output: string[] = []; - const exits: Array<[number | null, string | null]> = []; + const resyncs: string[] = []; backend.onData(data => output.push(data)); - backend.onExit((code, signal) => exits.push([code, signal])); + backend.onScreenResync(snapshot => resyncs.push(snapshot)); + await settleAtCurrentTime(); + + state.history = 'first\nsecond\n'; + tailChildren()[0]!.emitData('hint'); + await advanceAndSettle(50); + expect(output).toEqual(['second\r\n']); + expect(resyncs).toEqual(['first\r\n']); + + state.history = 'rewritten\n'; + tailChildren()[0]!.emitData('hint'); + await advanceAndSettle(50); + expect(resyncs).toEqual(['first\r\n', 'rewritten\r\n']); + }); - child.emitData('foreign session output'); - expect(child.writes).toEqual([]); - expect(output).toEqual([]); + it('coalesces tail triggers behind one in-flight history capture', async () => { + state.history = 'one\n'; + state.deferHistory = true; + const backend = spawnBackend(); + const output: string[] = []; + backend.onData(data => output.push(data)); + await settleAtCurrentTime(); + expect(historyChildren()).toHaveLength(1); + + for (let i = 0; i < 10; i += 1) tailChildren()[0]!.emitData('hint'); + expect(historyChildren()).toHaveLength(1); + + state.history = 'one\ntwo\n'; + state.deferHistory = false; + historyChildren()[0]!.emitClose(0, null); + await settleAtCurrentTime(); + // The ten triggers merge into exactly one follow-up capture, rather than + // spawning one `zmx history` process per tail chunk. The follow-up retains + // a short debounce so a large transcript cannot starve concurrent send. + expect(historyChildren()).toHaveLength(1); + await advanceAndSettle(50); + expect(historyChildren()).toHaveLength(2); + await settleAtCurrentTime(); + expect(output).toEqual(['two\r\n']); + }); - execFileSyncMock.mockReturnValueOnce('' as never); - execFileSyncMock.mockReturnValueOnce('' as never); - vi.advanceTimersByTime(5_000); - expect(exits).toEqual([]); - expect(child.killed).toBe(true); - expect(fakePtys).toHaveLength(1); + it('settleCurrentScreen waits for a capture that starts after the in-flight sample', async () => { + state.history = 'before\n'; + state.deferHistory = true; + const backend = spawnBackend(); + const output: string[] = []; + backend.onData(data => output.push(data)); + await settleAtCurrentTime(); + expect(historyChildren()).toHaveLength(1); + + const settled = backend.settleCurrentScreen(); + state.history = 'before\nfinal pure 中文曛😀\n'; + state.deferHistory = false; + historyChildren()[0]!.emitClose(0, null); + await settleAtCurrentTime(); + + expect(historyChildren()).toHaveLength(1); + await advanceAndSettle(50); + expect(historyChildren()).toHaveLength(2); + await expect(settled).resolves.toBe(true); + expect(output).toEqual(['final pure 中文曛😀\r\n']); + }); - vi.advanceTimersByTime(100); - expect(exits).toEqual([[75, null]]); + it('keeps settle pending when its own capture is dirtied and waits for the follow-up', async () => { + state.history = 'before\n'; + const backend = spawnBackend(); + const output: string[] = []; + backend.onData(data => output.push(data)); + await settleAtCurrentTime(); + expect(historyChildren()).toHaveLength(1); + + state.deferHistory = true; + const settled = backend.settleCurrentScreen(); + let didSettle = false; + void settled.then(() => { didSettle = true; }); + await settleAtCurrentTime(); + expect(historyChildren()).toHaveLength(2); + + // Output arrives after capture A started. The tail payload is only a wake + // signal, but it must latch a mandatory capture B before settle resolves. + state.history = 'before\nafter capture start 中文曛😀\n'; + tailChildren()[0]!.emitData('dirty'); + state.deferHistory = false; + historyChildren()[1]!.emitClose(0, null); + await settleAtCurrentTime(); + expect(didSettle).toBe(false); + + await advanceAndSettle(50); + expect(historyChildren()).toHaveLength(3); + await expect(settled).resolves.toBe(true); + expect(output).toEqual(['after capture start 中文曛😀\r\n']); + }); - child.emitExit(0, 0); - vi.advanceTimersByTime(10_000); - expect(fakePtys).toHaveLength(1); + it('rejects an ambiguous empty history without clearing cache or consuming resync obligations', async () => { + state.history = 'authoritative nonempty\n'; + const backend = spawnBackend(); + const resyncs: string[] = []; + backend.onScreenResync(snapshot => resyncs.push(snapshot)); + await settleAtCurrentTime(); + expect(backend.captureCurrentScreen()).toBe('authoritative nonempty\r\n'); + expect(resyncs).toEqual(['authoritative nonempty\r\n']); + + state.history = ''; + const rejected = backend.settleCurrentScreen(); + (backend as any).requestHistoryCapture(0, true, true); + await settleAtCurrentTime(); + + await expect(rejected).resolves.toBe(false); + expect(backend.captureCurrentScreen()).toBe('authoritative nonempty\r\n'); + expect((backend as any).tailActivitySinceCapture).toBe(true); + expect((backend as any).forceResyncOnNextSnapshot).toBe(true); + + state.history = 'authoritative nonempty\n'; + const recovered = backend.settleCurrentScreen(); + await settleAtCurrentTime(); + + await expect(recovered).resolves.toBe(true); + expect(resyncs).toEqual([ + 'authoritative nonempty\r\n', + 'authoritative nonempty\r\n', + ]); + expect((backend as any).tailActivitySinceCapture).toBe(false); + expect((backend as any).forceResyncOnNextSnapshot).toBe(false); }); - it('quarantines a crash-left bootstrap on reattach without forwarding output or input', () => { - const original = spawnBackend(); - const markers = launchMarkers(original.child); - original.backend.kill(); + it('returns false on a rejected single-frame send without emitting a generic compensation frame', () => { + const backend = spawnBackend(); - execFileSyncMock.mockReturnValueOnce('bmx-test0001\n' as never); - execFileSyncMock.mockReturnValueOnce( - ' name=bmx-test0001\tpid=42\tclients=0\tcmd=/bin/sh /tmp/botmux-zmx-launch-x/bootstrap.sh\n' as never, - ); - const reattached = new ZmxBackend('bmx-test0001', { isReattach: true }); - const output: string[] = []; - const exits: Array<[number | null, string | null]> = []; - reattached.onData(data => output.push(data)); - reattached.onExit((code, signal) => exits.push([code, signal])); - reattached.spawn('/bin/sh', ['-c', 'echo resumed'], { - cwd: '/tmp', - cols: 80, - rows: 24, - env: { PATH: '/bin' }, - }); - const child = fakePtys.at(-1)!; + state.failSendAt = 1; + expect(backend.sendText('short input')).toBe(false); + expect(state.sendInputs).toHaveLength(1); + expect(state.sendInputs[0]!.subarray(0, -1).toString()).toBe('short input'); - reattached.write('hello\r'); - child.emitData(`\x1b[2J${markers.ready.slice(0, 17)}`); - child.emitData(markers.ready.slice(17)); - expect(child.killed).toBe(true); - expect(child.writes).toEqual([]); - expect(output).toEqual([]); - expect(exits).toEqual([]); - - execFileSyncMock.mockReturnValueOnce('' as never); - execFileSyncMock.mockReturnValueOnce('' as never); - vi.advanceTimersByTime(100); - expect(exits).toEqual([[75, null]]); - expect(fakePtys).toHaveLength(2); - vi.advanceTimersByTime(5 * 60_000); - }); - - it('never kills an unverified same-name session when quarantine is destroyed', () => { - const original = spawnBackend(); - const markers = launchMarkers(original.child); - original.backend.kill(); - - execFileSyncMock.mockReturnValueOnce('bmx-test0001\n' as never); - execFileSyncMock.mockReturnValueOnce( - ' name=bmx-test0001\tpid=42\tclients=0\tcmd=/bin/sh /tmp/botmux-zmx-launch-x/bootstrap.sh\n' as never, - ); - const reattached = new ZmxBackend('bmx-test0001', { isReattach: true }); - reattached.spawn('/bin/sh', ['-c', 'echo resumed'], { - cwd: '/tmp', - cols: 80, - rows: 24, - env: { PATH: '/bin' }, - }); - const child = fakePtys.at(-1)!; - execFileSyncMock.mockClear(); - - child.emitData(markers.ready); - expect(child.killed).toBe(true); - reattached.destroySession(); - - expect(execFileSyncMock.mock.calls.some(([, args]) => - Array.isArray(args) && args[0] === 'kill', - )).toBe(false); - vi.advanceTimersByTime(5 * 60_000); - }); - - it('normalizes node-pty signal 0 to a normal null exit signal', () => { - const { backend, child } = spawnBackend(); - const markers = launchMarkers(child); - child.emitData(markers.ready); - child.emitData(markers.completion); - const exits: Array<[number | null, string | null]> = []; - backend.onExit((code, signal) => exits.push([code, signal])); - execFileSyncMock.mockReturnValueOnce('' as never); // --short: target is truly gone - execFileSyncMock.mockReturnValueOnce('' as never); // full list - - child.emitExit(0, 0); - vi.advanceTimersByTime(50); - expect(exits).toEqual([[0, null]]); - vi.advanceTimersByTime(5 * 60_000); + state.sendInputs.length = 0; + state.failSendAt = 1; + expect(backend.sendSpecialKeys('Enter')).toBe(false); + expect(state.sendInputs).toHaveLength(1); + expect(state.sendInputs[0]!.subarray(0, -1).toString()).toBe('\r'); + }); + + it('rejects input above 64 KiB before sending any prefix', () => { + const backend = spawnBackend(); + + expect(() => backend.sendText('x'.repeat((64 * 1024) + 1))).toThrow(/超过 65536 字节安全上限/); + expect(state.sendInputs).toEqual([]); + }); + + it('closes bracketed paste and cancels even when its first frame is rejected ambiguously', () => { + const backend = spawnBackend(); + state.failSendAt = 1; + + expect(() => backend.pasteText('short paste')).toThrow(/粘贴发送失败/); + expect(state.sendInputs).toHaveLength(2); + expect(state.sendInputs[0]!.subarray(0, 6).toString()).toBe('\x1b[200~'); + expect(state.sendInputs[1]!.toString()).toBe('\x1b[201~\x03\n'); + }); + + it('closes bracketed paste and cancels a partial multi-frame send', () => { + const backend = spawnBackend(); + state.failSendAt = 2; + + expect(() => backend.pasteText('x'.repeat(2_000))).toThrow(/粘贴发送失败/); + expect(state.sendInputs).toHaveLength(3); + expect(state.sendInputs[0]!.subarray(0, 6).toString()).toBe('\x1b[200~'); + expect(state.sendInputs[2]!.toString()).toBe('\x1b[201~\x03\n'); + }); + + it('serves captureCurrentScreen from the cache without spawning history', async () => { + state.history = 'cached 你好😀\n'; + const backend = spawnBackend(); + await settleAtCurrentTime(); + const capturesBefore = historyChildren().length; + + expect(backend.captureCurrentScreen()).toBe('cached 你好😀\r\n'); + expect(backend.captureViewport()).toBe('cached 你好😀\r\n'); + expect(historyChildren()).toHaveLength(capturesBefore); + }); + + it('preserves a same-name session whose complete UUID label belongs elsewhere', () => { + state.exists = true; + state.command = '/usr/bin/vim'; + state.transport = 'tail-send-v1'; + state.sessionId = 'test0001-9999-8888-7777-666666666666'; + const backend = makeBackend({ reattach: true }); + + expect(() => spawnBackend(backend)).toThrow(/另一个完整 botmux session/); + backend.destroySession(); + expect(state.exists).toBe(true); + expect(childMocks.execFileSync.mock.calls.some(([, argv]) => argv[0] === 'kill')).toBe(false); }); }); diff --git a/test/zmx-backend.e2e.ts b/test/zmx-backend.e2e.ts index 81d8dde2e..4d3fb7316 100644 --- a/test/zmx-backend.e2e.ts +++ b/test/zmx-backend.e2e.ts @@ -1,33 +1,28 @@ /** - * E2E smoke for ZmxBackend. + * Real ZMX smoke coverage for the tail/send/history transport. * - * Requires: zmx installed (skips if unavailable) - * Run: pnpm vitest run --project e2e test/zmx-backend.e2e.ts + * Requires zmx >= 0.7.1 with PR #202 semantics (ordinary contributors without ZMX are skipped): + * pnpm vitest run --project e2e test/zmx-backend.e2e.ts */ -import { describe, it, expect, afterEach } from 'vitest'; +import { createHash } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; const TEST_SESSION = `bmx-e2e-zmx-${process.pid}`; -const QUERY_SESSION = `bmx-e2e-da-${process.pid}`; const RECOVERY_SESSION = `bmx-e2e-recover-${process.pid}`; +const FRAMING_SESSION = `bmx-e2e-frame-${process.pid}`; +const KILL_SESSION = `bmx-e2e-kill-${process.pid}`; +const SESSION_ID = `e2e-${process.pid}-1111-2222-333333333333`; const PRIVATE_VALUE = `zmx-private-${process.pid}`; +const ZMX_AVAILABLE = ZmxBackend.isAvailable(); -function countOccurrences(haystack: string, needle: string): number { - return haystack.split(needle).length - 1; +if (process.env.BOTMUX_E2E_REQUIRE_ZMX === '1' && !ZMX_AVAILABLE) { + throw new Error( + 'BOTMUX_E2E_REQUIRE_ZMX=1, but a functional zmx >= 0.7.1 was not found in PATH', + ); } -function crashAttachClient(backend: ZmxBackend): void { - // The backing ZMX daemon and CLI are separate from this node-pty viewer. - // White-box the viewer only in this E2E so production does not need a - // crash-only API solely for exercising automatic transport recovery. - const attachClient = (backend as unknown as { - process: { kill(signal?: string): void } | null; - }).process; - expect(attachClient).not.toBeNull(); - attachClient!.kill('SIGKILL'); -} - -function waitFor(fn: () => boolean, timeoutMs = 5000, description = 'condition'): Promise<void> { +function waitFor(fn: () => boolean, timeoutMs = 7000, description = 'condition'): Promise<void> { const started = Date.now(); return new Promise((resolve, reject) => { const tick = () => { @@ -41,163 +36,258 @@ function waitFor(fn: () => boolean, timeoutMs = 5000, description = 'condition') }); } +function backendFor(session: string, isReattach = false): ZmxBackend { + return new ZmxBackend(session, { + ownsSession: true, + isReattach, + sessionId: SESSION_ID, + }); +} + +function observe(backend: ZmxBackend): { readonly screen: string } { + let screen = ''; + backend.onData(data => { screen += data; }); + backend.onScreenResync(snapshot => { screen = snapshot; }); + return { + get screen() { return screen; }, + }; +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return !!err && typeof err === 'object' && 'code' in err && err.code === 'EPERM'; + } +} + describe('ZmxBackend e2e', () => { afterEach(() => { ZmxBackend.killSession(TEST_SESSION); - ZmxBackend.killSession(QUERY_SESSION); ZmxBackend.killSession(RECOVERY_SESSION); + ZmxBackend.killSession(FRAMING_SESSION); + ZmxBackend.killSession(KILL_SESSION); }); - it.skipIf(!ZmxBackend.isAvailable())('preserves first output, real size, resize, detach, and reattach', async () => { - let output = ''; - const backend = new ZmxBackend(TEST_SESSION); - backend.spawn('sh', ['-lc', [ - "printf '\\033[31mBOOT\\033[0m\\n'", - "printf 'TERM=%s\\n' \"$TERM\"", - "printf 'ZMX_SESSION=%s\\n' \"${ZMX_SESSION-unset}\"", - "printf 'ZMX_SESSION_PREFIX=%s\\n' \"${ZMX_SESSION_PREFIX-unset}\"", - "printf 'PRIVATE=%s\\n' \"${PROVIDER_TEST_TOKEN-unset}\"", - 'stty size', - "trap \"printf 'WINCH:'; stty size\" WINCH", - // A WINCH may interrupt a shell builtin read on some platforms. Keep - // waiting so this fixture tests the backend's resize semantics instead - // of the shell's EINTR policy. - 'while :; do IFS= read -r line || continue; echo "GOT:$line"; [ "$line" = done ] && exit 0; done', - ].join('; ')], { - cwd: process.cwd(), - cols: 80, - rows: 24, - env: process.env as Record<string, string>, - injectEnv: { PROVIDER_TEST_TOKEN: PRIVATE_VALUE }, - }); - backend.onData(d => { output += d; }); - - await waitFor(() => output.includes('BOOT'), 5000, 'initial BOOT output'); - expect(output).toContain('\x1b[31mBOOT\x1b[0m'); - expect(output).toContain('TERM=xterm-256color'); - expect(output).toContain('ZMX_SESSION=unset'); - expect(output).toContain('ZMX_SESSION_PREFIX=unset'); - expect(output).toContain(`PRIVATE=${PRIVATE_VALUE}`); - const retainedCommand = ZmxBackend.listDetails(); - expect(retainedCommand).not.toContain(PRIVATE_VALUE); - expect(retainedCommand).not.toContain("printf 'PRIVATE="); - await waitFor(() => /24\s+80/.test(output), 5000, 'initial 80x24 size'); - expect(output).toMatch(/24\s+80/); - - backend.resize(101, 33); - await waitFor(() => /WINCH:\s*33\s+101/.test(output), 5000, 'resize to 101x33'); - - backend.sendText('hello\r'); - await waitFor(() => output.includes('GOT:hello'), 5000, 'initial hello input'); - const cliPid = backend.getChildPid(); - expect(cliPid).toEqual(expect.any(Number)); - - backend.kill(); - expect(ZmxBackend.hasSession(TEST_SESSION)).toBe(true); - - let reattachOutput = ''; - let exit: { code: number | null; signal: string | null } | undefined; - const reattached = new ZmxBackend(TEST_SESSION, { isReattach: true }); - reattached.spawn('sh', ['-lc', 'echo should-not-run'], { - cwd: process.cwd(), - cols: 80, - rows: 24, - env: process.env as Record<string, string>, - }); - reattached.onData(d => { reattachOutput += d; }); - reattached.onExit((code, signal) => { exit = { code, signal }; }); - await waitFor(() => reattachOutput.includes('GOT:hello'), 5000, 'warm snapshot'); - expect(reattached.getChildPid()).toBe(cliPid); - await waitFor(() => /WINCH:\s*24\s+80/.test(reattachOutput), 5000, 'reattach resize to 80x24'); - reattached.sendText('done\r'); - - try { - await waitFor(() => reattachOutput.includes('GOT:done'), 5000, 'reattached done input'); - } catch (err) { - throw new Error( - `${err instanceof Error ? err.message : String(err)}; ` + - `session=${ZmxBackend.probeSession(TEST_SESSION)}; output=${JSON.stringify(reattachOutput.slice(-500))}`, + it.skipIf(!ZMX_AVAILABLE)( + 'creates with a gated one-shot client, streams plain text, detaches, and reattaches from history', + async () => { + const backend = backendFor(TEST_SESSION); + backend.spawn('sh', ['-lc', [ + "printf '\\033[31mBOOT\\033[0m\\n'", + "printf 'UTF8=你好😀曛\\n'", + "printf 'ZMX_SESSION=%s\\n' \"${ZMX_SESSION-unset}\"", + "printf 'ZMX_SESSION_PREFIX=%s\\n' \"${ZMX_SESSION_PREFIX-unset}\"", + "printf 'PRIVATE=%s\\n' \"${PROVIDER_TEST_TOKEN-unset}\"", + 'stty size', + 'while IFS= read -r line; do', + ' echo "GOT:$line"', + ' [ "$line" = done ] && { sleep 0.2; printf "FINAL=最终纯中文曛😀"; exit 0; }', + 'done', + ].join('\n')], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + injectEnv: { PROVIDER_TEST_TOKEN: PRIVATE_VALUE }, + }); + const observed = observe(backend); + + await waitFor(() => observed.screen.includes('BOOT'), 7000, 'initial plain BOOT output'); + await waitFor(() => /24\s+120/.test(observed.screen), 7000, 'headless terminal size'); + expect(observed.screen).not.toContain('\x1b'); + expect(observed.screen).toContain('UTF8=你好😀曛'); + expect(observed.screen).toContain('ZMX_SESSION=unset'); + expect(observed.screen).toContain('ZMX_SESSION_PREFIX=unset'); + expect(observed.screen).toContain(`PRIVATE=${PRIVATE_VALUE}`); + // No terminal leader means ZMX keeps its documented headless default. + expect(observed.screen).toMatch(/24\s+120/); + const retainedCommand = ZmxBackend.listDetails(); + expect(retainedCommand).not.toContain(PRIVATE_VALUE); + expect(retainedCommand).not.toContain("printf 'PRIVATE="); + + expect(backend.sendText('hello\r')).toBe(true); + await waitFor(() => observed.screen.includes('GOT:hello'), 7000, 'first send'); + const cliPid = backend.getChildPid(); + expect(cliPid).toEqual(expect.any(Number)); + + backend.resize(101, 33); + backend.kill(); + expect(ZmxBackend.hasSession(TEST_SESSION)).toBe(true); + + const exits: Array<[number | null, string | null]> = []; + const reattached = backendFor(TEST_SESSION, true); + reattached.spawn('sh', ['-lc', 'echo should-not-run'], { + cwd: process.cwd(), + cols: 101, + rows: 33, + env: process.env as Record<string, string>, + }); + const reattachedObserved = observe(reattached); + reattached.onExit((code, signal) => exits.push([code, signal])); + + await waitFor( + () => reattachedObserved.screen.includes('GOT:hello'), + 7000, + 'reattach history snapshot', ); - } - // The warm attach snapshot must not be followed by a second live replay of - // the same line (the old history + tail transport duplicated this case). - expect(countOccurrences(reattachOutput, 'GOT:hello')).toBe(1); - await waitFor(() => !!exit, 5000, 'normal session exit'); - expect(exit?.code).toBe(0); - expect(ZmxBackend.hasSession(TEST_SESSION)).toBe(false); - }); + const snapshot = reattachedObserved.screen; + expect(snapshot).toContain('BOOT'); + expect(snapshot).toContain('UTF8=你好😀曛'); + expect(snapshot).toContain('GOT:hello'); + expect(snapshot).not.toContain('should-not-run'); + expect(snapshot).not.toContain('\x1b'); + expect(reattached.getChildPid()).toBe(cliPid); - it.skipIf(!ZmxBackend.isAvailable())('recovers a crashed attach client without losing or duplicating input', async () => { - let output = ''; - const exits: Array<{ code: number | null; signal: string | null }> = []; - const backend = new ZmxBackend(RECOVERY_SESSION); - backend.spawn('sh', ['-lc', [ - "printf 'READY\\n'", - 'while IFS= read -r line; do echo "GOT:$line"; [ "$line" = done ] && exit 0; done', - ].join('; ')], { - cwd: process.cwd(), - cols: 80, - rows: 24, - env: process.env as Record<string, string>, - }); - backend.onData(data => { output += data; }); - backend.onExit((code, signal) => { exits.push({ code, signal }); }); - - await waitFor(() => output.includes('READY')); - const cliPid = backend.getChildPid(); - expect(cliPid).toEqual(expect.any(Number)); - - crashAttachClient(backend); - await waitFor(() => (backend as unknown as { state: string }).state === 'recovering'); - expect(ZmxBackend.hasSession(RECOVERY_SESSION)).toBe(true); - expect(backend.getChildPid()).toBe(cliPid); - expect(exits).toHaveLength(0); - - // This write occurs while there is no attach client. Recovery must retain - // it, deliver it to the original CLI once, and never surface viewer death - // as a session exit. - backend.sendText('buffered-during-recovery\r'); - await waitFor(() => output.includes('GOT:buffered-during-recovery')); - expect(countOccurrences(output, 'GOT:buffered-during-recovery')).toBe(1); - expect(exits).toHaveLength(0); - expect(ZmxBackend.hasSession(RECOVERY_SESSION)).toBe(true); - expect(backend.getChildPid()).toBe(cliPid); - - backend.sendText('done\r'); - await waitFor(() => output.includes('GOT:done')); - await waitFor(() => exits.length === 1); - await waitFor(() => !ZmxBackend.hasSession(RECOVERY_SESSION)); - expect(exits).toHaveLength(1); - expect(exits[0]?.code).toBe(0); - }); + expect(reattached.sendText('done\r')).toBe(true); + await waitFor( + () => reattachedObserved.screen.includes('GOT:done'), + 7000, + 'reattached send', + ); + await waitFor(() => exits.length === 1, 7000, 'normal session exit'); + await waitFor(() => !ZmxBackend.hasSession(TEST_SESSION), 7000, 'session cleanup'); + expect(exits).toHaveLength(1); + expect(reattachedObserved.screen).toContain('FINAL=最终纯中文曛😀'); + }, + ); - it.skipIf(!ZmxBackend.isAvailable())('answers terminal DA, cursor, and color queries without a browser', async () => { - let output = ''; - let exited = false; - const backend = new ZmxBackend(QUERY_SESSION); - backend.spawn(process.execPath, ['-e', [ - 'process.stdin.setRawMode(true)', - 'process.stdin.resume()', - "const timer=setTimeout(()=>{console.error('QUERY_TIMEOUT');process.exit(2)},2000)", - 'let phase=0', - "process.stdin.on('data',d=>{if(phase===0){console.log('CPR='+d.toString('hex'));phase=1;process.stdout.write('\\x1b[c')}else if(phase===1){console.log('DA='+d.toString('hex'));phase=2;process.stdout.write('\\x1b]10;?\\x1b\\\\')}else{clearTimeout(timer);console.log('COLOR='+d.toString('hex'));process.exit(0)}})", - "process.stdout.write('abc\\x1b[6n')", - ].join(';')], { - cwd: process.cwd(), - cols: 80, - rows: 24, - env: process.env as Record<string, string>, - }); - backend.onData(data => { output += data; }); - backend.onExit(() => { exited = true; }); - - await waitFor(() => output.includes('CPR=') && output.includes('DA=') && output.includes('COLOR=')); - await waitFor(() => exited); - // The private fresh-release token is entered with echo disabled, so the - // stateful responder sees "abc" at row 1, column 4 with no bootstrap line. - expect(output).toContain('CPR=1b5b313b3452'); // ESC [ 1 ; 4 R - expect(output).toMatch(/DA=1b5b(?:3f313b3263|3f36323b323263)/); - expect(output).toContain('COLOR=1b5d31303b7267623a613961392f623162312f643664361b5c'); - expect(output).not.toContain('QUERY_TIMEOUT'); - }); + it.skipIf(!ZMX_AVAILABLE)( + 'keeps send independent from tail and observes pure Unicode through the safety poll', + async () => { + const exits: Array<[number | null, string | null]> = []; + const backend = backendFor(RECOVERY_SESSION); + backend.spawn('sh', ['-lc', [ + "printf 'READY\\n'", + "trap 'printf \"INTERRUPTED\\n\"' INT", + 'while :; do', + ' line=', + ' IFS= read -r line || continue', + ' [ "$line" = unicode ] && { printf "纯中文曛😀"; continue; }', + ' echo "GOT:$line"', + ' [ "$line" = done ] && exit 0', + 'done', + ].join('\n')], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + }); + const observed = observe(backend); + backend.onExit((code, signal) => exits.push([code, signal])); + + await waitFor(() => observed.screen.includes('READY'), 7000, 'READY'); + // Upstream tail currently emits no bytes for this all-non-ASCII burst. + // The cold safety poll must still make it visible from authoritative history. + expect(backend.sendText('unicode\r')).toBe(true); + await waitFor( + () => observed.screen.includes('纯中文曛😀'), + 7000, + 'pure-Unicode history safety poll', + ); + expect(backend.sendSpecialKeys('C-c')).toBe(true); + await waitFor(() => observed.screen.includes('INTERRUPTED'), 7000, 'Ctrl-C delivery'); + expect(ZmxBackend.hasSession(RECOVERY_SESSION)).toBe(true); + expect(backend.sendText('after-interrupt\r')).toBe(true); + await waitFor( + () => observed.screen.includes('GOT:after-interrupt'), + 7000, + 'input after handled Ctrl-C', + ); + const cliPid = backend.getChildPid(); + expect(cliPid).toEqual(expect.any(Number)); + const firstTail = (backend as unknown as { tailProcess: { kill(signal?: string): void } | null }) + .tailProcess; + expect(firstTail).not.toBeNull(); + firstTail!.kill('SIGKILL'); + await waitFor( + () => (backend as unknown as { state: string }).state === 'recovering', + 7000, + 'tail recovery state', + ); + + // Input does not depend on the observer and is never replayed on an + // ambiguous failure. History proves the original CLI consumed it once. + expect(backend.sendText('while-offline\r')).toBe(true); + await waitFor( + () => (backend as unknown as { state: string }).state === 'observing', + 7000, + 'replacement tail', + ); + await waitFor( + () => observed.screen.includes('GOT:while-offline'), + 7000, + 'worker-facing history rebase after offline send', + ); + expect(backend.getChildPid()).toBe(cliPid); + expect(exits).toEqual([]); + + expect(backend.sendText('done\r')).toBe(true); + await waitFor(() => observed.screen.includes('GOT:done'), 7000, 'post-recovery output'); + await waitFor(() => exits.length === 1, 7000, 'normal exit after recovery'); + expect(exits).toHaveLength(1); + }, + ); + + it.skipIf(!ZMX_AVAILABLE)( + 'preserves large, Unicode, control, and trailing-LF input across ordered send chunks', + async () => { + const payload = `${'-leading\n'.repeat(700)}你好😀曛\x00\x03\n`; + const expected = Buffer.from(payload, 'utf8'); + const expectedHash = createHash('sha256').update(expected).digest('hex'); + const backend = backendFor(FRAMING_SESSION); + backend.spawn(process.execPath, ['-e', [ + 'process.stdin.setRawMode?.(true)', + 'process.stdin.resume()', + `const expected=${expected.length}`, + "const chunks=[];let length=0", + "process.stdin.on('data',chunk=>{chunks.push(chunk);length+=chunk.length;if(length>=expected){const b=Buffer.concat(chunks);const out='LEN='+b.length+'\\nHASH='+require('node:crypto').createHash('sha256').update(b).digest('hex')+'\\n';process.stdout.write(out,()=>process.exit(b.length===expected?0:3))}})", + "console.log('READY')", + ].join(';')], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + }); + const observed = observe(backend); + + await waitFor(() => observed.screen.includes('READY'), 7000, 'raw reader READY'); + expect(backend.sendText(payload)).toBe(true); + try { + await waitFor(() => observed.screen.includes('HASH='), 7000, 'input digest'); + } catch (err) { + throw new Error( + `${err instanceof Error ? err.message : String(err)}; ` + + `probe=${ZmxBackend.probeSession(FRAMING_SESSION)}; ` + + `observed=${JSON.stringify(observed.screen.slice(-1000))}; ` + + `history=${JSON.stringify(backend.captureCurrentScreen().slice(-1000))}`, + ); + } + expect(observed.screen).toContain(`LEN=${expected.length}`); + expect(observed.screen).toContain(`HASH=${expectedHash}`); + }, + ); + + it.skipIf(!ZMX_AVAILABLE)( + 'kills the ZMX root and its foreground CLI without leaving an orphan', + async () => { + const backend = backendFor(KILL_SESSION); + backend.spawn(process.execPath, ['-e', "process.on('SIGHUP',()=>{});console.log('READY');setInterval(()=>{},1000)"], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + }); + const observed = observe(backend); + await waitFor(() => observed.screen.includes('READY'), 7000, 'kill fixture READY'); + const cliPid = backend.getChildPid(); + expect(cliPid).toEqual(expect.any(Number)); + expect(processIsAlive(cliPid!)).toBe(true); + + backend.destroySession(); + await waitFor(() => !ZmxBackend.hasSession(KILL_SESSION), 7000, 'forced session removal'); + await waitFor(() => !processIsAlive(cliPid!), 7000, 'foreground CLI exit'); + }, + ); }); From 937ac2c63fc35fef2d714b1ad89f9841fb413c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Thu, 23 Jul 2026 08:19:20 +0800 Subject: [PATCH 04/26] =?UTF-8?q?fix(worker):=20ZMX=20=E5=AE=9A=E7=A8=BF?= =?UTF-8?q?=E5=89=8D=E5=90=8C=E6=AD=A5=E5=BF=99=E7=A2=8C=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E5=B1=8F=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worker.ts | 30 ++++++++++++++++--- test/worker-pipe-initial-screen-order.test.ts | 19 ++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index f774fb499..a8a6e6a18 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6512,15 +6512,37 @@ function busyProbeRegion(content: string): string { function probeBusyPatternIdle( source: string, - be: Pick<SessionBackend, 'captureCurrentScreen' | 'captureViewport'>, + be: SessionBackend, ): boolean { try { const content = captureBackendScreen(be); if (!content) return false; if (cliAdapter?.busyPattern) { if (cliAdapter.busyPattern.test(busyProbeRegion(content))) return false; - log(`${source} idle probe: busy marker absent, marking prompt ready`); - markPromptReady(); + if (!be.settleCurrentScreen) { + log(`${source} idle probe: busy marker absent, marking prompt ready`); + markPromptReady(); + return true; + } + + // A busy-marker probe reads the backend's cached screen. ZMX refreshes + // that cache from `history`, so absence in the current sample is not yet + // an authoritative turn boundary. Reuse the same revision-keyed settle + // fence as IdleDetector before publishing prompt_ready; otherwise this + // fallback can finalize a whole turn from a pre-tail/poll snapshot. + const revisionBeforeSettle = backendScreenRevision; + log(`${source} idle probe: busy marker absent, settling authoritative screen before prompt ready`); + void settleBackendScreenBeforeIdle(be, revisionBeforeSettle).then((settle) => { + if (!settle.proceed || backend !== be || isPromptReady) return; + if (backendScreenRevision !== revisionBeforeSettle) { + log(`${source} idle probe: authoritative screen changed during settle; deferring completion`); + return; + } + if (settle.degraded) { + log(`${source} idle probe: screen settle degraded; finalizing from the last successful snapshot`); + } + markPromptReady(); + }); return true; } } catch (err: any) { @@ -6529,7 +6551,7 @@ function probeBusyPatternIdle( return false; } -function scheduleReattachIdleProbe(source: string, be: Pick<SessionBackend, 'captureCurrentScreen' | 'captureViewport'>): void { +function scheduleReattachIdleProbe(source: string, be: SessionBackend): void { stopReattachIdleProbe(); if (!cliAdapter?.busyPattern || (!be.captureCurrentScreen && !be.captureViewport)) return; reattachIdleProbeTimer = setTimeout(() => { diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index c8566d47e..a62f10108 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -252,6 +252,25 @@ describe('worker pipe initial screen ordering', () => { expect(probe).not.toContain('cliAdapter.busyPattern.test(content)'); }); + it('settles an authoritative screen before a busy-pattern probe marks the prompt ready', () => { + const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + const probeStart = source.indexOf('function probeBusyPatternIdle'); + const probeEnd = source.indexOf('function scheduleReattachIdleProbe', probeStart); + const probe = source.slice(probeStart, probeEnd); + + const revisionIdx = probe.indexOf('const revisionBeforeSettle = backendScreenRevision;'); + const settleIdx = probe.indexOf('settleBackendScreenBeforeIdle(be, revisionBeforeSettle)'); + const backendFenceIdx = probe.indexOf('backend !== be', settleIdx); + const revisionFenceIdx = probe.indexOf('backendScreenRevision !== revisionBeforeSettle', settleIdx); + const markIdx = probe.indexOf('markPromptReady();', settleIdx); + + expect(revisionIdx).toBeGreaterThan(-1); + expect(settleIdx).toBeGreaterThan(revisionIdx); + expect(backendFenceIdx).toBeGreaterThan(settleIdx); + expect(revisionFenceIdx).toBeGreaterThan(backendFenceIdx); + expect(markIdx).toBeGreaterThan(revisionFenceIdx); + }); + it('limits the reattach idle probe to adapters with a busy marker', () => { const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); const helperStart = source.indexOf('function scheduleReattachIdleProbe'); From e2a67f6fbbcebc505017bacc236aa064ab6e88cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Thu, 23 Jul 2026 08:32:47 +0800 Subject: [PATCH 05/26] =?UTF-8?q?docs(zmx):=20=E8=A1=A5=E5=85=85=20Dashboa?= =?UTF-8?q?rd=20=E5=90=8E=E7=AB=AF=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/assets/pr-458/zmx-backend-dashboard.jpg | Bin 0 -> 70057 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/pr-458/zmx-backend-dashboard.jpg diff --git a/docs/assets/pr-458/zmx-backend-dashboard.jpg b/docs/assets/pr-458/zmx-backend-dashboard.jpg new file mode 100644 index 0000000000000000000000000000000000000000..51b17fe3f445632fc270eb9dc2e059289f11efdd GIT binary patch literal 70057 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 literal 0 HcmV?d00001 From 6ee03c59e30c31b88159836ba1cd3a2b2db0b74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Sat, 25 Jul 2026 03:24:39 +0800 Subject: [PATCH 06/26] =?UTF-8?q?fix(rebase):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=98=E5=9F=BA=E5=90=8E=E5=A4=B1=E6=95=88=E7=9A=84=20worker?= =?UTF-8?q?=20=E6=BA=90=E7=A0=81=E6=89=AB=E6=8F=8F=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upstream/master 把 `idleDetector.onIdle(async () =>` 改成了 `async (evidenceSource) =>`,导致 worker-pipe-initial-screen-order 的 源码切片锚点匹配不到、slice 出空串,断言随之失败。 锚点改为不绑定参数列表的正则,并对 idleStart/idleEnd 显式断言, 避免以后再出现「锚点失效 → 静默切出空串」这种假绿/假红。 Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso --- test/worker-pipe-initial-screen-order.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index a62f10108..15d93071d 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -201,8 +201,13 @@ describe('worker pipe initial screen ordering', () => { const settleStart = source.indexOf('function settleBackendScreenBeforeIdle'); const settleEnd = source.indexOf('/** Submission writes', settleStart); const settle = source.slice(settleStart, settleEnd); - const idleStart = source.indexOf('idleDetector.onIdle(async () =>'); + // Anchor on the async onIdle handler without pinning its parameter list — + // upstream has already renamed/extended it once (`(evidenceSource)`), and a + // stale anchor silently slices an empty string instead of failing loudly. + const idleStart = source.search(/idleDetector\.onIdle\(async \(/); + expect(idleStart).toBeGreaterThan(-1); const idleEnd = source.indexOf('backend.onData(onPtyData);', idleStart); + expect(idleEnd).toBeGreaterThan(idleStart); const idle = source.slice(idleStart, idleEnd); expect(settle).toContain('existing.revision >= requestedRevision'); From bdd246146c761f04ce91027e34cf78699ba16c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Sat, 25 Jul 2026 03:25:01 +0800 Subject: [PATCH 07/26] =?UTF-8?q?fix(zmx):=20=E7=89=88=E6=9C=AC=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E4=B8=8B=E8=B0=83=E5=88=B0=E5=B7=B2=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=9A=84=200.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zmx 上游已在 2026-07-23 发布 v0.7.0,其中包含 commit 8ba312d7 "fix(send): preserve client leadership"(issue #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 #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 --- docs-site/docs/en/zmx.md | 13 +++++++------ docs-site/docs/zh/zmx.md | 13 +++++++------ .../backend/session-backend-selector.ts | 2 +- src/adapters/backend/zmx-backend.ts | 8 +++++--- src/cli.ts | 2 +- src/dashboard/web/i18n.ts | 4 ++-- src/services/backend-availability.ts | 2 +- src/services/bot-config-store.ts | 2 +- src/setup/ensure-zmx.ts | 19 ++++++++++++++++--- test/backend-availability.test.ts | 4 ++-- test/backend-gate.test.ts | 11 +++++++---- test/zmx-backend-helpers.test.ts | 18 +++++++++++------- test/zmx-backend.e2e.ts | 4 ++-- 13 files changed, 63 insertions(+), 39 deletions(-) diff --git a/docs-site/docs/en/zmx.md b/docs-site/docs/en/zmx.md index be75682fb..0fa053550 100644 --- a/docs-site/docs/en/zmx.md +++ b/docs-site/docs/en/zmx.md @@ -6,14 +6,15 @@ ZMX is an **explicit opt-in** backend. botmux does not install ZMX, and merely f ## Install and probe -botmux requires **zmx >= 0.7.1**. This version floor is a **prerequisite assumption**: the integration treats 0.7.1 as the first release assumed to contain the behavior specified by upstream [issue #201](https://github.com/neurosnap/zmx/issues/201) and [PR #202](https://github.com/neurosnap/zmx/pull/202), where `send` only queues input without claiming the leader or rewriting terminal size; the installed 0.7.1+ build must actually contain that fix. ZMX officially supports macOS and Linux. - -> **⚠️ Current release prerequisite (as of 2026-07-23)**: ZMX's latest official release and Homebrew tap are still at 0.6.0, so running the `brew install` command below currently installs a version that **does not satisfy** this integration. Wait for an official **>= 0.7.1** build that actually contains PR #202 before enabling ZMX; do not bypass the gate by spoofing a version. The installation commands below describe the target flow after that release is available. +botmux requires **zmx >= 0.7.0**. That floor is the fix for upstream [issue #201](https://github.com/neurosnap/zmx/issues/201) — commit [`8ba312d7` *fix(send): preserve client leadership*](https://github.com/neurosnap/zmx/commit/8ba312d7), shipped in [v0.7.0](https://github.com/neurosnap/zmx/releases/tag/v0.7.0) (2026-07-23): `zmx send` now uses a dedicated `.Send` IPC tag that only queues input into the PTY buffer, without claiming the leader or rewriting terminal size. ZMX officially supports macOS and Linux. ```bash -# Homebrew (macOS / Linuxbrew; wait for a >= 0.7.1 tap release containing PR #202) +# Homebrew (macOS / Linuxbrew) brew install neurosnap/tap/zmx +# or mise +mise use -g github:neurosnap/zmx@latest + # Verify the daemon user's PATH and control plane zmx version zmx list @@ -23,7 +24,7 @@ On other hosts, download the prebuilt binary for your architecture from the [off Before creating a new ZMX-backed session, botmux checks the executable, version, and `zmx list` control plane. Any failure **fails closed** with an actionable session error; botmux never silently falls back to PTY. -> **⚠️ Upgrading from 0.6**: replacing the `zmx` binary on disk does not replace per-session daemons that are already running; after upgrading to a 0.7.1+ build containing PR #202, manually stop and recreate every session launched by 0.6, then restart botmux. botmux performs **no automatic cold migration**, and `botmux restart` alone is insufficient: a 0.6 daemon does not understand the new `send` message and can silently discard input even when the command exits successfully. +> **⚠️ Upgrading from 0.6**: replacing the `zmx` binary on disk does not replace per-session daemons that are already running; after upgrading to 0.7.0+, manually stop and recreate every session launched by 0.6, then restart botmux. botmux performs **no automatic cold migration**, and `botmux restart` alone is insufficient. ZMX's IPC `Tag` enum is non-exhaustive (unknown tags fall through the `_` arm and are ignored), so a 0.6 daemon **discards** the new `.Send` tag outright while `zmx send` still exits 0 — the command reports success and the input never arrives. For contributors, the default `pnpm test` command runs mocked/pure unit tests and **does not require ZMX to be installed**. Coverage that launches a real `zmx` binary lives in `*.e2e.ts`, runs only when E2E is requested explicitly, and skips automatically when ZMX is unavailable—the same pattern already used by the tmux and Herdr E2E suites. @@ -92,7 +93,7 @@ When the daemon runs on macOS, you can also explicitly enable **Native CLI openi ## Troubleshooting -1. Run `zmx version` and `zmx list` as the same user that runs the daemon to verify version 0.7.1 or newer, `PATH`, and the socket directory. +1. Run `zmx version` and `zmx list` as the same user that runs the daemon to verify version 0.7.0 or newer, `PATH`, and the socket directory. 2. After an upgrade from 0.6, manually stop and recreate old session daemons; botmux does not cold-migrate them automatically, and restarting botmux alone does not replace them. Check this first if `zmx send` succeeds but the CLI receives nothing. 3. If you set `ZMX_DIR`, make sure the daemon and the shell used for local attach share the same value. botmux preserves `ZMX_DIR`, but strips inherited `ZMX_SESSION` / `ZMX_SESSION_PREFIX` so nested sessions and prefixes cannot rewrite the deterministic `bmx-*` target. 4. Inspect `botmux logs`. When a probe is inconclusive, botmux conservatively refuses to start/recreate a session so it cannot launch a duplicate CLI or delete a still-live one. diff --git a/docs-site/docs/zh/zmx.md b/docs-site/docs/zh/zmx.md index 103d52233..3ec670784 100644 --- a/docs-site/docs/zh/zmx.md +++ b/docs-site/docs/zh/zmx.md @@ -6,14 +6,15 @@ ZMX 是**显式 opt-in** 后端:botmux 不会自动安装 ZMX,也不会因 ## 安装与探测 -botmux 要求 **zmx >= 0.7.1**。这里的版本下限是一项**前置假设**:本集成将 0.7.1 视为假定包含上游 [issue #201](https://github.com/neurosnap/zmx/issues/201) / [PR #202](https://github.com/neurosnap/zmx/pull/202) 修复行为的首个发布版,即 `send` 只排队输入而不抢占 leader 或改写终端尺寸;实际安装的 0.7.1+ build 必须已包含这项修复。ZMX 官方支持 macOS 和 Linux。 - -> **⚠️ 当前发布前置(截至 2026-07-23)**:ZMX 官方 latest release 与 Homebrew tap 仍为 0.6.0,执行下面的 `brew install` 目前会安装一个**不满足**本集成要求的版本。请等待上游发布实际包含 PR #202 行为的正式 **>= 0.7.1** build 后再启用 ZMX;不要通过伪造版本号绕过门禁。本页安装命令描述的是该正式版本发布后的目标流程。 +botmux 要求 **zmx >= 0.7.0**。这个版本下限对应上游 [issue #201](https://github.com/neurosnap/zmx/issues/201) 的修复 —— commit [`8ba312d7` *fix(send): preserve client leadership*](https://github.com/neurosnap/zmx/commit/8ba312d7),随 [v0.7.0](https://github.com/neurosnap/zmx/releases/tag/v0.7.0)(2026-07-23)发布:`zmx send` 改用独立的 `.Send` IPC tag,只把输入排进 PTY 队列,不再抢占 leader、也不再改写终端尺寸。ZMX 官方支持 macOS 和 Linux。 ```bash -# Homebrew(macOS / Linuxbrew;等待 tap 发布包含 PR #202 的 >= 0.7.1) +# Homebrew(macOS / Linuxbrew) brew install neurosnap/tap/zmx +# 或 mise +mise use -g github:neurosnap/zmx@latest + # 验证 daemon 用户的 PATH 与控制面 zmx version zmx list @@ -23,7 +24,7 @@ zmx list 每次启动新 ZMX 会话前,botmux 都会校验可执行文件、版本和 `zmx list` 控制面。任意一项失败都会 **fail closed** 并向会话返回可操作的错误;绝不会悄悄降级到 PTY。 -> **⚠️ 从 0.6 升级**:替换磁盘上的 `zmx` 二进制不会替换已经运行的逐会话 daemon;升级到包含 PR #202 的 0.7.1+ build 后,请手动关闭并重新创建所有 0.6 会话,再重启 botmux。botmux **不会自动冷迁移**旧会话,只运行 `botmux restart` 也不够;0.6 daemon 不认识新版 `send` 消息,可能在命令返回成功时仍静默丢弃输入。 +> **⚠️ 从 0.6 升级**:替换磁盘上的 `zmx` 二进制不会替换已经运行的逐会话 daemon;升级到 0.7.0+ 后,请手动关闭并重新创建所有 0.6 会话,再重启 botmux。botmux **不会自动冷迁移**旧会话,只运行 `botmux restart` 也不够。ZMX 的 IPC `Tag` 枚举是 non-exhaustive 的(未知 tag 走 `_` 分支被忽略),所以 0.6 daemon 收到新的 `.Send` tag 会**直接丢弃**,而 `zmx send` 仍然退出码 0 —— 表现为命令成功但输入从未送达。 开发时,默认 `pnpm test` 只跑 mock / 纯函数单测,**不要求本机安装 ZMX**。会启动真实 `zmx` 的覆盖位于 `*.e2e.ts`,只在显式运行 E2E 时参与,并在 ZMX 不可用时自动跳过;这与仓库现有 tmux / Herdr E2E 的处理方式一致。 @@ -92,7 +93,7 @@ botmux list ## 排错 -1. 以运行 daemon 的同一用户执行 `zmx version` 和 `zmx list`,确认版本至少为 0.7.1、`PATH` 和 socket 目录可用。 +1. 以运行 daemon 的同一用户执行 `zmx version` 和 `zmx list`,确认版本至少为 0.7.0、`PATH` 和 socket 目录可用。 2. 如果刚从 0.6 升级,手动关闭并重新创建旧会话 daemon;botmux 不做自动冷迁移,仅重启 botmux 不会替换它们。出现 `zmx send` 返回成功但 CLI 没收到输入,优先检查这一项。 3. 如果显式设了 `ZMX_DIR`,确保 daemon 和本地 attach 的 shell 使用同一值。botmux 会保留 `ZMX_DIR`,但会清掉继承的 `ZMX_SESSION` / `ZMX_SESSION_PREFIX`,避免嵌套会话和名称前缀改写 `bmx-*` 目标。 4. 查看 `botmux logs`。探测结果不确定时,botmux 会保守拒绝启动/重建,避免重复启动 CLI 或误删仍存活的会话。 diff --git a/src/adapters/backend/session-backend-selector.ts b/src/adapters/backend/session-backend-selector.ts index c5e0d6e0a..1a23facc9 100644 --- a/src/adapters/backend/session-backend-selector.ts +++ b/src/adapters/backend/session-backend-selector.ts @@ -46,7 +46,7 @@ export function backendGateUserMessage(backend: BackendType, reason: string): st backend === 'tmux' ? 'macOS: brew install tmux | Debian/Ubuntu: sudo apt-get install -y tmux | 其它发行版用对应包管理器安装 tmux' : backend === 'zmx' - ? '需要包含 PR #202 send 行为的 zmx >= 0.7.1;当前官方 0.6.0 尚不满足,请等待对应正式版' + ? '需要 zmx >= 0.7.0(send 不再抢占 client leadership)|macOS: brew install neurosnap/tap/zmx | Linux: 装官方 release binary | mise: mise use -g github:neurosnap/zmx@latest' : `请确认 ${backend} 已正确安装并可用`; return [ `⚠️ 本机 ${backend} 不可用,无法启动会话。`, diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index 423c53ecf..c7e057fe9 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -57,9 +57,11 @@ const ZMX_HISTORY_MAX_BYTES = 16 * 1024 * 1024; // ZMX's daemon reads one 4096-byte IPC frame at a time and may observe HUP from // the short-lived `send` client in the same poll iteration. Keep header+payload // comfortably below that boundary so it can parse the complete message before -// closing the client (PR #202 currently has no ACK/drain handshake). +// closing the client (`zmx send` has no ACK/drain handshake as of v0.7.0). const ZMX_SEND_CHUNK_BYTES = 1024; -// PR #202 still has a 256 KiB daemon-side input queue and no send ACK. Reject +// v0.7.0's daemon queues send input into a 256 KiB pty_write_buf and silently +// DROPS any payload that would overflow it (daemon-side log only, no ACK, so +// `zmx send` still exits 0). Reject // one-shot payloads well below that ceiling before writing any prefix; adapters // that intentionally stream larger input already split and throttle their calls. const ZMX_SEND_MAX_BYTES = 64 * 1024; @@ -1035,7 +1037,7 @@ export class ZmxBackend implements SessionBackend { historyPath = join(historyDir, 'history.txt'); historyFd = openSync(historyPath, 'wx+', 0o600); // Keep transcript bytes reachable only through the open fd. A private - // regular file also avoids PR #202's single-write pipe truncation. + // regular file also avoids `zmx history`'s single-write pipe truncation. rmSync(historyPath); child = spawn('zmx', ['history', this.sessionName], { stdio: ['ignore', historyFd, 'pipe'], diff --git a/src/cli.ts b/src/cli.ts index 5892ca6b8..11960485d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1345,7 +1345,7 @@ async function promptEditBotConfig( } printInputHelp('会话后端 backendType', [ - '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr 支持托管持久会话;zmx >= 0.7.1 提供纯文本持久会话 + 本机 attach(无 Web TUI);zellij 为实验后端(需 zellij >= 0.44)。', + '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr 支持托管持久会话;zmx >= 0.7.0 提供纯文本持久会话 + 本机 attach(无 Web TUI);zellij 为实验后端(需 zellij >= 0.44)。', '选择 traex + herdr 时,可在 Dashboard Settings 中开启 TraeX herdr plugin opt-in 并填写可信插件 spec;默认不会自动安装第三方插件。', '留空保留当前值;输入 - 回到全局默认(未设置 BACKEND_TYPE 时为 tmux);接受 pty / tmux / herdr / zellij / zmx。', ]); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 6df6b0b06..9c1827da2 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1743,7 +1743,7 @@ const zh: DashboardMessages = { 'botDefaults.readIsolationStaleOn': '(已开启,但当前环境无法强制执行——会话会 fail-close 拒启动,建议关闭以恢复)', 'botDefaults.sectionBackend': '会话后端', 'botDefaults.backendLabel': '后端类型', - 'botDefaults.backendHelp': '该 bot 新会话使用的会话后端;改动实时生效于新会话,运行中的会话仍用其启动时的后端、不受影响。tmux 支持 adopt / Web 终端;herdr 托管持久会话;zmx >= 0.7.1 使用 tail 变化信号 + history 权威纯文本屏幕 + send 输入(无 Web TUI);zellij 实验性;pty 最轻量但不跨 daemon 重启存活。“自动”跟随全局默认。', + 'botDefaults.backendHelp': '该 bot 新会话使用的会话后端;改动实时生效于新会话,运行中的会话仍用其启动时的后端、不受影响。tmux 支持 adopt / Web 终端;herdr 托管持久会话;zmx >= 0.7.0 使用 tail 变化信号 + history 权威纯文本屏幕 + send 输入(无 Web TUI);zellij 实验性;pty 最轻量但不跨 daemon 重启存活。“自动”跟随全局默认。', 'botDefaults.backendAuto': '自动(跟随全局默认)', 'botDefaults.backendTmux': 'tmux', 'botDefaults.backendHerdr': 'herdr', @@ -3742,7 +3742,7 @@ const en: DashboardMessages = { 'botDefaults.readIsolationStaleOn': "(enabled, but it can't be enforced here — sessions will fail-close; turn it off to recover)", 'botDefaults.sectionBackend': 'Session backend', 'botDefaults.backendLabel': 'Backend type', - 'botDefaults.backendHelp': 'The session backend for this bot\'s new sessions. Changes apply live to new sessions; running sessions keep the backend they started on and are unaffected. tmux supports adopt / Web Terminal; herdr manages persistent sessions; zmx >= 0.7.1 uses tail change signals + an authoritative plain-text history screen + send input (no Web TUI); zellij is experimental; pty is lightest but does not survive daemon restarts. "Auto" follows the global default.', + 'botDefaults.backendHelp': 'The session backend for this bot\'s new sessions. Changes apply live to new sessions; running sessions keep the backend they started on and are unaffected. tmux supports adopt / Web Terminal; herdr manages persistent sessions; zmx >= 0.7.0 uses tail change signals + an authoritative plain-text history screen + send input (no Web TUI); zellij is experimental; pty is lightest but does not survive daemon restarts. "Auto" follows the global default.', 'botDefaults.backendAuto': 'Auto (follow global default)', 'botDefaults.backendTmux': 'tmux', 'botDefaults.backendHerdr': 'herdr', diff --git a/src/services/backend-availability.ts b/src/services/backend-availability.ts index a6d99c5a0..6ad8c89a1 100644 --- a/src/services/backend-availability.ts +++ b/src/services/backend-availability.ts @@ -58,7 +58,7 @@ export async function ensureBackendAvailable( ok: false, backendType, reason: result.reason, - manualCommand: '等待包含 PR #202 send 行为的 ZMX >= 0.7.1 正式版;发布后 macOS 可用 Homebrew、Linux 可用官方 release binary 安装', + manualCommand: '安装 ZMX >= 0.7.0:macOS 用 brew install neurosnap/tap/zmx;Linux 用官方 release binary;或 mise use -g github:neurosnap/zmx@latest', }; } diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index 296bfa164..effcff542 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -88,7 +88,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'canTalkDaemonCommands', configKey: 'canTalkDaemonCommands', kind: 'stringList', effect: 'immediate', clearable: true, parseList: parseCanTalkDaemonCommandsInput, hint: '把列出的 daemon 命令权限从 canOperate(仅管理员)降到 canTalk(对话放行即可用),如 /status /help;仅认 daemon 命令,透传命令无效;unset 回全部仅管理员' }, { key: 'startupCommands', configKey: 'startupCommands', kind: 'stringList', effect: 'next-session', clearable: true, parseList: parseStartupCommandsInput, hint: '开会话后、首条消息前自动发给 CLI 的命令(逗号/换行分隔,可带参数,如 /effort ultracode);unset 回不发' }, { key: 'env', configKey: 'env', kind: 'json', effect: 'next-session', clearable: true, hint: 'per-bot 环境变量 JSON(如 {"ANTHROPIC_BASE_URL":"…","ANTHROPIC_AUTH_TOKEN":"…"} 让本 bot 走 GLM/第三方服务商,或设 HTTPS_PROXY);注入到本 bot 的 CLI 进程,下个会话生效;值不显示(脱敏);unset 清除' }, - { key: 'backendType', configKey: 'backendType', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['pty', 'tmux', 'herdr', 'zellij', 'zmx', 'riff'], hint: '会话后端类型:pty=本地 PTY 子进程(默认)|tmux=tmux 会话|herdr=herdr 终端复用|zellij=zellij 多路复用|zmx=ZMX >=0.7.1 纯文本持久会话(无 Web TUI)|riff=远程 riff agent 服务;选 riff 时需配置 riff 字段;unset 回 pty' }, + { key: 'backendType', configKey: 'backendType', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['pty', 'tmux', 'herdr', 'zellij', 'zmx', 'riff'], hint: '会话后端类型:pty=本地 PTY 子进程(默认)|tmux=tmux 会话|herdr=herdr 终端复用|zellij=zellij 多路复用|zmx=ZMX >=0.7.0 纯文本持久会话(无 Web TUI)|riff=远程 riff agent 服务;选 riff 时需配置 riff 字段;unset 回 pty' }, { key: 'riff', configKey: 'riff', kind: 'json', effect: 'next-session', clearable: true, hint: 'riff 后端配置 JSON(baseUrl/agent/model/jwt 等),仅 backendType=riff 时生效;unset 清除' }, ]; diff --git a/src/setup/ensure-zmx.ts b/src/setup/ensure-zmx.ts index a39913caa..9d82e5c70 100644 --- a/src/setup/ensure-zmx.ts +++ b/src/setup/ensure-zmx.ts @@ -8,6 +8,14 @@ import { execFileSync } from 'node:child_process'; import { homedir } from 'node:os'; +/** + * Lowest zmx release whose `send` only queues input instead of taking client + * leadership (upstream commit 8ba312d7 "fix(send): preserve client leadership", + * shipped in v0.7.0). Below this floor `send` steals the leader and rewrites + * the terminal size, which corrupts the `history` screen botmux reads. + */ +export const ZMX_MIN_VERSION: [number, number, number] = [0, 7, 0]; + const ZMX_PATH_EXTRAS = [ `${homedir()}/.local/share/mise/shims`, `${homedir()}/.local/bin`, @@ -64,14 +72,19 @@ export function probeZmxVersion(): { ok: true; version: string } | { ok: false; if (!parsedVersion) { return { ok: false, reason: `无法解析 zmx 版本:${version.split('\n')[0] || '(empty)'}` }; } - if (compareVersion(parsedVersion, [0, 7, 1]) < 0) { + if (compareVersion(parsedVersion, ZMX_MIN_VERSION) < 0) { return { ok: false, - reason: `zmx >= 0.7.1 才受支持(当前 ${parsedVersion.join('.')};需要包含 PR #202 的 send 行为,输出由 history 获取)`, + reason: `zmx >= ${ZMX_MIN_VERSION.join('.')} 才受支持(当前 ${parsedVersion.join('.')};需要 send 不抢占 client leadership 的行为,输出由 history 获取)`, }; } - return { ok: true, version }; + // Normalise to a single "zmx x.y.z" line. `zmx version` also prints + // ghostty_vt / socket_dir / log_dir, and this string is surfaced verbatim by + // the Dashboard backend-availability API — returning the raw blob would leak + // local socket/log paths and render as a multi-line smear next to peers like + // "tmux 3.5". + return { ok: true, version: `zmx ${parsedVersion.join('.')}` }; } export function probeZmxFunctional(): { ok: true; version: string } | { ok: false; reason: string } { diff --git a/test/backend-availability.test.ts b/test/backend-availability.test.ts index 1fa3ec424..0b53e5c02 100644 --- a/test/backend-availability.test.ts +++ b/test/backend-availability.test.ts @@ -6,7 +6,7 @@ function deps(overrides: Partial<BackendAvailabilityDeps> = {}): BackendAvailabi ensureTmux: vi.fn(async () => ({ installed: true, version: 'tmux 3.5', freshInstall: false, binaryPresent: true })), ensureHerdr: vi.fn(async () => ({ installed: true, version: 'herdr 0.7.3', freshInstall: false })), probeZellijFunctional: vi.fn(() => ({ ok: true, version: 'zellij 0.44.1' })), - probeZmxFunctional: vi.fn(() => ({ ok: true, version: 'zmx 0.7.1' })), + probeZmxFunctional: vi.fn(() => ({ ok: true, version: 'zmx 0.7.0' })), ...overrides, }; } @@ -66,7 +66,7 @@ describe('ensureBackendAvailable', () => { ok: false, backendType: 'zmx', reason: 'zmx 0.5.9 过旧', - manualCommand: '等待包含 PR #202 send 行为的 ZMX >= 0.7.1 正式版;发布后 macOS 可用 Homebrew、Linux 可用官方 release binary 安装', + manualCommand: '安装 ZMX >= 0.7.0:macOS 用 brew install neurosnap/tap/zmx;Linux 用官方 release binary;或 mise use -g github:neurosnap/zmx@latest', }); expect(d.probeZmxFunctional).toHaveBeenCalledTimes(1); expect(d.probeZellijFunctional).not.toHaveBeenCalled(); diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 6f398adbe..a1b847f82 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -67,11 +67,14 @@ describe('backendGateUserMessage', () => { expect(msg).toContain('BACKEND_TYPE=pty'); }); - it('includes the supported ZMX version and unreleased upstream prerequisite', () => { + it('includes the supported ZMX version floor and an actionable install hint', () => { const msg = backendGateUserMessage('zmx', 'zmx 二进制不在 PATH 上'); - expect(msg).toContain('zmx >= 0.7.1'); - expect(msg).toContain('PR #202'); - expect(msg).toContain('官方 0.6.0 尚不满足'); + expect(msg).toContain('zmx >= 0.7.0'); + expect(msg).toContain('client leadership'); + // The hint must tell the user how to actually install it, not to wait for + // an unreleased upstream build (0.7.0 has shipped). + expect(msg).toContain('brew install neurosnap/tap/zmx'); + expect(msg).not.toContain('等待'); }); }); diff --git a/test/zmx-backend-helpers.test.ts b/test/zmx-backend-helpers.test.ts index ec977355d..7873a7cd3 100644 --- a/test/zmx-backend-helpers.test.ts +++ b/test/zmx-backend-helpers.test.ts @@ -73,17 +73,21 @@ describe('zmx env/probe helpers', () => { execFileSyncMock.mockReturnValueOnce('zmx 0.6.99\n' as never); expect(probeZmxFunctional()).toEqual({ ok: false, - reason: 'zmx >= 0.7.1 才受支持(当前 0.6.99;需要包含 PR #202 的 send 行为,输出由 history 获取)', + reason: 'zmx >= 0.7.0 才受支持(当前 0.6.99;需要 send 不抢占 client leadership 的行为,输出由 history 获取)', }); expect(execFileSyncMock).toHaveBeenCalledTimes(1); + // 0.7.0 is the exact floor: upstream commit 8ba312d7 ("fix(send): preserve + // client leadership") shipped in that release, so it must be ACCEPTED. execFileSyncMock.mockReset(); - execFileSyncMock.mockReturnValueOnce('zmx 0.7.0\n' as never); - expect(probeZmxFunctional()).toEqual({ - ok: false, - reason: 'zmx >= 0.7.1 才受支持(当前 0.7.0;需要包含 PR #202 的 send 行为,输出由 history 获取)', - }); - expect(execFileSyncMock).toHaveBeenCalledTimes(1); + execFileSyncMock.mockReturnValueOnce( + 'zmx\t\t0.7.0\nghostty_vt\tdev\nsocket_dir\t/tmp/zmx-501\nlog_dir\t/tmp/zmx-501/logs\n' as never, + ); + execFileSyncMock.mockReturnValueOnce('' as never); + // The reported version is normalised to one line: the raw `zmx version` + // blob would leak socket_dir/log_dir through the Dashboard API. + expect(probeZmxFunctional()).toEqual({ ok: true, version: 'zmx 0.7.0' }); + expect(execFileSyncMock).toHaveBeenCalledTimes(2); execFileSyncMock.mockReset(); execFileSyncMock.mockReturnValueOnce('garbage\n' as never); diff --git a/test/zmx-backend.e2e.ts b/test/zmx-backend.e2e.ts index 4d3fb7316..094c6a764 100644 --- a/test/zmx-backend.e2e.ts +++ b/test/zmx-backend.e2e.ts @@ -1,7 +1,7 @@ /** * Real ZMX smoke coverage for the tail/send/history transport. * - * Requires zmx >= 0.7.1 with PR #202 semantics (ordinary contributors without ZMX are skipped): + * Requires zmx >= 0.7.0 (send no longer claims client leadership) (ordinary contributors without ZMX are skipped): * pnpm vitest run --project e2e test/zmx-backend.e2e.ts */ import { createHash } from 'node:crypto'; @@ -18,7 +18,7 @@ const ZMX_AVAILABLE = ZmxBackend.isAvailable(); if (process.env.BOTMUX_E2E_REQUIRE_ZMX === '1' && !ZMX_AVAILABLE) { throw new Error( - 'BOTMUX_E2E_REQUIRE_ZMX=1, but a functional zmx >= 0.7.1 was not found in PATH', + 'BOTMUX_E2E_REQUIRE_ZMX=1, but a functional zmx >= 0.7.0 was not found in PATH', ); } From 34d85cbd347aef8a2830f98fc9e2f576e36d00e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Sat, 25 Jul 2026 03:33:44 +0800 Subject: [PATCH 08/26] =?UTF-8?q?docs(zmx):=20=E5=86=99=E6=98=8E=E5=8F=97?= =?UTF-8?q?=E7=AE=A1=E4=BC=9A=E8=AF=9D=E5=9B=BA=E5=AE=9A=20120x24=20?= =?UTF-8?q?=E5=8F=8A=20resize=20no-op=20=E7=9A=84=E6=88=90=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- docs-site/docs/en/zmx.md | 2 +- docs-site/docs/zh/zmx.md | 2 +- src/adapters/backend/zmx-backend.ts | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs-site/docs/en/zmx.md b/docs-site/docs/en/zmx.md index 0fa053550..f1eb1151b 100644 --- a/docs-site/docs/en/zmx.md +++ b/docs-site/docs/en/zmx.md @@ -71,7 +71,7 @@ This integration deliberately uses eventually consistent plain-text screen seman - ZMX exposes an **eventually consistent plain-text screen** from `history`; it does not preserve color, cursor state, OSC, or the alternate screen. Capture is single-flight per session and a dirty latch forces one follow-up when activity arrives during a capture. - The ZMX backend does not provide botmux's interactive Web TUI or resize the backing PTY. Use local `zmx attach` when you need raw ANSI, a fullscreen TUI, or terminal-size negotiation. -- A local attach leader controls terminal size. Without one, the ZMX session keeps its existing size; botmux's `send` does not change it. +- A local attach leader controls terminal size. Without one, the ZMX session keeps its existing size; botmux's `send` does not change it. ZMX exposes no leaderless resize primitive, so botmux's `resize()` is a deliberate no-op. botmux creates sessions from a non-TTY client, which lands on ZMX's `getTerminalSize` fallback, so **managed sessions run at a fixed 120×24** and the CLI wraps its TUI at 120 columns — the width you see in Lark. Use a local `zmx attach` to take leadership if you need a different size. - Upstream `send` currently provides no delivery ACK or backpressure. botmux sends 1 KiB chunks and rejects any single backend input over 64 KiB before writing a prefix. It never retries an ambiguous result automatically because the input may already be queued and a retry could duplicate it; the backend reports failure to its caller instead of hiding a retry internally. - `zmx history` can restore only the bounded scrollback still retained by ZMX / ghostty; older output is evicted once the upstream scrollback budget is exceeded. It reconstructs the eventually consistent observable state rather than a lossless transcript or terminal recording; transient output after the daemon has exited cannot be recovered. Workflow raw PTY replay logs therefore do not have tmux's lossless semantics. diff --git a/docs-site/docs/zh/zmx.md b/docs-site/docs/zh/zmx.md index 3ec670784..8174b322f 100644 --- a/docs-site/docs/zh/zmx.md +++ b/docs-site/docs/zh/zmx.md @@ -71,7 +71,7 @@ botmux 为每个受管会话使用确定性名称 `bmx-<sessionId 前 8 位>`。 - ZMX 向 botmux 提供的是 `history` 的**最终一致纯文本屏幕**,不保留颜色、光标状态、OSC 或 alternate screen。采集单会话 single-flight,并在采集中出现新活动时强制补拉,避免并发 history 风暴或漏掉飞行中的尾段。 - ZMX 后端不提供 botmux 的交互式 Web TUI,也不向 backing PTY 发送 resize。需要 raw ANSI、全屏 TUI 或尺寸协商时,请使用本机 `zmx attach`。 -- 本机 attach 的 leader 负责终端尺寸;没有本机 leader 时沿用 ZMX 会话的既有尺寸。botmux 的 `send` 不会改变它。 +- 本机 attach 的 leader 负责终端尺寸;没有本机 leader 时沿用 ZMX 会话的既有尺寸。botmux 的 `send` 不会改变它。ZMX 没有提供任何「不当 leader 也能 resize」的接口,所以 botmux 的 `resize()` 是刻意的 no-op。botmux 建会话时用的是非 TTY 客户端,落到 ZMX `getTerminalSize` 的兜底值,因此**受管会话固定跑在 120×24**,CLI 的 TUI 按 120 列折行 —— 这也是飞书侧看到的宽度。需要别的尺寸时用本机 `zmx attach` 接管 leader。 - 上游 `send` 目前没有投递 ACK / backpressure。botmux 以 1 KiB 分片发送,并在写入任何前缀前拒绝超过 64 KiB 的单次后端输入;结果不确定时不会自动重试,以免把已经入队的输入重复提交。后端会向调用方返回失败,而不是在内部隐藏重试。 - `zmx history` 只能恢复 ZMX / ghostty 当时仍保留的有界 scrollback;超过上游滚动缓冲预算后,较早输出会被淘汰。它构成最终一致的当前可观察状态,不是无损 transcript 或终端录屏;进程退出后才出现且未被最后一次采集命中的瞬态输出也无法补回。Workflow 的 raw PTY replay log 因此不具备 tmux 的无损语义。 diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index c7e057fe9..cd260150a 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -537,7 +537,15 @@ export class ZmxBackend implements SessionBackend { } } - /** send intentionally never becomes a leader, so botmux cannot resize ZMX. */ + /** + * No-op by construction: zmx exposes no leaderless resize primitive. Size is + * set only by an attached client's TIOCGWINSZ, and `send` deliberately never + * becomes that client (upstream 8ba312d7). Since `createFreshSession` creates + * the session with a non-TTY stdio, zmx's `getTerminalSize` fallback applies + * and every botmux-owned session runs at a fixed 120x24 until a local + * `zmx attach` takes leadership. The CLI therefore wraps its TUI at 120 + * columns, which is the width reflected in the history screen we relay. + */ resize(_cols: number, _rows: number): void {} onData(cb: (data: string) => void): void { From b3bd6c0bf4c65008b54d69ab5193dd3afb49b5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Sat, 25 Jul 2026 13:23:23 +0800 Subject: [PATCH 09/26] =?UTF-8?q?fix(rebase):=20=E5=AF=B9=E9=BD=90=20upstr?= =?UTF-8?q?eam=20=E5=90=8E=E7=AB=AF=E9=87=8D=E9=80=89=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E7=9A=84=E5=86=B7=E5=90=AF=E9=A1=BA=E5=BA=8F=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test/backend-gate.test.ts | 41 +++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index a1b847f82..202590f8b 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -111,28 +111,45 @@ describe('ZMX filesystem-isolation gate', () => { }); describe('persistent backend cold-restart ordering', () => { - it('selects the backend only after stale persistent panes have been removed', () => { - const coldRestartGate = workerSource.indexOf( - 'if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length', - ); - const backendSelection = workerSource.indexOf( - 'const selectedBackend = selectSessionBackend({', - ); - - expect(coldRestartGate).toBeGreaterThan(-1); - expect(backendSelection).toBeGreaterThan(coldRestartGate); + // The backend is selected once up-front and RE-selected through the + // `selectBackend()` thunk after any gate kills a stale pane. The invariant is + // no longer "select last" but "never keep a selection made against a pane that + // was just destroyed" — a stale `isReattach: true` would reattach the new + // backend to the pane the gate had removed. + it('re-selects the backend after every gate that kills a stale persistent pane', () => { + const thunk = workerSource.indexOf('const selectBackend = () => selectSessionBackend({'); + expect(thunk).toBeGreaterThan(-1); + + // Each `killPersistentBackendTarget` / `ZmxBackend.killManagedSession` gate + // must be followed by a re-selection before the backend is used. + const gates = [ + workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'), + workerSource.indexOf('if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length'), + ]; + for (const gate of gates) { + expect(gate).toBeGreaterThan(-1); + const reselect = workerSource.indexOf('selectedBackend = selectBackend();', gate); + expect(reselect).toBeGreaterThan(gate); + // ...and the re-selection must refresh the reattach decision too. + expect( + workerSource.indexOf('willReattachPersistent = selectedBackend.isReattach === true;', gate), + ).toBeGreaterThan(gate); + } }); it('fails closed on an uncertain MCP pane and refreshes the cached ZMX probe after killing it', () => { const start = workerSource.indexOf( 'if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length', ); - const end = workerSource.indexOf('const willReattachPersistent =', start); + const end = workerSource.indexOf('// The plugin set is stable only', start); const gate = workerSource.slice(start, end); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); - expect(gate).toContain('probePersistentSession('); + // ZMX ownership is verified against the frozen PID; other backends go + // through the exact recorded target (Herdr may own an agent, not a session). + expect(gate).toContain('probeOwnedZmxSession('); + expect(gate).toContain('probePersistentBackendTarget('); expect(gate).toContain("paneProbe === 'unknown'"); expect(gate).toContain("postKillProbe !== 'missing'"); expect(gate).toContain("effectiveBackendType === 'zmx'"); From c16ed270345898a2c94bd2a838fee72a65c3c210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Sat, 25 Jul 2026 13:29:21 +0800 Subject: [PATCH 10/26] =?UTF-8?q?fix(zmx):=20=E4=BF=AE=E6=AD=A3=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E5=A4=B1=E8=B4=A5=E5=BD=92=E5=9B=A0=E4=B8=8E=20tail?= =?UTF-8?q?=20=E8=BF=9E=E6=8E=A5=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自检轮次里两条经对抗验证成立的问题。 **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 --- src/adapters/backend/zmx-backend.ts | 36 ++++++++++++++----- src/cli.ts | 43 +++++++++------------- src/core/command-handler.ts | 2 +- src/core/dashboard-ipc-server.ts | 7 +++- src/core/session-manager.ts | 2 +- src/core/trigger-session.ts | 2 +- src/core/worker-pool.ts | 56 ++++++++++------------------- src/daemon.ts | 6 ++-- src/setup/ensure-zmx.ts | 52 ++++++++++++++++++++++++--- src/worker.ts | 2 +- test/zmx-backend-helpers.test.ts | 43 ++++++++++++++++++++++ test/zmx-backend-recovery.test.ts | 26 ++++++++++++++ 12 files changed, 193 insertions(+), 84 deletions(-) diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index cd260150a..7f8be5a70 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -505,9 +505,8 @@ export class ZmxBackend implements SessionBackend { ); if (this.reattaching) { - const baselineClients = probe.state === 'compatible' ? (probe.clients ?? 0) : 0; this.startTail(); - if (!this.waitForTailClient(baselineClients)) { + if (!this.waitForTailClient()) { this.stopTailAfterLaunchFailure(); this.preserveSessionOnDestroy = true; throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`); @@ -696,9 +695,8 @@ export class ZmxBackend implements SessionBackend { this.preserveSessionOnDestroy = true; throw new Error(`ZMX 会话 ${this.sessionName} 在 release 前未通过完整身份校验`); } - const baselineClients = managed.clients ?? 0; this.startTail(); - if (!this.waitForTailClient(baselineClients)) { + if (!this.waitForTailClient()) { throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`); } @@ -808,7 +806,30 @@ export class ZmxBackend implements SessionBackend { return pid; } - private waitForTailClient(baselineClients: number): boolean { + /** + * Wait until the session reports at least one connected client. + * + * Deliberately NOT a differential against a pre-tail baseline. zmx's + * `clients=` is an aggregate — `main.zig` reports `clients.items.len - 1`, + * subtracting only the `zmx list` connection that asked. A user's + * `zmx attach` (which this integration actively encourages), and every + * transient `zmx list`/`get`/`history`/`send` botmux itself issues, are all + * counted identically to our `tail`. A differential therefore produces false + * NEGATIVES: a user detaching inside this window keeps the net delta at 0 and + * a perfectly healthy session fails to restore with "tail 未能连接". + * + * `>= 1` inverts the error direction. The residual false POSITIVE — someone + * else holds a client while our tail failed — is cheap and self-correcting: + * tail is only a wakeup signal (never a byte source), the authoritative + * screen comes from `history` polling which does not need tail at all, and + * `scheduleTailRecovery` reconnects a dead observer. Blocking a restore is + * the far more expensive mistake. + * + * Note this cannot instead watch our own child: the wait is synchronous + * (`sleepSync`), so the child's 'error'/'close' callbacks cannot run until it + * returns. Session identity is still verified below. + */ + private waitForTailClient(): boolean { const deadline = Date.now() + TAIL_CONNECT_TIMEOUT_MS; while (Date.now() < deadline) { const details = sessionDetails( @@ -819,7 +840,7 @@ export class ZmxBackend implements SessionBackend { this.preserveSessionOnDestroy = true; return false; } - if (details?.clients != null && details.clients > baselineClients) return true; + if (details?.clients != null && details.clients >= 1) return true; if (!details && ZmxBackend.probeSession( this.sessionName, this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(), @@ -1420,9 +1441,8 @@ export class ZmxBackend implements SessionBackend { } try { logger.warn(`[zmx:${this.sessionName}] tail observer exited while session lives; reconnecting`); - const baselineClients = probe.clients ?? 0; this.startTail(); - if (!this.waitForTailClient(baselineClients)) { + if (!this.waitForTailClient()) { throw new Error('replacement tail did not become a connected client'); } this.requestHistoryCapture(0, true, true); diff --git a/src/cli.ts b/src/cli.ts index 11960485d..8046387e3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3454,13 +3454,19 @@ function closeSessionOffline(s: SessionData): void { // Adopted panes belong to the user. Ordinary bmx-* sessions are botmux-owned // and still need direct cleanup when no daemon exists to run killWorker(). if (!isAdoptedSession(s)) { - const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`; - try { - execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { - stdio: 'ignore', - env: tmuxEnv(), - }); - } catch { /* no tmux session */ } + if (isSuspendableBackendType(s.backendType)) { + const name = persistentSessionName(s.backendType, s.sessionId); + try { killPersistentSession(s.backendType, name, s.sessionId); } catch { /* absent or unavailable */ } + } else { + // Legacy rows without backendType were historically tmux-backed. + const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`; + try { + execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { + stdio: 'ignore', + env: tmuxEnv(), + }); + } catch { /* no tmux session */ } + } } s.status = 'closed'; @@ -3847,13 +3853,6 @@ function interactiveSessionPicker(active: SessionData[], probeSnapshot: BackingP flashMsg = `\x1b[31m✗ 删除失败: ${result.error}\x1b[0m`; return; } - if (daemonClose.state === 'offline') { - // Offline fallback. For adopted sessions, never kill the user's - // original CLI pid if an old record stored it in `pid`. - const originalPid = adoptedCliPid(s); - if (s.pid && s.pid !== originalPid && isProcessAlive(s.pid)) { - killProcess(s.pid); - } // Remove from active list and TUI rows const activeIdx = active.indexOf(s); @@ -3995,19 +3994,11 @@ async function cmdList(): Promise<void> { else if (disposition === 'prune_scratch') prunedScratch.push(s); else live.push(s); } - const closeOffline = (s: SessionData) => { - s.status = 'closed'; - s.closedAt = new Date().toISOString(); - saveSession(s); - }; const closeNow = async (arr: SessionData[], kind: 'scratch' | 'real'): Promise<number> => { let closed = 0; for (const s of arr) { - const daemonClose = await closeSessionViaDaemon(s); - if (daemonClose.state === 'closed') { - closed++; - } else if (daemonClose.state === 'offline') { - closeOffline(s); + const result = await closeSessionForDelete(s); + if (result.ok) { closed++; } else { // Keep it visible: mutating only the store while a possible owner @@ -4015,7 +4006,7 @@ async function cmdList(): Promise<void> { // exactly the session auto-prune claimed to close. live.push(s); console.warn( - `⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}:${daemonClose.reason}`, + `⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}:${result.error}`, ); } } @@ -4241,7 +4232,7 @@ async function cmdSuspend(): Promise<void> { async function postSessionCliIpc( ipcPort: number, sessionId: string, - route: 'slash' | 'cd' | 'close', + route: 'slash' | 'cd' | 'close' | 'rename', payload: Record<string, unknown>, ): Promise<Response> { const requestBody: Record<string, unknown> = { ...payload }; diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 8c9e8ff6e..110ed0bb9 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -24,7 +24,7 @@ import { chatAppLink, normalizeBrand } from '../im/lark/lark-hosts.js'; import { claimPairing } from '../services/pairing-store.js'; import { logger } from '../utils/logger.js'; import { scheduleTimeZone } from '../utils/timezone.js'; -import { killWorker, suspendWorker, forkWorker, forkAdoptWorker, adoptSandboxBlocked, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart } from './worker-pool.js'; +import { killWorker, suspendWorker, forkWorker, forkAdoptWorker, adoptSandboxBlocked, closeSession, teardownAuthoritativePersistentBackingBeforeClose, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart } from './worker-pool.js'; import { expandHome, getSessionWorkingDir, diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 08a39d521..2b910f7f0 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -136,7 +136,7 @@ import { validateSlashInjection } from './slash-inject.js'; import { validateRoleLibraryPath } from './role-library.js'; import { repinSessionWorkingDir } from './session-cwd.js'; import { authorizeSessionScopedIpc } from './daemon-ipc-session-auth.js'; -import { updateSessionTitle } from './session-title.js'; +import { normalizeSessionTitleSource, updateSessionTitle } from './session-title.js'; import { requestAgentSessionRename } from './session-rename.js'; import type { DaemonToWorker, ScheduledTask, ParsedSchedule, ScheduleExecutionPosition, Session } from '../types.js'; import { sessionAnchorId, type DaemonSession } from './types.js'; @@ -853,6 +853,11 @@ function findSessionRecord(sessionId: string): Session | undefined { return findActiveBySessionId(sessionId)?.session ?? sessionStore.getSession(sessionId); } +/** Mutating IPC routes may only touch this daemon's own bot-partitioned store. */ +function findOwnedSessionRecord(sessionId: string): Session | undefined { + return findActiveBySessionId(sessionId)?.session ?? sessionStore.getOwnedSession(sessionId); +} + /** Four-state async lookup with durable fallback (design A). * * In-memory `asyncTriggerResults` lives only on the active DaemonSession and is diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 418312c6a..c9448ef1d 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -12,7 +12,7 @@ import * as sessionStore from '../services/session-store.js'; import * as messageQueue from '../services/message-queue.js'; import { downloadMessageResource, listChatBotMembers, UserTokenMissingError } from '../im/lark/client.js'; import { logger } from '../utils/logger.js'; -import { forkWorker, sendWorkerInput, forkAdoptWorker, adoptSandboxBlocked, killStalePids, getCurrentCliVersion, restoreUsageLimitRuntimeState, setActiveSessionSafe, isRelayableRealSession, closeSession, getActiveSessionsRegistry, suspendWorker } from './worker-pool.js'; +import { forkWorker, sendWorkerInput, forkAdoptWorker, adoptSandboxBlocked, killStalePids, getCurrentCliVersion, restoreUsageLimitRuntimeState, setActiveSessionIfActive, setActiveSessionSafe, isDisposableCommandScratch, isRelayableRealSession, closeSession, getActiveSessionsRegistry, suspendWorker } from './worker-pool.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { buildBotmuxShellHints } from '../adapters/cli/shared-hints.js'; import { diff --git a/src/core/trigger-session.ts b/src/core/trigger-session.ts index 0d8fbb6e6..e6446e1d3 100644 --- a/src/core/trigger-session.ts +++ b/src/core/trigger-session.ts @@ -10,7 +10,7 @@ import { localeForBot, t } from '../i18n/index.js'; import { validateWorkingDir } from './working-dir.js'; import { buildFollowUpCliInput, buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots, rememberLastCliInput } from './session-manager.js'; import { markSessionActivity } from './session-activity.js'; -import { forkWorker, getCurrentCliVersion, sendWorkerInput } from './worker-pool.js'; +import { closeSession, forkWorker, getCurrentCliVersion, sendWorkerInput, setActiveSessionIfActive } from './worker-pool.js'; import { armTriggerFinalSuppression, disarmTriggerFinalSuppression, inheritTriggerReplyAnchor } from './trigger-final-suppression.js'; import { botAutoWorktreeEnabled } from '../services/default-worktree.js'; import * as messageQueue from '../services/message-queue.js'; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 8cbcbd5b5..9ee064d6b 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -1862,22 +1862,13 @@ export async function closeSession( // crash/limited turn may never have reached an idle edge). recordUsageForDaemonSession(ds); killWorker(ds); - // Commit the daemon-visible close barrier before any network await below. - // `botmux delete` may be running inside the session being destroyed; once - // its worker/backing pane exits, a new IM message must not find this stale - // entry and resurrect the old logical session while doc cleanup is pending. activeSessionsRegistry?.delete(activeSessionKey(ds)); + // Mark the captured object too. Async message/card paths may already hold a + // reference to `ds`; deleting only the registry entry would not stop one of + // those continuations from re-forking the closed session after its await. + ds.session.status = 'closed'; + ds.session.closedAt ??= new Date().toISOString(); killedLive = true; - } - - // Persistence is part of the same no-await barrier as Map eviction. If the - // daemon crashes during best-effort doc cleanup, restart recovery must not - // restore a session that was already explicitly closed. - const stored = sessionStore.getSession(sessionId); - const wasOpen = !!stored && stored.status !== 'closed'; - if (wasOpen) sessionStore.closeSession(sessionId); - - if (ds) { if (!ds.exitEventEmitted) { ds.exitEventEmitted = true; dashboardEventBus.publish({ @@ -1888,11 +1879,20 @@ export async function closeSession( } } - // Preserve the existing externally-visible event order: exit first, then - // the final persisted-row update. Persistence itself was already committed - // above as part of the close barrier. + // Persistence path — load → mark closed → save (delegated to sessionStore). + // Mutations are bot-owner scoped. getSession() has a read-only cross-file + // fallback for agent CLI discovery; using it here lets the wrong daemon see + // another bot's active row, report a false successful close, then write only + // its own file (a no-op). The CLI relies on alreadyClosed to decide whether a + // legacy local fallback is safe, so owner proof must be real. if (wasOpen) { - const after = sessionStore.getSession(sessionId); + if (!ds && stored) destroyUnregisteredPersistentBacking(stored); + sessionStore.closeSession(sessionId); + const after = sessionStore.getOwnedSession(sessionId); + if (ds) { + ds.session.status = 'closed'; + ds.session.closedAt = after?.closedAt ?? ds.session.closedAt; + } dashboardEventBus.publish({ type: 'session.update', body: { @@ -1906,26 +1906,6 @@ export async function closeSession( }); } - if (ds) { - // 文档入口清理:会话关闭即删除其绑定。只有旧 - // /subscribe-lark-doc 记录需要调飞书逐文件退订 API; - // /watch-comment 仅依赖应用级评论事件,删本地监听表即可。此段允许 await, - // 因为上面的内存 + 持久化关闭屏障已经提交。 - try { - const anchor = sessionAnchorId(ds); - const subs = listDocSubscriptionsForSession(config.session.dataDir, ds.larkAppId, anchor); - for (const sub of subs) { - if (sub.managedBy !== 'watch-comment') { - await unsubscribeDocFile(ds.larkAppId, { fileToken: sub.fileToken, fileType: sub.fileType }); - } - removeDocSubscription(config.session.dataDir, ds.larkAppId, sub.fileToken); - } - if (subs.length) logger.info(`[doc-comment] session ${sessionId.slice(0, 8)} closed → removed ${subs.length} doc binding(s)`); - } catch (err: any) { - logger.warn(`[doc-comment] cleanup on close failed for ${sessionId.slice(0, 8)}: ${err?.message ?? err}`); - } - } - // All authoritative map/status/store/event state above transitions // synchronously, before the first await. Lark reaction/unsubscribe cleanup is // best-effort and can be slow; it must not leave a resurrection window. diff --git a/src/daemon.ts b/src/daemon.ts index cdf650d42..c0cbde966 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -323,9 +323,9 @@ function republishResolvedAllowedUsers(larkAppId: string, resolved: string[]): v } let vcMeetingTerminalReconciler: VcMeetingTerminalReconciler | undefined; import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes, type RoutingContext, type TalkEvaluation, type DocCommentContext, type EventHandlers } from './im/lark/event-dispatcher.js'; -import { getDocSubscription, listAllDocSubscriptions, listDocSubscriptionsForSession, removeDocSubscription, setDocCommentPollCursor, type DocSubscription } from './services/doc-subs-store.js'; -import { BOT_REPLY_SENTINEL, subscribeDocFile, unsubscribeDocFile, addCommentReaction, hasBotSentinel, isBotAuthoredReply, listDocComments } from './im/lark/doc-comment.js'; -import { learnFromMentions, resolveSender, flushIdentityCacheSync } from './im/lark/identity-cache.js'; +import { getDocSubscription, listAllDocSubscriptions, listDocSubscriptionsForSession, putDocSubscription, removeDocSubscription, setDocCommentPollCursor, type DocSubscription } from './services/doc-subs-store.js'; +import { BOT_REPLY_SENTINEL, subscribeDocFile, unsubscribeDocFile, addCommentReaction, removeCommentReaction, hasBotSentinel, isBotAuthoredReply, listDocComments } from './im/lark/doc-comment.js'; +import { learnFromMentions, resolveSender, flushIdentityCacheSync, type ResolvedSender } from './im/lark/identity-cache.js'; import { normalizeBrand } from './im/lark/lark-hosts.js'; import { buildDocCommentTurnInput, buildDocWatchWarmupTurnInput } from './core/doc-comment-prompt.js'; import { advanceDocCommentCursor, docCommentRepliesAfterCursor, latestDocCommentPollCursor } from './core/doc-comment-poller.js'; diff --git a/src/setup/ensure-zmx.ts b/src/setup/ensure-zmx.ts index 9d82e5c70..b61a64097 100644 --- a/src/setup/ensure-zmx.ts +++ b/src/setup/ensure-zmx.ts @@ -55,17 +55,61 @@ export function zmxEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv }; } +/** + * Which env var decides zmx's socket dir, in zmx's own precedence order. + * `zmx version` touches that dir, so a failure there is very often a socket-dir + * problem rather than a missing binary — surface which var is in play. + */ +function zmxSocketDirHint(env: NodeJS.ProcessEnv): string { + for (const key of ['ZMX_DIR', 'XDG_RUNTIME_DIR', 'TMPDIR'] as const) { + const value = env[key]; + if (value) return `,socket dir 来自 ${key}=${value}`; + } + return ',未设置 ZMX_DIR / XDG_RUNTIME_DIR / TMPDIR'; +} + +/** + * Only ENOENT proves the binary is absent. Mirrors ensure-tmux's + * childFailureReason: a timeout / EACCES / non-zero exit must NOT be reported as + * the misleading "not on PATH" diagnosis. `zmx version` is not a pure print — + * it resolves and touches the socket dir, so a read-only or unwritable + * ZMX_DIR / XDG_RUNTIME_DIR exits non-zero on an otherwise healthy install. + */ +function zmxProbeFailureReason(command: string, failure: any, timeoutMs: number, env: NodeJS.ProcessEnv): string { + const nested = failure?.error; + const code = failure?.code ?? nested?.code; + const signal = failure?.signal ?? nested?.signal; + const stderr = (failure?.stderr?.toString?.() ?? nested?.stderr?.toString?.() ?? '').trim(); + + if (code === 'ENOENT') return 'zmx 二进制不在 PATH 上'; + if (code === 'EACCES') return `${command} 启动失败:zmx 不可执行(EACCES)`; + if (code === 'EMFILE' || code === 'ENFILE') return `${command} 启动失败:文件描述符耗尽(${code})`; + if (code === 'ETIMEDOUT' || signal || failure?.killed || nested?.killed) { + const detail = signal ? `,signal=${signal}` : ''; + return `${command} 探测超时(${timeoutMs}ms${detail})${zmxSocketDirHint(env)}`; + } + if (stderr) return `${command} 失败:${stderr}${zmxSocketDirHint(env)}`; + if (typeof failure?.status === 'number') { + return `${command} 失败(exit ${failure.status})${zmxSocketDirHint(env)}`; + } + const message = nested?.message ?? failure?.message; + return `${command} 启动/探测失败${message ? `:${message}` : ''}`; +} + export function probeZmxVersion(): { ok: true; version: string } | { ok: false; reason: string } { + const env = zmxEnv(); let version: string; try { version = execFileSync('zmx', ['version'], { encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], + // stderr is piped, not discarded: it carries the real cause (e.g. + // `error: ReadOnlyFileSystem` for an unwritable socket dir). + stdio: ['ignore', 'pipe', 'pipe'], timeout: 3000, - env: zmxEnv(), + env, }).trim(); - } catch { - return { ok: false, reason: 'zmx 二进制不在 PATH 上' }; + } catch (err) { + return { ok: false, reason: zmxProbeFailureReason('zmx version', err, 3000, env) }; } const parsedVersion = parseZmxVersion(version); diff --git a/src/worker.ts b/src/worker.ts index a8a6e6a18..c4f73b3c8 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -33,7 +33,7 @@ import { type IsolationCapability, } from './adapters/cli/read-isolation.js'; import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths } from './adapters/cli/fs-policy.js'; -import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, type PersistentBackendType } from './core/persistent-backend.js'; +import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, type PersistentBackendType } from './core/persistent-backend.js'; import { readProcessStartIdentity } from './core/session-marker.js'; import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText, type TranscriptEvent } from './services/claude-transcript.js'; import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js'; diff --git a/test/zmx-backend-helpers.test.ts b/test/zmx-backend-helpers.test.ts index 7873a7cd3..8208c50b6 100644 --- a/test/zmx-backend-helpers.test.ts +++ b/test/zmx-backend-helpers.test.ts @@ -49,6 +49,49 @@ describe('zmx env/probe helpers', () => { expect(env.PATH).toContain('.local/share/mise/shims'); }); + // `zmx version` is not a pure print: it resolves and touches the socket dir. + // A read-only ZMX_DIR / a stale XDG_RUNTIME_DIR (systemd --user without + // lingering) therefore exits non-zero on a perfectly healthy install. Only + // ENOENT may be reported as "not on PATH" — anything else must surface the + // real cause, or a Linux operator reinstalls zmx forever chasing the wrong bug. + it('reports the real cause instead of blaming PATH when the socket dir is unusable', () => { + const failure: any = new Error('Command failed'); + failure.status = 1; + failure.stderr = Buffer.from('error: ReadOnlyFileSystem\n'); + execFileSyncMock.mockImplementationOnce(() => { throw failure; }); + + const result = probeZmxVersion(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('ReadOnlyFileSystem'); + expect(result.reason).not.toContain('不在 PATH 上'); + // The effective socket-dir source is actionable on a headless daemon. + expect(result.reason).toContain('ZMX_DIR'); + } + }); + + it('still blames PATH only for a genuine ENOENT', () => { + const failure: any = new Error('spawn zmx ENOENT'); + failure.code = 'ENOENT'; + execFileSyncMock.mockImplementationOnce(() => { throw failure; }); + + expect(probeZmxVersion()).toEqual({ ok: false, reason: 'zmx 二进制不在 PATH 上' }); + }); + + it('reports a probe timeout as a timeout rather than a missing binary', () => { + const failure: any = new Error('ETIMEDOUT'); + failure.killed = true; + failure.signal = 'SIGTERM'; + execFileSyncMock.mockImplementationOnce(() => { throw failure; }); + + const result = probeZmxVersion(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('超时'); + expect(result.reason).not.toContain('不在 PATH 上'); + } + }); + it('requires the compatible version and a functional list command', () => { execFileSyncMock.mockReturnValueOnce('zmx 0.7.1\n' as never); execFileSyncMock.mockReturnValueOnce('' as never); diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index 1078fcc49..d3ff096eb 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -539,6 +539,32 @@ describe('ZmxBackend history-authoritative transport', () => { expect(historyChildren()).toHaveLength(capturesBefore); }); + // zmx's `clients=` is an aggregate: a user's `zmx attach` and botmux's own + // `tail` are indistinguishable. The old check required the count to RISE + // above a pre-tail baseline, so a user detaching while our tail connected + // left a net delta of 0 and a healthy session failed to restore. + it('reattaches when a concurrent client detaches as our tail connects (net client delta 0)', () => { + state.exists = true; + state.command = '/bin/sh -c echo ready'; + state.transport = 'tail-send-v1'; + state.sessionId = SESSION_ID; + // A user is attached at probe time — this was the old baseline. + state.clients = 1; + + const inner = childMocks.spawn.getMockImplementation()!; + childMocks.spawn.mockImplementation((file: string, argv: string[], options?: any) => { + const child = inner(file, argv, options); + // The user detached in the same window our tail attached: one client + // leaves, one joins, so the count never exceeds the baseline. + if (argv[0] === 'tail') state.clients = 1; + return child; + }); + + const backend = makeBackend({ reattach: true }); + expect(() => spawnBackend(backend)).not.toThrow(); + expect(tailChildren()).toHaveLength(1); + }); + it('preserves a same-name session whose complete UUID label belongs elsewhere', () => { state.exists = true; state.command = '/usr/bin/vim'; From aa1b8ddc0babdb04723ea6061ce270afa38f8a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Tue, 28 Jul 2026 00:21:43 +0800 Subject: [PATCH 11/26] =?UTF-8?q?fix(zmx):=20=E4=BF=9D=E7=95=99=20forkpty?= =?UTF-8?q?=20=E7=9A=84=E6=A0=87=E5=87=86=E8=BE=93=E5=85=A5=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/zmx-backend.ts | 6 ++++- test/zmx-backend-helpers.test.ts | 4 ++- test/zmx-backend.e2e.ts | 40 +++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index 7f8be5a70..478bb5621 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -1593,7 +1593,11 @@ export function buildZmxLaunchFiles( 'rm -f -- "$ready_path" "$release_path"', 'unset ready_path release_path release_value attempt cli_pid_path ready_nonce release_token', 'trap - 1 2 15', - 'exec </dev/tty || exit 126', + // Keep the ZMX forkpty slave inherited on fd 0. Reopening `/dev/tty` + // produces a descriptor that Darwin kqueue rejects with EINVAL, so + // crossterm/mio event readers fail to initialize even though isatty(0) + // still reports true. The release-file read above redirects only that one + // command and automatically restores the inherited PTY afterwards. `exec ${shellCommand}`, ].join('\n'); const gateCommand = [ diff --git a/test/zmx-backend-helpers.test.ts b/test/zmx-backend-helpers.test.ts index 8208c50b6..ccff66b43 100644 --- a/test/zmx-backend-helpers.test.ts +++ b/test/zmx-backend-helpers.test.ts @@ -366,7 +366,9 @@ describe('zmx backend pure helpers', () => { expect(files.bootstrap).toContain(releasePath); expect(files.bootstrap).toContain(readyNonce); expect(files.bootstrap).toContain(releaseToken); - expect(files.bootstrap).toContain('exec </dev/tty'); + // The ZMX forkpty child already owns the correct slave descriptor. Reopening + // `/dev/tty` changes fd 0 into a Darwin kqueue-incompatible descriptor. + expect(files.bootstrap).not.toContain('exec </dev/tty'); expect(files.bootstrap).toContain('cli_pid_path='); expect(files.bootstrap).toContain('"$$" > "$cli_pid_path"'); expect(files.bootstrap).toContain('/bin/sh -c '); diff --git a/test/zmx-backend.e2e.ts b/test/zmx-backend.e2e.ts index 094c6a764..a26a1282b 100644 --- a/test/zmx-backend.e2e.ts +++ b/test/zmx-backend.e2e.ts @@ -5,6 +5,7 @@ * pnpm vitest run --project e2e test/zmx-backend.e2e.ts */ import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import { afterEach, describe, expect, it } from 'vitest'; import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; @@ -12,9 +13,12 @@ const TEST_SESSION = `bmx-e2e-zmx-${process.pid}`; const RECOVERY_SESSION = `bmx-e2e-recover-${process.pid}`; const FRAMING_SESSION = `bmx-e2e-frame-${process.pid}`; const KILL_SESSION = `bmx-e2e-kill-${process.pid}`; +const TTY_SESSION = `bmx-e2e-tty-${process.pid}`; const SESSION_ID = `e2e-${process.pid}-1111-2222-333333333333`; const PRIVATE_VALUE = `zmx-private-${process.pid}`; const ZMX_AVAILABLE = ZmxBackend.isAvailable(); +const DARWIN_KQUEUE_PROBE_AVAILABLE = process.platform !== 'darwin' + || spawnSync('python3', ['-c', 'import select'], { stdio: 'ignore' }).status === 0; if (process.env.BOTMUX_E2E_REQUIRE_ZMX === '1' && !ZMX_AVAILABLE) { throw new Error( @@ -68,8 +72,44 @@ describe('ZmxBackend e2e', () => { ZmxBackend.killSession(RECOVERY_SESSION); ZmxBackend.killSession(FRAMING_SESSION); ZmxBackend.killSession(KILL_SESSION); + ZmxBackend.killSession(TTY_SESSION); }); + it.skipIf(!ZMX_AVAILABLE)( + 'preserves the forkpty stdin descriptor required by Darwin kqueue readers', + async () => { + const backend = backendFor(TTY_SESSION); + backend.spawn('sh', ['-lc', [ + 'printf "STDIN_TTY=%s\\n" "$(tty)"', + 'if [ "$(uname -s)" = Darwin ] && command -v python3 >/dev/null 2>&1; then', + ' python3 -c \'import select, sys; kq = select.kqueue(); kq.control([select.kevent(sys.stdin.fileno(), filter=select.KQ_FILTER_READ, flags=select.KQ_EV_ADD)], 0, 0); kq.close()\'', + ' printf "KQUEUE_STDIN=%s\\n" "$?"', + 'else', + ' printf "KQUEUE_STDIN=skip\\n"', + 'fi', + 'sleep 1', + ].join('\n')], { + cwd: process.cwd(), + cols: 80, + rows: 24, + env: process.env as Record<string, string>, + }); + const observed = observe(backend); + + await waitFor( + () => observed.screen.includes('KQUEUE_STDIN='), + 7000, + 'stdin tty/kqueue probe', + ); + expect(observed.screen).toMatch(/STDIN_TTY=\/dev\/(?:ttys|pts\/)\S+/); + if (process.platform === 'darwin' && DARWIN_KQUEUE_PROBE_AVAILABLE) { + expect(observed.screen).toContain('KQUEUE_STDIN=0'); + } else { + expect(observed.screen).toContain('KQUEUE_STDIN=skip'); + } + }, + ); + it.skipIf(!ZMX_AVAILABLE)( 'creates with a gated one-shot client, streams plain text, detaches, and reattaches from history', async () => { From cdb95b718f5232da81bcba6f32c3b8a65c392e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Tue, 28 Jul 2026 18:00:02 +0800 Subject: [PATCH 12/26] =?UTF-8?q?fix(rebase):=20=E4=BF=AE=E5=A4=8D=20ZMX?= =?UTF-8?q?=20=E4=B8=8E=20master=20=E5=90=88=E5=B9=B6=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/session-backend-selector.ts | 8 +-- src/core/worker-pool.ts | 9 ++- src/worker.ts | 9 +++ test/backend-gate.test.ts | 27 +++++---- test/kill-worker-orphaned-backend.test.ts | 58 ++++++++++++++++++- test/raw-input-followup-atomicity.test.ts | 4 +- test/restore-zombie-close.test.ts | 8 ++- test/session-delete-close-barrier.test.ts | 2 +- test/tmux-reattach-backend.test.ts | 2 +- test/zmx-backend-helpers.test.ts | 9 ++- 10 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/adapters/backend/session-backend-selector.ts b/src/adapters/backend/session-backend-selector.ts index 1a23facc9..4d6a44c92 100644 --- a/src/adapters/backend/session-backend-selector.ts +++ b/src/adapters/backend/session-backend-selector.ts @@ -66,9 +66,9 @@ export function backendGateUserMessage(backend: BackendType, reason: string): st export function backendSandboxCompatibilityError(opts: { backendType: BackendType; fileSandboxRequested: boolean; - /** True only when the legacy standalone readIsolation flag is effective - * on this host. The raw config value is intentionally not accepted here: - * on Linux that legacy flag is a no-op unless the unified sandbox is on. */ + /** Compatibility input for callers that still model standalone read + * isolation. The worker passes false because its unified sandbox request + * already folds in the legacy readIsolation flag on every host. */ effectiveReadIsolationRequested: boolean; }): string | undefined { if ( @@ -85,7 +85,7 @@ export function backendSandboxCompatibilityUserMessage(reason: string): string { return [ '⚠️ ZMX 当前无法执行 botmux 的文件沙盒或读隔离,已拒绝启动以避免未隔离运行。', `原因:${reason}`, - '请将该 bot 的 backendType 改为 tmux / pty,或关闭 sandbox(含全局 BOTMUX_SANDBOX)及 macOS 上独立生效的 readIsolation 后重试。', + '请将该 bot 的 backendType 改为 tmux / pty,或关闭 sandbox(含全局 BOTMUX_SANDBOX)及 legacy readIsolation 后重试。', ].join('\n'); } diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 9ee064d6b..d464cf7e0 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -5445,8 +5445,13 @@ function cleanupPersistentBackendSessions(backendType: 'tmux' | 'herdr' | 'zmx', // each persisted managed target precisely so a CLI switch cannot reattach // the old executable, while never stopping the shared `botmux` host or an // explicitly adopted user pane. - for (const target of managedTargetsForCliChange(backendType, activeSessions_)) { - killPersistentBackendTarget(target); + if (backendType === 'herdr') { + for (const target of managedTargetsForCliChange( + 'herdr', + activeSessions_.filter(belongsToBackend), + )) { + killPersistentBackendTarget(target); + } } } else { for (const name of backend.listBotmuxSessions()) { diff --git a/src/worker.ts b/src/worker.ts index c4f73b3c8..81f1565d7 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6972,6 +6972,15 @@ async function spawnCli( } const sandboxRequested = !riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled()); + const backendIsolationGate = backendSandboxCompatibilityError({ + backendType: effectiveBackendType, + fileSandboxRequested: sandboxRequested, + // The unified sandbox request above already includes legacy readIsolation. + effectiveReadIsolationRequested: false, + }); + if (backendIsolationGate) { + throw new Error(backendSandboxCompatibilityUserMessage(backendIsolationGate)); + } const fullIsolationCoversCredentials = sandboxRequested; let credentialMechanismAvailable = true; let credentialMechanismExecutable: string | undefined; diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 202590f8b..35395c65a 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -79,7 +79,7 @@ describe('backendGateUserMessage', () => { }); describe('ZMX filesystem-isolation gate', () => { - it('posts an actionable user notification before failing closed', () => { + it('formats an actionable startup error before failing closed', () => { const msg = backendSandboxCompatibilityUserMessage( 'backend "zmx" does not support file/read isolation', ); @@ -91,22 +91,27 @@ describe('ZMX filesystem-isolation gate', () => { expect(msg).toContain('readIsolation'); }); - it('gates on effective isolation and sends user_notify before throwing', () => { - const start = workerSource.indexOf('const explicitLegacyReadIso ='); - const end = workerSource.indexOf('const readIsolationGate =', start); + it('gates on the unified effective isolation before selecting or mutating a backend', () => { + const start = workerSource.indexOf('const sandboxRequested ='); + const end = workerSource.indexOf('const fullIsolationCoversCredentials =', start); const gate = workerSource.slice(start, end); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); - // A bare legacy readIsolation flag is a no-op on Linux. Passing the raw - // cfg value here would reject ZMX even though no boundary is enforced. - expect(gate).toContain('effectiveReadIsolationRequested: explicitLegacyReadIso'); + expect(gate).toContain('backendSandboxCompatibilityError({'); + expect(gate).toContain('fileSandboxRequested: sandboxRequested'); + // readIsolation is already folded into sandboxRequested on every host. + expect(gate).toContain('effectiveReadIsolationRequested: false'); expect(gate).not.toContain('effectiveReadIsolationRequested: cfg.readIsolation'); - - const notify = gate.indexOf("type: 'user_notify'"); + expect(gate).not.toContain("type: 'user_notify'"); + const compatibilityCheck = gate.indexOf('backendSandboxCompatibilityError({'); const failure = gate.indexOf('throw new Error'); - expect(notify).toBeGreaterThan(-1); - expect(failure).toBeGreaterThan(notify); + expect(compatibilityCheck).toBeGreaterThan(-1); + expect(failure).toBeGreaterThan(compatibilityCheck); + expect(gate).toContain( + 'throw new Error(backendSandboxCompatibilityUserMessage(backendIsolationGate))', + ); + expect(workerSource.indexOf('const selectBackend =', start)).toBeGreaterThan(end); }); }); diff --git a/test/kill-worker-orphaned-backend.test.ts b/test/kill-worker-orphaned-backend.test.ts index 671bab981..41f889464 100644 --- a/test/kill-worker-orphaned-backend.test.ts +++ b/test/kill-worker-orphaned-backend.test.ts @@ -15,15 +15,27 @@ * * Run: pnpm vitest run test/kill-worker-orphaned-backend.test.ts */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { DaemonSession } from '../src/core/types.js'; -const { tmuxKill, herdrKill, herdrKillAgent, zellijKill, zmxKill, getBotMock } = vi.hoisted(() => ({ +const { + tmuxKill, + herdrKill, + herdrKillAgent, + zellijKill, + zmxKill, + zmxList, + getBotMock, +} = vi.hoisted(() => ({ tmuxKill: vi.fn(), herdrKill: vi.fn(), herdrKillAgent: vi.fn(), zellijKill: vi.fn(), zmxKill: vi.fn(), + zmxList: vi.fn(() => [] as string[]), getBotMock: vi.fn(() => ({ resolvedAllowedUsers: [], config: {} })), })); @@ -44,7 +56,7 @@ vi.mock('../src/adapters/backend/zmx-backend.js', () => ({ ZmxBackend: { sessionName: (id: string) => `bmx-${id.slice(0, 8)}`, killManagedSession: zmxKill, - listBotmuxSessions: vi.fn(() => []), + listBotmuxSessions: zmxList, }, })); @@ -72,7 +84,13 @@ vi.mock('../src/utils/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, })); -import { killWorker, teardownAuthoritativePersistentBackingBeforeClose } from '../src/core/worker-pool.js'; +import { config } from '../src/config.js'; +import { + killStalePids, + killWorker, + teardownAuthoritativePersistentBackingBeforeClose, +} from '../src/core/worker-pool.js'; +import * as sessionStore from '../src/services/session-store.js'; const SID = 'abcd1234-0000-0000-0000-000000000000'; const EXPECTED_NAME = 'bmx-abcd1234'; @@ -92,9 +110,43 @@ const ds = (over: Partial<DaemonSession> = {}, initOver: any = {}): DaemonSessio beforeEach(() => { vi.clearAllMocks(); + zmxList.mockReturnValue([]); getBotMock.mockReturnValue({ resolvedAllowedUsers: [], config: {} } as any); }); +describe('killStalePids — ZMX CLI-change cleanup', () => { + it('keeps the complete owning session identity and does not issue a second name-only kill', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'botmux-zmx-cli-change-')); + const previousDataDirEnv = process.env.SESSION_DATA_DIR; + const previousBackendType = config.daemon.backendType; + const previousCliId = config.daemon.cliId; + + try { + config.session.dataDir = dataDir; + config.daemon.backendType = 'zmx'; + config.daemon.cliId = 'codex'; + sessionStore.init('zmx-cli-change-test'); + writeFileSync(join(dataDir, 'last-cli-id-zmx'), 'claude-code', 'utf8'); + zmxList.mockReturnValue([EXPECTED_NAME]); + + expect(() => killStalePids([{ + sessionId: SID, + backendType: 'zmx', + } as any])).not.toThrow(); + + expect(zmxKill).toHaveBeenCalledTimes(1); + expect(zmxKill).toHaveBeenCalledWith(EXPECTED_NAME, SID); + } finally { + sessionStore.init(); + config.daemon.cliId = previousCliId; + config.daemon.backendType = previousBackendType; + if (previousDataDirEnv === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = previousDataDirEnv; + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); + describe('killWorker — orphaned backing session teardown (no live worker)', () => { it('destroys the tmux backing session by deterministic name', () => { const d = ds({ managedTurnOrigin: { capability: 'cap-stale', turnId: 'om-stale' } }, { backendType: 'tmux' }); diff --git a/test/raw-input-followup-atomicity.test.ts b/test/raw-input-followup-atomicity.test.ts index 1362f996d..2c97c509d 100644 --- a/test/raw-input-followup-atomicity.test.ts +++ b/test/raw-input-followup-atomicity.test.ts @@ -225,12 +225,14 @@ describe('post-settle restart fence', () => { const fence = flush.indexOf('if (cliRestartInProgress) return;', detector); const startup = flush.indexOf('await runStartupCommands()', detector); const rawShift = flush.indexOf('freshnessInputQueue.takeRaw()', detector); - const writeInput = flush.indexOf('cliAdapter.writeInput(backend', detector); + const writeStructuredInput = flush.indexOf('cliAdapter.writeStructuredInput(', detector); + const writeInput = flush.indexOf('cliAdapter.writeInput(', detector); expect(detector).toBeGreaterThanOrEqual(0); expect(fence).toBeGreaterThan(detector); // Fence must precede every downstream write/shift the settle await exposed. expect(startup).toBeGreaterThan(fence); expect(rawShift).toBeGreaterThan(fence); + expect(writeStructuredInput).toBeGreaterThan(fence); expect(writeInput).toBeGreaterThan(fence); }); diff --git a/test/restore-zombie-close.test.ts b/test/restore-zombie-close.test.ts index 947484dc1..bc94e4eb5 100644 --- a/test/restore-zombie-close.test.ts +++ b/test/restore-zombie-close.test.ts @@ -193,8 +193,12 @@ import { restoreActiveSessions, closeCliMismatchedSessionsForBot } from '../src/ import { TmuxBackend } from '../src/adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../src/adapters/backend/herdr-backend.js'; import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; -import { forkWorker, closeSession } from '../src/core/worker-pool.js'; -import { forkAdoptWorker } from '../src/core/worker-pool.js'; +import { + closeSession, + forkAdoptWorker, + forkWorker, + setActiveSessionSafe, +} from '../src/core/worker-pool.js'; import { announceSessionRow } from '../src/core/session-activity.js'; import * as sessionStore from '../src/services/session-store.js'; import { sessionKey } from '../src/core/types.js'; diff --git a/test/session-delete-close-barrier.test.ts b/test/session-delete-close-barrier.test.ts index a5426a5e0..111e1d8df 100644 --- a/test/session-delete-close-barrier.test.ts +++ b/test/session-delete-close-barrier.test.ts @@ -74,7 +74,7 @@ describe('daemon close barrier used by botmux delete', () => { expect(sessionStore.getSession(session.sessionId)?.status).toBe('closed'); releaseCleanup(); - await expect(pending).resolves.toEqual({ ok: true, alreadyClosed: false }); + await expect(pending).resolves.toEqual({ ok: true, alreadyClosed: false, known: true }); expect(docSubsStore.removeDocSubscription).toHaveBeenCalledWith( dataDir, 'app-delete-barrier', diff --git a/test/tmux-reattach-backend.test.ts b/test/tmux-reattach-backend.test.ts index 6ce7903fc..bf3861bb0 100644 --- a/test/tmux-reattach-backend.test.ts +++ b/test/tmux-reattach-backend.test.ts @@ -274,7 +274,7 @@ describe('backendSandboxCompatibilityError', () => { })).toMatch(/does not support/); }); - it('allows unsandboxed ZMX, including a Linux legacy readIsolation no-op', () => { + it('allows unsandboxed ZMX and does not gate isolation-capable backends', () => { expect(backendSandboxCompatibilityError({ backendType: 'zmx', fileSandboxRequested: false, diff --git a/test/zmx-backend-helpers.test.ts b/test/zmx-backend-helpers.test.ts index ccff66b43..1a72c970a 100644 --- a/test/zmx-backend-helpers.test.ts +++ b/test/zmx-backend-helpers.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal<typeof import('node:child_process')>(); @@ -33,6 +33,10 @@ beforeEach(() => { execFileSyncMock.mockReset(); }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe('zmx env/probe helpers', () => { it('strips inherited session vars but preserves the socket dir', () => { const env = zmxEnv({ @@ -55,6 +59,7 @@ describe('zmx env/probe helpers', () => { // ENOENT may be reported as "not on PATH" — anything else must surface the // real cause, or a Linux operator reinstalls zmx forever chasing the wrong bug. it('reports the real cause instead of blaming PATH when the socket dir is unusable', () => { + vi.stubEnv('ZMX_DIR', '/tmp/zmx-readonly'); const failure: any = new Error('Command failed'); failure.status = 1; failure.stderr = Buffer.from('error: ReadOnlyFileSystem\n'); @@ -66,7 +71,7 @@ describe('zmx env/probe helpers', () => { expect(result.reason).toContain('ReadOnlyFileSystem'); expect(result.reason).not.toContain('不在 PATH 上'); // The effective socket-dir source is actionable on a headless daemon. - expect(result.reason).toContain('ZMX_DIR'); + expect(result.reason).toContain('ZMX_DIR=/tmp/zmx-readonly'); } }); From 0dc3601c0e8af8197fb3923f2b6b679fd86498a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Tue, 28 Jul 2026 22:11:11 +0800 Subject: [PATCH 13/26] =?UTF-8?q?fix(zmx):=20=E9=98=BB=E6=AD=A2=E5=90=91?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E4=BC=9A=E8=AF=9D=E6=B3=A8=E5=85=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=8C=89=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/zmx-backend.ts | 24 +++++++++++++++++++++--- test/zmx-backend-recovery.test.ts | 23 ++++++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index 478bb5621..018e080ec 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -1326,8 +1326,17 @@ export class ZmxBackend implements SessionBackend { // status 0. Empty stdout is part of the transport contract. if (stdout.trim()) { logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`); - this.verifyBackingIdentity('send rejection'); - if (bracketedPaste || offset > 0) this.abortPartialSend(bracketedPaste); + const probe = this.verifyBackingIdentity('send rejection'); + if (bracketedPaste || offset > 0) { + if (probe.state === 'compatible') { + this.abortPartialSend(bracketedPaste); + } else { + logger.warn( + `[zmx:${this.sessionName}] skipped partial-send recovery: ` + + `backing identity is ${probe.state}`, + ); + } + } return false; } } catch (err) { @@ -1338,7 +1347,16 @@ export class ZmxBackend implements SessionBackend { ); // Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying // can duplicate a prompt that the daemon already queued. - if (bracketedPaste || offset > 0) this.abortPartialSend(bracketedPaste); + if (bracketedPaste || offset > 0) { + if (probe.state === 'compatible') { + this.abortPartialSend(bracketedPaste); + } else { + logger.warn( + `[zmx:${this.sessionName}] skipped partial-send recovery: ` + + `backing identity is ${probe.state}`, + ); + } + } return false; } } diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index d3ff096eb..173d89c9c 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -107,6 +107,7 @@ interface FakeZmxState { historyStderr: string; sendInputs: Buffer[]; failSendAt: number | null; + replaceOnFailedSend: boolean; deferHistory: boolean; readyPath: string | null; } @@ -186,6 +187,7 @@ describe('ZmxBackend history-authoritative transport', () => { historyStderr: '', sendInputs: [], failSendAt: null, + replaceOnFailedSend: false, deferHistory: false, readyPath: null, }; @@ -223,9 +225,13 @@ describe('ZmxBackend history-authoritative transport', () => { } if (command === 'send') { state.sendInputs.push(Buffer.from(options?.input ?? '')); - return state.failSendAt === state.sendInputs.length - ? `session ${SESSION} is unresponsive\n` - : ''; + if (state.failSendAt === state.sendInputs.length) { + if (state.replaceOnFailedSend) { + state.sessionId = 'replacement-session-id'; + } + return `session ${SESSION} is unresponsive\n`; + } + return ''; } if (command === 'kill') { state.exists = false; @@ -528,6 +534,17 @@ describe('ZmxBackend history-authoritative transport', () => { expect(state.sendInputs[2]!.toString()).toBe('\x1b[201~\x03\n'); }); + it('does not inject partial-send recovery into a replacement session', () => { + const backend = spawnBackend(); + state.failSendAt = 2; + state.replaceOnFailedSend = true; + + expect(() => backend.pasteText('x'.repeat(2_000))).toThrow(/粘贴发送失败/); + expect(state.sendInputs).toHaveLength(2); + expect(state.sendInputs[0]!.subarray(0, 6).toString()).toBe('\x1b[200~'); + expect(state.sendInputs).not.toContainEqual(Buffer.from('\x1b[201~\x03\n')); + }); + it('serves captureCurrentScreen from the cache without spawning history', async () => { state.history = 'cached 你好😀\n'; const backend = spawnBackend(); From 442b3f9cbcd72c4335e2ea986c8dfaa5c4f4fdd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Tue, 28 Jul 2026 22:28:14 +0800 Subject: [PATCH 14/26] =?UTF-8?q?fix(worker):=20=E8=B4=AF=E9=80=9A=20TUI?= =?UTF-8?q?=20=E8=BE=93=E5=85=A5=E5=A4=B1=E8=B4=A5=E4=B8=8E=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/worker-ipc.ts | 29 ++++ src/core/worker-pool.ts | 55 ++++++- src/i18n/en.ts | 3 + src/i18n/zh.ts | 3 + src/im/lark/card-builder.ts | 19 ++- src/im/lark/card-handler.ts | 109 ++++++++----- src/types.ts | 22 ++- src/utils/stuck-key-guard.ts | 25 ++- src/utils/tui-input-delivery.ts | 90 +++++++++++ src/worker.ts | 196 +++++++++++++++++------- test/card-builder.test.ts | 13 ++ test/stuck-card-handler.test.ts | 121 ++++++++++++++- test/stuck-key-guard.test.ts | 19 ++- test/stuck-warning-integration.test.ts | 93 ++++++++++- test/tui-input-delivery.test.ts | 90 +++++++++++ test/tui-input-failure-contract.test.ts | 73 +++++++++ test/worker-ipc.test.ts | 41 +++++ 17 files changed, 889 insertions(+), 112 deletions(-) create mode 100644 src/core/worker-ipc.ts create mode 100644 src/utils/tui-input-delivery.ts create mode 100644 test/tui-input-delivery.test.ts create mode 100644 test/tui-input-failure-contract.test.ts create mode 100644 test/worker-ipc.test.ts diff --git a/src/core/worker-ipc.ts b/src/core/worker-ipc.ts new file mode 100644 index 000000000..665034a86 --- /dev/null +++ b/src/core/worker-ipc.ts @@ -0,0 +1,29 @@ +import type { ChildProcess } from 'node:child_process'; +import type { DaemonToWorker } from '../types.js'; + +/** + * Resolve only after Node confirms that an IPC message was sent. + * + * ChildProcess.send() returning false is not itself a delivery failure: it can + * mean the message is queued behind IPC backpressure. The callback is the + * authoritative success/error signal. + */ +export function sendWorkerIpc( + worker: ChildProcess, + message: DaemonToWorker, +): Promise<void> { + if (worker.killed || worker.connected === false) { + return Promise.reject(new Error('worker IPC channel is not connected')); + } + + return new Promise((resolve, reject) => { + try { + worker.send(message, (error) => { + if (error) reject(error); + else resolve(); + }); + } catch (error) { + reject(error); + } + }); +} diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index d464cf7e0..f1809eaf1 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -21,7 +21,7 @@ import * as asyncTriggerStore from '../services/async-trigger-store.js'; import { persistStreamCardState, rememberLastCliInput } from './session-manager.js'; import { fallbackTurnId, isSubstituteTurn } from './reply-target.js'; import { updateMessage, deleteMessage, sendEphemeralCard, sendUserMessage, addReaction, removeReaction, getMessageChatId, MessageWithdrawnError } from '../im/lark/client.js'; -import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildRelayedFrozenCard, getCliDisplayName } from '../im/lark/card-builder.js'; +import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildTuiPromptFailedCard, buildRelayedFrozenCard, getCliDisplayName } from '../im/lark/card-builder.js'; import { loadFrozenCards, saveFrozenCards } from '../services/frozen-card-store.js'; import { hashUrlForLog, cancelRiffTaskById } from '../adapters/backend/riff-backend.js'; import { logger } from '../utils/logger.js'; @@ -3755,6 +3755,13 @@ function setupWorkerHandlers( case 'tui_prompt_resolved': { // TUI prompt is no longer showing — update card if it exists logger.info(`[${t}] TUI prompt resolved${msg.selectedText ? `: ${msg.selectedText}` : ''}`); + if (msg.cardMessageId && ds.tuiPromptCardId !== msg.cardMessageId) { + logger.info( + `[${t}] Ignored stale TUI resolved ACK for ${msg.cardMessageId} ` + + `(active=${ds.tuiPromptCardId ?? 'none'})`, + ); + break; + } if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) { ds.tuiPromptCardId = undefined; ds.tuiPromptOptions = undefined; @@ -3772,6 +3779,52 @@ function setupWorkerHandlers( break; } + case 'tui_prompt_submit_failed': { + if (ds.worker !== worker || ds.workerGeneration !== workerGeneration) break; + const matchesTuiCard = !!msg.cardMessageId + && ds.tuiPromptCardId === msg.cardMessageId; + const matchesStuckCard = msg.stuckNonce !== undefined + && ds.stuckWarningNonce === msg.stuckNonce + && !!ds.stuckWarningCardId; + if (!matchesTuiCard && !matchesStuckCard) { + logger.info( + `[${t}] Ignored stale TUI submit failure ` + + `(card=${msg.cardMessageId ?? 'none'}, stuckNonce=${msg.stuckNonce ?? 'none'})`, + ); + break; + } + + const failureText = tr('worker.tui_submit_failed', { + cliName: getCliDisplayName(effectiveCliId), + }, loc); + if (!managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) { + const failedCard = buildTuiPromptFailedCard(failureText, loc); + const failedCardId = matchesTuiCard + ? ds.tuiPromptCardId + : ds.stuckWarningCardId; + if (failedCardId) { + updateMessage(ds.larkAppId, failedCardId, failedCard).catch(err => + logger.debug(`[${t}] Failed to update TUI failure card: ${err}`), + ); + } + try { + await scopedReply(failureText, 'text', msg.turnId); + } catch (err: any) { + logger.error(`[${t}] Failed to deliver TUI submit failure: ${err.message}`); + } + } + + if (matchesTuiCard) { + ds.tuiPromptCardId = undefined; + ds.tuiPromptOptions = undefined; + ds.tuiPromptMultiSelect = undefined; + ds.tuiToggledIndices = undefined; + } + if (matchesStuckCard) clearStuckWarningAuthority(ds); + publishAttentionPatch(ds); + break; + } + case 'stuck_warning': { // Ignore stuck_warning from a stale worker generation — the CLI may have // been replaced and a new worker owns the session now. diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 505b0edba..b28475b8a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -37,6 +37,7 @@ export const messages: Record<string, string> = { 'card.status.limited': 'Limit reached', 'card.status.retry_ready': 'Ready to retry', 'card.status.executing': 'Executing…', + 'card.status.failed': 'Failed', 'card.status.session_closed': '🛑 Session Closed', 'card.status.relay_frozen': '🔄 Relayed away', 'card.status.selected': 'Selected', @@ -711,6 +712,7 @@ export const messages: Record<string, string> = { 'card.action.tui_select_title': 'Select options', 'card.action.tui_custom_input': 'Custom input', 'card.action.tui_done': 'Done', + 'card.action.tui_ipc_failed': '⚠️ The TUI action was not sent; the worker may be unavailable. Please try again.', 'card.action.continue_using_current_repo': 'Keeping current repo: {cwd}', 'card.action.retry_last_task_missing': '⚠️ Cannot find the last task to retry. Send a new message instead.', 'card.action.retry_last_task_unavailable': '⚠️ This retry state is no longer active. Send a new message instead.', @@ -723,6 +725,7 @@ export const messages: Record<string, string> = { 'worker.crash_recent_output': 'Recent terminal output:', 'worker.start_failed': '⚠️ The {cliName} session failed to start: {reason}\nCheck the Agent/backend settings in Dashboard and the installation environment on the daemon host, then resend your message to retry.', 'worker.start_exited_early': 'The worker exited before becoming ready (exit code: {code}); see the Botmux logs for details.', + 'worker.tui_submit_failed': '⚠️ The TUI answer could not be confirmed as delivered to {cliName}. The CLI may still be waiting for input; open the local terminal or send a new message to recover.', 'worker.empty_final_completed': '⚠️ {cliName} reported this turn as completed, but botmux captured no final text from the terminal transcript and saw no tracked reply for this turn. If you already replied via a redirected send (--top-level / --into / --override-chat), you can ignore this. Otherwise open the web terminal to inspect the last output, or resend a message to continue the session.', // ─── CLI setup wizard / pm2 lifecycle (no per-bot context) ─────────────── diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 469e8b0a9..bc0df4fd1 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -40,6 +40,7 @@ export const messages: Record<string, string> = { 'card.status.limited': '限额已达', 'card.status.retry_ready': '可重试', 'card.status.executing': '正在执行…', + 'card.status.failed': '提交失败', 'card.status.session_closed': '🛑 会话已关闭', 'card.status.relay_frozen': '🔄 会话已搬迁', 'card.status.selected': '已选择', @@ -714,6 +715,7 @@ export const messages: Record<string, string> = { 'card.action.tui_select_title': 'Select options', 'card.action.tui_custom_input': 'Custom input', 'card.action.tui_done': 'Done', + 'card.action.tui_ipc_failed': '⚠️ TUI 操作未发送(worker 可能不可用),请重试。', 'card.action.continue_using_current_repo': '继续使用当前仓库:{cwd}', 'card.action.retry_last_task_missing': '⚠️ 找不到上一条任务,无法重发。请直接发送新的消息。', 'card.action.retry_last_task_unavailable': '⚠️ 当前重试状态已失效。请直接发送新的消息。', @@ -726,6 +728,7 @@ export const messages: Record<string, string> = { 'worker.crash_recent_output': '最近终端输出:', 'worker.start_failed': '⚠️ {cliName} 会话启动失败:{reason}\n请检查 Dashboard 的 Agent / 后端配置和 daemon 所在机器的安装环境,修复后重发消息即可重试。', 'worker.start_exited_early': 'worker 在就绪前退出(exit code: {code});详细错误可查看 Botmux 日志。', + 'worker.tui_submit_failed': '⚠️ TUI 答案未能确认送达 {cliName}。CLI 可能仍在等待输入;请打开本机终端处理,或发送一条新消息解除并继续。', 'worker.empty_final_completed': '⚠️ {cliName} 已报告本轮处理完成,但 botmux 没有从终端记录里捕获到最终文本,也没有追踪到本轮的回复。若你已经通过改道发送(--top-level / --into / --override-chat)回复过,可忽略本提示;否则请打开 Web 终端查看最后输出,或直接重发消息让会话继续。', // ─── CLI setup wizard / pm2 lifecycle (no per-bot context) ─────────────── diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index 9be48af18..45466ada2 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -1475,6 +1475,24 @@ export function buildTuiPromptResolvedCard(selectedText: string, locale?: Locale return JSON.stringify(card); } +/** Build a terminal failure state when worker/backend input was not confirmed. */ +export function buildTuiPromptFailedCard(message: string, locale?: Locale): string { + const card = { + config: { wide_screen_mode: true }, + header: { + title: { tag: 'plain_text', content: t('card.status.failed', undefined, locale) }, + template: 'red', + }, + elements: [ + { + tag: 'div', + text: { tag: 'lark_md', content: escapeMd(message) }, + }, + ], + }; + return JSON.stringify(card); +} + // ─── Adopt cards ───────────────────────────────────────────────────────────── function formatDuration(ms: number): string { @@ -2000,4 +2018,3 @@ export function buildCodexAppThreadSelectCard(threads: CodexAppThreadSummary[], }; return JSON.stringify(card); } - diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 85d3aa762..6704ee7ae 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -9,7 +9,7 @@ import { config } from '../../config.js'; import { getBot, getAllBots, getOwnerOpenId } from '../../bot-registry.js'; import { canOperate, canTalk } from './event-dispatcher.js'; import { updateMessage, deleteMessage, replyMessage, sendMessage, sendUserMessage, sendEphemeralCard, getMessageDetail, isHumanOpenId, resolveUserUnionId as defaultResolveUserUnionId } from './client.js'; -import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildTuiPromptResolvedCard, buildGrantResultCard, buildGrantNotifyCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js'; +import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildGrantResultCard, buildGrantNotifyCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js'; import { findConfigField, applyConfigField, coerceConfigValue, getConfigCardData } from '../../services/bot-config-store.js'; import { updateBotGrantPrefs } from '../../services/grant-prefs-store.js'; import { writeTeamRoleFile, deleteTeamRoleFile } from '../../core/role-resolver.js'; @@ -74,6 +74,7 @@ import { getSessionWorkingDir, buildNewTopicCliInput, getAvailableBots, persistS import { markInitialUserTurnPending } from '../../core/initial-user-turn.js'; import { publishAttentionPatch, announcePendingRepoSession } from '../../core/session-activity.js'; import { fallbackTurnId } from '../../core/reply-target.js'; +import { sendWorkerIpc } from '../../core/worker-ipc.js'; import { validateWorkingDir } from '../../core/working-dir.js'; import type { DaemonToWorker, DisplayMode, TermActionKey } from '../../types.js'; import { sessionKey, sessionAnchorId, frozenDisplayMode, markRepoCardConsumed, isRepoCardConsumed, isActiveRepoCard, claimCurrentRepoCard } from '../../core/types.js'; @@ -2026,6 +2027,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe if (ds.worker) { let allKeys: string[] = []; let isFinalStuck = false; + let dispatchedKeys = false; if (isActiveTuiCard && ds.tuiToggledIndices?.length && ds.tuiPromptOptions) { // Send each toggled option's keys in sequence for (const ti of ds.tuiToggledIndices.sort((a, b) => a - b)) { @@ -2102,11 +2104,36 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe const stuckCliLifetime = isActiveStuckCard ? ds.stuckWarningCliLifetime : undefined; const stuckPage = isActiveStuckCard ? ds.stuckWarningPageType : undefined; const effectiveFinal = isFinal || isFinalStuck; - ds.worker.send({ type: 'tui_keys', keys: allKeys, isFinal: effectiveFinal, rearmStuckDetector: isStuckWarningEnter, stuckNonce, stuckCliLifetime, stuckPageType: stuckPage } as DaemonToWorker); + const resolveText = isActiveTuiCard && ds.tuiToggledIndices?.length + ? ds.tuiToggledIndices.map(i => ds.tuiPromptOptions?.[i]?.text).filter(Boolean).join(', ') + : selectedText; + try { + await sendWorkerIpc(ds.worker, { + type: 'tui_keys', + keys: allKeys, + isFinal: effectiveFinal, + rearmStuckDetector: isStuckWarningEnter, + stuckNonce, + stuckCliLifetime, + stuckPageType: stuckPage, + cardMessageId: isActiveTuiCard ? cardMessageId : undefined, + selectedText: resolveText || selectedText, + } as DaemonToWorker); + dispatchedKeys = true; + } catch (err) { + if (isActiveStuckCard) ds.stuckWarningProcessing = false; + logger.warn(`[${tag(ds)}] TUI key IPC delivery failed: ${err instanceof Error ? err.message : String(err)}`); + return { + toast: { + type: 'warning', + content: t('card.action.tui_ipc_failed', undefined, localeForBot(ds.larkAppId)), + }, + }; + } logger.info(`[${tag(ds)}] TUI keys: [${allKeys.join(',')}] final=${effectiveFinal} rearmStuck=${isStuckWarningEnter} stuckNonce=${stuckNonce ?? 'none'} — "${selectedText}"`); } - if (isFinal || isFinalStuck) { + if ((isFinal || isFinalStuck) && dispatchedKeys) { const resolveText = isActiveTuiCard && ds.tuiToggledIndices?.length ? ds.tuiToggledIndices.map(i => ds.tuiPromptOptions?.[i]?.text).filter(Boolean).join(', ') : selectedText; @@ -2115,8 +2142,9 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe // For a stuck-warning card, do NOT clear authority or render success // here. The worker may still reject the keys (stale screen). We show a // "processing" state to block duplicate clicks, and wait for the - // worker's tui_keys_delivered (success) or stuck_warning_expired - // ("page changed, not sent") ACK before resolving the card. + // worker's tui_keys_delivered (success), stuck_warning_expired + // ("page changed, not sent"), or tui_prompt_submit_failed ACK before + // resolving the card. if (isActiveStuckCard) { if (cardMessageId) { const processingCard = buildTuiPromptProcessingCard('处理中…', locDs); @@ -2127,24 +2155,9 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe publishAttentionPatch(ds); try { return JSON.parse(buildTuiPromptProcessingCard('处理中…', locDs)); } catch { /* fall through */ } } - // Normal TUI prompt card (ScreenAnalyzer): resolve immediately. - if (cardMessageId) { - setTimeout(() => { - const resolvedCard = buildTuiPromptResolvedCard(finalText, locDs); - updateMessage(ds.larkAppId, cardMessageId, resolvedCard).catch(err => - logger.debug(`[${tag(ds)}] Failed to update TUI prompt card: ${err}`), - ); - }, allKeys.length * 100 + 500); - } - // Clear state only for the card that was actually clicked — a stuck - // card click must NOT wipe the ScreenAnalyzer TUI prompt state (or - // vice versa) if both coexist / race. - if (cardMessageId === ds.tuiPromptCardId) { - ds.tuiPromptCardId = undefined; - ds.tuiPromptOptions = undefined; - ds.tuiPromptMultiSelect = undefined; - ds.tuiToggledIndices = undefined; - } + // Normal TUI prompt cards also remain in processing until the worker + // confirms backend delivery via tui_prompt_resolved. A worker/backend + // rejection produces tui_prompt_submit_failed instead. publishAttentionPatch(ds); try { return JSON.parse(buildTuiPromptProcessingCard(finalText, locDs)); } catch { /* fall through */ } } @@ -2157,23 +2170,43 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe let inputKeys: string[] = []; try { inputKeys = JSON.parse(value?.input_keys ?? '[]'); } catch { /* bad json */ } const locDs = localeForBot(ds.larkAppId); - if (ds.worker && inputText && inputKeys.length > 0) { - // Atomic IPC — worker handles keys + text in one flow to avoid race - ds.worker.send({ type: 'tui_text_input', keys: inputKeys, text: inputText } as DaemonToWorker); - logger.info(`[${tag(ds)}] TUI text input: "${inputText}" (keys: ${JSON.stringify(inputKeys)})`); - if (cardMessageId) { - const resolvedCard = buildTuiPromptResolvedCard(inputText, locDs); - updateMessage(ds.larkAppId, cardMessageId, resolvedCard).catch(err => - logger.debug(`[${tag(ds)}] Failed to update TUI prompt card: ${err}`), - ); - } - ds.tuiPromptCardId = undefined; - ds.tuiPromptOptions = undefined; - publishAttentionPatch(ds); + const isActiveTuiCard = !!ds.tuiPromptCardId && cardMessageId === ds.tuiPromptCardId; + if (!isActiveTuiCard) { + logger.info(`[${tag(ds)}] tui_text_input from stale card ${cardMessageId} — ignored`); + return; } + if (!ds.worker || !inputText || inputKeys.length === 0) { + logger.info( + `[${tag(ds)}] TUI text input not dispatched ` + + `(worker=${!!ds.worker}, text=${!!inputText}, keys=${inputKeys.length})`, + ); + return { + toast: { + type: 'warning', + content: t('card.action.tui_ipc_failed', undefined, locDs), + }, + }; + } + // Atomic IPC — worker handles keys + text in one flow to avoid race try { - return JSON.parse(buildTuiPromptResolvedCard(inputText || t('card.action.tui_custom_input', undefined, locDs), locDs)); - } catch { /* fall through */ } + await sendWorkerIpc(ds.worker, { + type: 'tui_text_input', + keys: inputKeys, + text: inputText, + cardMessageId, + } as DaemonToWorker); + } catch (err) { + logger.warn(`[${tag(ds)}] TUI text IPC delivery failed: ${err instanceof Error ? err.message : String(err)}`); + return { + toast: { + type: 'warning', + content: t('card.action.tui_ipc_failed', undefined, locDs), + }, + }; + } + logger.info(`[${tag(ds)}] TUI text input: "${inputText}" (keys: ${JSON.stringify(inputKeys)})`); + publishAttentionPatch(ds); + try { return JSON.parse(buildTuiPromptProcessingCard(inputText, locDs)); } catch { /* fall through */ } } // Compatibility path for cards emitted before open_local_cli was introduced. diff --git a/src/types.ts b/src/types.ts index 6ce21839b..0b2fd783f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -639,11 +639,26 @@ export type DaemonToWorker = // diagnostic shell (bmx-diag-<sid>) preserving the last output. Deferred from // onExit so transient auto-restarted exits don't park-then-tear-down. | { type: 'park_diagnostic' } - | { type: 'tui_keys'; keys: string[]; isFinal: boolean; rearmStuckDetector?: boolean; stuckNonce?: number; stuckCliLifetime?: number; stuckPageType?: string } + | { + type: 'tui_keys'; + keys: string[]; + isFinal: boolean; + rearmStuckDetector?: boolean; + stuckNonce?: number; + stuckCliLifetime?: number; + stuckPageType?: string; + cardMessageId?: string; + selectedText?: string; + } // 白名单 TUI 命令注入(/slash 路由)。cwd 移动不走注入——角色切换用 // restart+updateWorkingDir 的 respawn,避免绕过 cd 路由的角色库硬校验。 | { type: 'inject_command'; command: string } - | { type: 'tui_text_input'; keys: string[]; text: string } + | { + type: 'tui_text_input'; + keys: string[]; + text: string; + cardMessageId?: string; + } // CoCo AskUserQuestion 作答:daemon 在 ask 结算后下发,worker 等原生 picker 渲染后 // 用 navKeys 驱动它选择+导航。needsReviewSubmit=true(多题)时 navKeys 停在 Review // 屏,worker 再补一记 Enter 提交;单题 navKeys 直接提交(无 Review)。comment 非空 @@ -706,7 +721,8 @@ export type WorkerToDaemon = | { type: 'error'; message: string; turnId?: string; dispatchAttempt?: number } | { type: 'bridge_source_session'; bridge: 'hermes'; sourceSessionId: string } | { type: 'tui_prompt'; description: string; options: Array<{ label?: string; text: string; selected: boolean; type?: string; keys?: string[] }>; multiSelect?: boolean; turnId?: string; dispatchAttempt?: number } - | { type: 'tui_prompt_resolved'; selectedText?: string; turnId?: string; dispatchAttempt?: number } + | { type: 'tui_prompt_resolved'; selectedText?: string; cardMessageId?: string; turnId?: string; dispatchAttempt?: number } + | { type: 'tui_prompt_submit_failed'; cardMessageId?: string; stuckNonce?: number; turnId?: string; dispatchAttempt?: number } | { type: 'stuck_warning'; elapsedMs: number; snapshot: string; matchedPattern?: string; turnId?: string; dispatchAttempt?: number; cliLifetime?: number } | { type: 'stuck_warning_expired'; nonce: number; turnId?: string; dispatchAttempt?: number } | { type: 'tui_keys_delivered'; nonce: number; turnId?: string; dispatchAttempt?: number } diff --git a/src/utils/stuck-key-guard.ts b/src/utils/stuck-key-guard.ts index 639d09dc8..7fc8f087f 100644 --- a/src/utils/stuck-key-guard.ts +++ b/src/utils/stuck-key-guard.ts @@ -11,7 +11,7 @@ * - fresh capture returns null or throws → no keys written, send expired * - backend/lifetime changed after capture, OR page type no longer matches * → no keys written, send expired - * - write success → send delivered; write returns false or throws → send expired + * - write success → send delivered; write returns false or throws → send failed * * backend/lifetime are read via getters (not value snapshots) so the guard can * re-check LIVE state after `await capture()` — a backend replacement or CLI @@ -29,7 +29,7 @@ export interface StuckKeyGuardMessage { export interface StuckKeyGuardResult { /** What was sent back to the daemon, if anything. */ - sent: 'delivered' | 'expired' | 'none'; + sent: 'delivered' | 'expired' | 'failed' | 'none'; /** Whether keys were actually written to the backend. */ wroteKeys: boolean; } @@ -73,6 +73,7 @@ export interface StuckKeyGuardDeps { /** Sends a message back to the daemon. */ sendExpired: (nonce: number, turnId: string | undefined, dispatchAttempt: number | undefined) => void; sendDelivered: (nonce: number, turnId: string | undefined, dispatchAttempt: number | undefined) => void; + sendFailed: (nonce: number, turnId: string | undefined, dispatchAttempt: number | undefined) => void; log: (msg: string) => void; } @@ -87,7 +88,21 @@ export async function processStuckWarningTuiKeys( deps: StuckKeyGuardDeps, ): Promise<StuckKeyGuardResult> { const { stuckNonce, stuckPageType, stuckCliLifetime, keys, isFinal } = msg; - const { getBackend, getCurrentLifetime, renderCols, renderRows, turnId, dispatchAttempt, capture, match, writeKeys, sendExpired, sendDelivered, log } = deps; + const { + getBackend, + getCurrentLifetime, + renderCols, + renderRows, + turnId, + dispatchAttempt, + capture, + match, + writeKeys, + sendExpired, + sendDelivered, + sendFailed, + log, + } = deps; // P1-2: validate the CLI lifetime the card was issued for BEFORE doing // anything else. If the CLI restarted between card post and click, @@ -143,6 +158,6 @@ export async function processStuckWarningTuiKeys( sendDelivered(stuckNonce, turnId, dispatchAttempt); return { sent: 'delivered', wroteKeys: true }; } - sendExpired(stuckNonce, turnId, dispatchAttempt); - return { sent: 'expired', wroteKeys: false }; + sendFailed(stuckNonce, turnId, dispatchAttempt); + return { sent: 'failed', wroteKeys: false }; } diff --git a/src/utils/tui-input-delivery.ts b/src/utils/tui-input-delivery.ts new file mode 100644 index 000000000..7b68a41c5 --- /dev/null +++ b/src/utils/tui-input-delivery.ts @@ -0,0 +1,90 @@ +import type { PtyHandle } from '../adapters/cli/types.js'; + +export interface TuiKeySequenceOptions { + isCurrent?: () => boolean; + pause?: (ms: number) => Promise<void>; + keyDelayMs?: number; +} + +export type TuiInputWriteResult = void | { + submitted: boolean; +}; + +const defaultPause = (ms: number): Promise<void> => + new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Deliver one TUI key sequence without crossing a backend-generation boundary. + * + * `void` / `true` preserve the established PTY/tmux contract. An explicit + * `false` is a definite transport rejection and stops the sequence before any + * later key can be injected into an unknown TUI state. + */ +export async function sendTuiKeySequence( + target: PtyHandle, + keys: string[], + keyToAnsi: Readonly<Record<string, string>>, + options: TuiKeySequenceOptions = {}, +): Promise<boolean> { + const isCurrent = options.isCurrent ?? (() => true); + const pause = options.pause ?? defaultPause; + const keyDelayMs = options.keyDelayMs ?? 100; + + for (const key of keys) { + if (!isCurrent()) return false; + if (typeof target.sendSpecialKeys === 'function') { + if (target.sendSpecialKeys(key) === false) return false; + } else { + target.write(keyToAnsi[key] ?? key); + } + if (keyDelayMs > 0) await pause(keyDelayMs); + } + + return isCurrent(); +} + +export interface TuiTextSubmissionOptions extends TuiKeySequenceOptions { + target: PtyHandle; + keys: string[]; + text: string; + keyToAnsi: Readonly<Record<string, string>>; + writeInput: ( + target: PtyHandle, + text: string, + ) => Promise<TuiInputWriteResult>; + textSettleMs?: number; +} + +/** + * Navigate into a TUI's custom-input row, then submit text atomically. + * Returns false for an explicit key rejection, backend replacement, or an + * adapter-level `submitted: false`; exceptions remain visible to the caller. + */ +export async function submitTuiTextInput( + options: TuiTextSubmissionOptions, +): Promise<boolean> { + const { + target, + keys, + text, + keyToAnsi, + writeInput, + } = options; + const isCurrent = options.isCurrent ?? (() => true); + const pause = options.pause ?? defaultPause; + const navKeys = keys[keys.length - 1] === 'Enter' ? keys.slice(0, -1) : keys; + + const navigated = await sendTuiKeySequence(target, navKeys, keyToAnsi, { + isCurrent, + pause, + keyDelayMs: options.keyDelayMs, + }); + if (!navigated) return false; + + await pause(options.textSettleMs ?? 200); + if (!isCurrent()) return false; + + const result = await writeInput(target, text); + if (result?.submitted === false) return false; + return isCurrent(); +} diff --git a/src/worker.ts b/src/worker.ts index 81f1565d7..b24509e94 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -238,6 +238,7 @@ import { tmuxRestartJitterMs } from './core/tmux-recovery.js'; import { IdleDetector } from './utils/idle-detector.js'; import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js'; import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js'; +import { sendTuiKeySequence, submitTuiTextInput } from './utils/tui-input-delivery.js'; import { captureToPng } from './utils/screenshot-renderer.js'; import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven } from './utils/transient-snapshot.js'; import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js'; @@ -4698,20 +4699,18 @@ const KEY_TO_ANSI: Record<string, string> = { */ async function handleTuiKeys(keys: string[], isFinal: boolean): Promise<boolean> { if (!backend || keys.length === 0) return false; + const targetBackend = backend; try { - if ('sendSpecialKeys' in backend) { - const b = backend as any; - // Send each key individually with 100ms delay for TUI state processing - for (const key of keys) { - b.sendSpecialKeys(key); - await new Promise(r => setTimeout(r, 100)); - } - } else { - for (const key of keys) { - backend.write(KEY_TO_ANSI[key] ?? key); - await new Promise(r => setTimeout(r, 100)); - } + const delivered = await sendTuiKeySequence( + targetBackend, + keys, + KEY_TO_ANSI, + { isCurrent: () => backend === targetBackend }, + ); + if (!delivered) { + logError('handleTuiKeys write rejected or backend replaced'); + return false; } } catch (e: any) { logError(`handleTuiKeys write failed: ${e?.message ?? e}`); @@ -4816,43 +4815,38 @@ async function flushPendingInjections(): Promise<void> { * is treated as a "decline" action, not a "enter text mode" action. The TUI * auto-switches to text input mode as soon as a character is typed. */ -async function handleTuiTextInput(keys: string[], text: string): Promise<void> { - if (!backend || !cliAdapter) return; - - // Strip trailing Enter from keys — we don't want to press Enter on "Type something" - const navKeys = keys[keys.length - 1] === 'Enter' ? keys.slice(0, -1) : keys; +async function handleTuiTextInput(keys: string[], text: string): Promise<boolean> { + if (!backend || !cliAdapter) return false; + const targetBackend = backend; + const targetAdapter = cliAdapter; + const navKeyCount = keys[keys.length - 1] === 'Enter' ? keys.length - 1 : keys.length; - // Step 1: navigate to "Type something" (no Enter) - if ('sendSpecialKeys' in backend) { - const b = backend as any; - for (const key of navKeys) { - b.sendSpecialKeys(key); - await new Promise(r => setTimeout(r, 100)); - } - } else { - for (const key of navKeys) { - backend.write(KEY_TO_ANSI[key] ?? key); - await new Promise(r => setTimeout(r, 100)); + log(`TUI text input: writing "${text.substring(0, 80)}" to PTY (after ${navKeyCount} nav keys)`); + try { + const delivered = await submitTuiTextInput({ + target: strictInputHandle(targetBackend), + keys, + text, + keyToAnsi: KEY_TO_ANSI, + isCurrent: () => backend === targetBackend && cliAdapter === targetAdapter, + writeInput: (target, content) => targetAdapter.writeInput(target, content), + }); + if (!delivered) { + logError('TUI text input was rejected, unconfirmed, or crossed a backend replacement'); + return false; } + } catch (err: any) { + logError(`TUI text input write failed: ${err?.message ?? err}`); + return false; } - // Step 2: clear blocking state + // Clear blocking only after navigation and adapter submission both succeed. tuiPromptBlocking = false; if (isPromptReady) { isPromptReady = false; idleDetector?.reset(); } - - // Wait briefly so the cursor position is stable before pasting - await new Promise(r => setTimeout(r, 200)); - - // Step 3: write text via cliAdapter (auto-switches to text mode + submits with Enter) - log(`TUI text input: writing "${text.substring(0, 80)}" to PTY (after ${navKeys.length} nav keys)`); - try { - await cliAdapter.writeInput(adapterInputHandle(backend), text); - } catch (err: any) { - log(`TUI text input write failed: ${err.message}`); - } + return true; } /** @@ -4892,34 +4886,80 @@ async function driveCocoPicker(navKeys: string[], needsReviewSubmit: boolean, co // The hook returns passthrough → CoCo renders the picker; only then send keys. const appeared = await waitFor(/Enter to select|Tab\/Arrow keys|Review your answers/, 30_000); if (!appeared) { log('coco_drive_picker: picker not detected within 30s — aborting drive'); return; } + if (!backend) return; + const targetBackend = backend; tuiPromptBlocking = true; + let failureReported = false; + const reportFailure = (detail?: unknown): void => { + if (failureReported) return; + failureReported = true; + const suffix = detail + ? `: ${detail instanceof Error ? detail.message : String(detail)}` + : ''; + logError(`coco_drive_picker: TUI answer delivery failed${suffix}`); + send({ + type: 'user_notify', + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + message: t('worker.tui_submit_failed', { cliName: cliName() }), + }); + }; if (comment && comment.trim()) { // Free-text reply: navigate to the first question's "Type something" row, // type the text, then a single Enter. Single-question submits directly; for // multi-question this only fills the first question (logged limitation). log(`coco_drive_picker: free-text answer (${navKeys.length} nav keys)${needsReviewSubmit ? ' [multi-question — partial]' : ''}`); - const b = backend as any; - if ('sendSpecialKeys' in backend) { - for (const key of navKeys) { b.sendSpecialKeys(key); await new Promise(r => setTimeout(r, 100)); } - } else { - for (const key of navKeys) { backend.write(KEY_TO_ANSI[key] ?? key); await new Promise(r => setTimeout(r, 100)); } + try { + const navigated = await sendTuiKeySequence( + targetBackend, + navKeys, + KEY_TO_ANSI, + { isCurrent: () => backend === targetBackend }, + ); + if (!navigated) { + reportFailure(); + return; + } + await new Promise(r => setTimeout(r, 150)); + if (backend !== targetBackend) { + reportFailure(); + return; + } + const input = strictInputHandle(targetBackend as SessionBackend & PtyHandle); + if (typeof input.sendText === 'function') input.sendText(comment); + else input.write(comment); + await new Promise(r => setTimeout(r, 200)); + if (backend !== targetBackend) { + reportFailure(); + return; + } + if (!await handleTuiKeys(['Enter'], true)) { + reportFailure(); + } + } catch (err) { + reportFailure(err); } - await new Promise(r => setTimeout(r, 150)); - if ('sendText' in backend && b.sendText) b.sendText(comment); else backend.write(comment); - await new Promise(r => setTimeout(r, 200)); - await handleTuiKeys(['Enter'], true); // single Enter submits + clears blocking state return; } // Button selection. Single question: navKeys submit directly (isFinal=true). // Multi question: navKeys land on Review, then one Enter on "Submit answers". log(`coco_drive_picker: selection answer (${navKeys.length} keys, review=${needsReviewSubmit})`); - await handleTuiKeys(navKeys, !needsReviewSubmit); + if (!await handleTuiKeys(navKeys, !needsReviewSubmit)) { + reportFailure(); + return; + } if (needsReviewSubmit) { const review = await waitFor(/Review your answers|Submit answers/, 8_000); if (!review) log('coco_drive_picker: Review screen not detected — submitting anyway'); - await handleTuiKeys(['Enter'], true); // cursor defaults to "Submit answers" + if (backend !== targetBackend) { + reportFailure(); + return; + } + if (!await handleTuiKeys(['Enter'], true)) { + reportFailure(); + } } } @@ -10953,13 +10993,38 @@ process.on('message', async (raw: unknown) => { writeKeys: handleTuiKeys, sendExpired: (nonce, turnId, dispatchAttempt) => send({ type: 'stuck_warning_expired', nonce, turnId, dispatchAttempt }), sendDelivered: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_keys_delivered', nonce, turnId, dispatchAttempt }), + sendFailed: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_prompt_submit_failed', stuckNonce: nonce, turnId, dispatchAttempt }), log, }, ); wroteKeys = result.wroteKeys; } else { - await handleTuiKeys(msg.keys, msg.isFinal); - wroteKeys = true; + wroteKeys = await handleTuiKeys(msg.keys, msg.isFinal); + if (msg.cardMessageId) { + if (!wroteKeys) { + send({ + type: 'tui_prompt_submit_failed', + cardMessageId: msg.cardMessageId, + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + }); + } else if (msg.isFinal) { + send({ + type: 'tui_prompt_resolved', + cardMessageId: msg.cardMessageId, + selectedText: msg.selectedText, + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + }); + } + } else if (!wroteKeys) { + send({ + type: 'user_notify', + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + message: t('worker.tui_submit_failed', { cliName: cliName() }), + }); + } } // Re-arm the stuck detector ONLY when the card-handler explicitly flags // this as a stuck-warning card's Enter action (advances to the next @@ -10981,7 +11046,30 @@ process.on('message', async (raw: unknown) => { } case 'tui_text_input': { - handleTuiTextInput(msg.keys, msg.text); + const wroteText = await handleTuiTextInput(msg.keys, msg.text); + if (msg.cardMessageId) { + send(wroteText + ? { + type: 'tui_prompt_resolved', + cardMessageId: msg.cardMessageId, + selectedText: msg.text, + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + } + : { + type: 'tui_prompt_submit_failed', + cardMessageId: msg.cardMessageId, + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + }); + } else if (!wroteText) { + send({ + type: 'user_notify', + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + message: t('worker.tui_submit_failed', { cliName: cliName() }), + }); + } break; } diff --git a/test/card-builder.test.ts b/test/card-builder.test.ts index f6eda6f31..af6ae599f 100644 --- a/test/card-builder.test.ts +++ b/test/card-builder.test.ts @@ -19,6 +19,7 @@ import { buildAdoptSelectCard, buildPrivateSnapshotCard, buildConfigCard, + buildTuiPromptFailedCard, getCliDisplayName, } from '../src/im/lark/card-builder.js'; import type { RelayPickerEntry } from '../src/im/lark/card-builder.js'; @@ -62,6 +63,18 @@ function buttonTexts(actions: any[]): string[] { .map((a: any) => a.text.content); } +describe('buildTuiPromptFailedCard', () => { + it('renders an explicit red terminal state for unconfirmed TUI delivery', () => { + const card = parse(buildTuiPromptFailedCard('Input was not delivered', 'en')); + + expect(card.header).toMatchObject({ + template: 'red', + title: { content: 'Failed' }, + }); + expect(card.elements[0].text.content).toBe('Input was not delivered'); + }); +}); + function allActions(card: any): any[] { return card.elements .filter((e: any) => e.tag === 'action') diff --git a/test/stuck-card-handler.test.ts b/test/stuck-card-handler.test.ts index 8ad6aa0b2..3b99f6f1e 100644 --- a/test/stuck-card-handler.test.ts +++ b/test/stuck-card-handler.test.ts @@ -133,6 +133,21 @@ function makeTuiKeysEvent(value: Record<string, any>, clickedMessageId = CARD_ID }; } +function makeTuiTextEvent(text: string, inputKeys = '["Down","Enter"]') { + return { + action: { + value: { + action: 'tui_text_input', + root_id: ROOT_ID, + input_keys: inputKeys, + }, + form_value: { tui_custom_input: text }, + }, + operator: { open_id: 'ou_user' }, + context: { open_message_id: CARD_ID }, + }; +} + function flush(): Promise<void> { return new Promise(resolve => setTimeout(resolve, 0)); } @@ -144,7 +159,10 @@ describe('stuck-warning card tui_keys handler', () => { beforeEach(() => { vi.clearAllMocks(); - workerSend = vi.fn(); + workerSend = vi.fn((_message, callback?: (error: Error | null) => void) => { + callback?.(null); + return true; + }); }); it('sends allowlisted key derived from pageType+index (ignores value.keys)', async () => { @@ -264,4 +282,105 @@ describe('stuck-warning card tui_keys handler', () => { expect(workerSend).toHaveBeenCalledTimes(1); expect(workerSend.mock.calls[0][0].keys).toEqual(['Escape']); }); + + it('releases the stuck-card processing claim when IPC delivery fails', async () => { + workerSend.mockImplementation((_message, callback?: (error: Error | null) => void) => { + callback?.(new Error('channel closed')); + return false; + }); + const ds = makeDs({ + worker: { killed: false, connected: true, send: workerSend } as any, + stuckWarningCardId: CARD_ID, + stuckWarningNonce: 1, + stuckWarningNonceCounter: 1, + stuckWarningPageType: 'hook review level 1', + stuckWarningCliLifetime: 1, + }); + const sessions = new Map([[sessionKey(ROOT_ID, APP_ID), ds]]); + + const result = await handleCardAction( + makeTuiKeysEvent({ selected_index: '0', is_final: '1' }) as any, + makeDeps(sessions), + APP_ID, + ); + + expect(result).toMatchObject({ toast: { type: 'warning' } }); + expect(ds.stuckWarningProcessing).toBe(false); + }); + + it('keeps a normal TUI card active until the worker/backend ACK arrives', async () => { + const ds = makeDs({ + worker: { killed: false, connected: true, send: workerSend } as any, + tuiPromptCardId: CARD_ID, + tuiPromptOptions: [ + { text: 'Approve', selected: false, type: 'select', keys: ['Enter'] }, + ], + }); + const sessions = new Map([[sessionKey(ROOT_ID, APP_ID), ds]]); + + const result = await handleCardAction( + makeTuiKeysEvent({ + keys: '["Enter"]', + selected_index: '0', + selected_text: 'Approve', + option_type: 'select', + is_final: '1', + }) as any, + makeDeps(sessions), + APP_ID, + ); + + expect(result).toEqual({ text: 'Approve' }); + expect(ds.tuiPromptCardId).toBe(CARD_ID); + expect(workerSend).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tui_keys', + cardMessageId: CARD_ID, + selectedText: 'Approve', + }), + expect.any(Function), + ); + }); + + it('keeps text-input cards active while awaiting the worker/backend ACK', async () => { + const ds = makeDs({ + worker: { killed: false, connected: true, send: workerSend } as any, + tuiPromptCardId: CARD_ID, + }); + const sessions = new Map([[sessionKey(ROOT_ID, APP_ID), ds]]); + + const result = await handleCardAction( + makeTuiTextEvent('custom answer') as any, + makeDeps(sessions), + APP_ID, + ); + + expect(result).toEqual({ text: 'custom answer' }); + expect(ds.tuiPromptCardId).toBe(CARD_ID); + expect(workerSend).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tui_text_input', + cardMessageId: CARD_ID, + text: 'custom answer', + }), + expect.any(Function), + ); + }); + + it('does not render text input as processing when nothing was dispatched', async () => { + const ds = makeDs({ + worker: null, + tuiPromptCardId: CARD_ID, + }); + const sessions = new Map([[sessionKey(ROOT_ID, APP_ID), ds]]); + + const result = await handleCardAction( + makeTuiTextEvent('custom answer') as any, + makeDeps(sessions), + APP_ID, + ); + + expect(result).toMatchObject({ toast: { type: 'warning' } }); + expect(ds.tuiPromptCardId).toBe(CARD_ID); + }); }); diff --git a/test/stuck-key-guard.test.ts b/test/stuck-key-guard.test.ts index b39fcc519..5df4e231e 100644 --- a/test/stuck-key-guard.test.ts +++ b/test/stuck-key-guard.test.ts @@ -9,8 +9,8 @@ * 5. lifetime changed DURING capture (live getter re-read) → expired * 6. page type no longer matches after capture → expired * 7. write success → only delivered, keys written - * 8. write returns false → only expired - * 9. write throws → only expired + * 8. write returns false → only failed + * 9. write throws → only failed * 10. happy path → delivered * 11. expired carries correct nonce/turnId/dispatchAttempt * @@ -39,6 +39,7 @@ function makeDeps(state: MutableState, overrides?: Partial<StuckKeyGuardDeps>): writeKeys: vi.fn(async () => true), sendExpired: vi.fn(), sendDelivered: vi.fn(), + sendFailed: vi.fn(), log: vi.fn(), ...overrides, }; @@ -182,24 +183,26 @@ describe('processStuckWarningTuiKeys (worker fail-closed guard)', () => { expect(deps.writeKeys).toHaveBeenCalledWith(['Escape'], true); }); - it('write returns false → expired, keys not written', async () => { + it('write returns false → failed, keys not written', async () => { const state = { backend: {}, lifetime: 1 }; const deps = makeDeps(state, { writeKeys: vi.fn(async () => false) }); const msg = makeMsg(); const result = await processStuckWarningTuiKeys(msg, deps); - expect(result).toEqual({ sent: 'expired', wroteKeys: false }); - expect(deps.sendExpired).toHaveBeenCalledTimes(1); + expect(result).toEqual({ sent: 'failed', wroteKeys: false }); + expect(deps.sendFailed).toHaveBeenCalledTimes(1); + expect(deps.sendExpired).not.toHaveBeenCalled(); expect(deps.sendDelivered).not.toHaveBeenCalled(); expect(deps.writeKeys).toHaveBeenCalledTimes(1); }); - it('write throws → expired, keys not written', async () => { + it('write throws → failed, keys not written', async () => { const state = { backend: {}, lifetime: 1 }; const deps = makeDeps(state, { writeKeys: vi.fn(async () => { throw new Error('write failed'); }) }); const msg = makeMsg(); const result = await processStuckWarningTuiKeys(msg, deps); - expect(result).toEqual({ sent: 'expired', wroteKeys: false }); - expect(deps.sendExpired).toHaveBeenCalledTimes(1); + expect(result).toEqual({ sent: 'failed', wroteKeys: false }); + expect(deps.sendFailed).toHaveBeenCalledTimes(1); + expect(deps.sendExpired).not.toHaveBeenCalled(); expect(deps.sendDelivered).not.toHaveBeenCalled(); expect(deps.writeKeys).toHaveBeenCalledTimes(1); }); diff --git a/test/stuck-warning-integration.test.ts b/test/stuck-warning-integration.test.ts index 439987936..9110389da 100644 --- a/test/stuck-warning-integration.test.ts +++ b/test/stuck-warning-integration.test.ts @@ -2,7 +2,8 @@ * Integration tests for the stuck-warning state machine in worker-pool.ts. * * Exercises the REAL message handlers (stuck_warning / tui_keys_delivered / - * stuck_warning_expired) via __testOnly_setupWorkerHandlers, with a deferred + * stuck_warning_expired / tui_prompt_submit_failed) via + * __testOnly_setupWorkerHandlers, with a deferred * sessionReply to simulate in-flight card POSTs. * * Covers (PR #559 review rounds 3-5): @@ -37,6 +38,7 @@ vi.mock('../src/im/lark/client.js', () => ({ vi.mock('../src/im/lark/card-builder.js', () => ({ buildTuiPromptCard: vi.fn(() => '{"type":"tui_prompt"}'), buildTuiPromptResolvedCard: vi.fn((text: string) => JSON.stringify({ type: 'resolved', text })), + buildTuiPromptFailedCard: vi.fn((text: string) => JSON.stringify({ type: 'failed', text })), buildTuiPromptProcessingCard: vi.fn((text: string) => JSON.stringify({ type: 'processing', text })), buildStreamingCard: vi.fn(() => '{}'), buildSessionCard: vi.fn(() => '{}'), @@ -296,6 +298,95 @@ describe('stuck-warning state machine (integration)', () => { expect(ds.stuckWarningNonce).toBeUndefined(); }); + it('backend delivery failure renders a failure state and clears stuck-card authority', async () => { + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ worker: fakeWorker }); + __testOnly_setupWorkerHandlers(ds, fakeWorker); + + fakeWorker.emit('message', { + type: 'stuck_warning', + elapsedMs: 15000, + snapshot: '...', + matchedPattern: 'hook review level 1', + turnId: 'turn_1', + cliLifetime: 1, + }); + await flush(); + sessionReplyDeferred.resolve('om_card_1'); + await flush(); + + fakeWorker.emit('message', { + type: 'tui_prompt_submit_failed', + stuckNonce: 1, + turnId: 'turn_1', + }); + await flush(); + + expect(updateMessageMock).toHaveBeenCalledWith( + 'app_test', + 'om_card_1', + expect.stringContaining('"type":"failed"'), + ); + expect(ds.stuckWarningCardId).toBeUndefined(); + expect(ds.stuckWarningNonce).toBeUndefined(); + }); + + it('normal TUI cards resolve only from a matching worker ACK', async () => { + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + worker: fakeWorker, + tuiPromptCardId: 'om_tui_card', + tuiPromptOptions: [ + { text: 'Approve', selected: false, type: 'select', keys: ['Enter'] }, + ], + }); + __testOnly_setupWorkerHandlers(ds, fakeWorker); + + fakeWorker.emit('message', { + type: 'tui_prompt_resolved', + cardMessageId: 'om_old_card', + selectedText: 'stale', + }); + await flush(); + expect(updateMessageMock).not.toHaveBeenCalled(); + expect(ds.tuiPromptCardId).toBe('om_tui_card'); + + fakeWorker.emit('message', { + type: 'tui_prompt_resolved', + cardMessageId: 'om_tui_card', + selectedText: 'Approve', + }); + await flush(); + expect(updateMessageMock).toHaveBeenCalledTimes(1); + expect(ds.tuiPromptCardId).toBeUndefined(); + }); + + it('normal TUI backend failure renders failed instead of selected', async () => { + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + worker: fakeWorker, + tuiPromptCardId: 'om_tui_card', + tuiPromptOptions: [ + { text: 'Approve', selected: false, type: 'select', keys: ['Enter'] }, + ], + }); + __testOnly_setupWorkerHandlers(ds, fakeWorker); + sessionReplyDeferred.resolve('om_failure_notice'); + + fakeWorker.emit('message', { + type: 'tui_prompt_submit_failed', + cardMessageId: 'om_tui_card', + }); + await flush(); + + expect(updateMessageMock).toHaveBeenCalledWith( + 'app_test', + 'om_tui_card', + expect.stringContaining('"type":"failed"'), + ); + expect(ds.tuiPromptCardId).toBeUndefined(); + }); + it('old ACK (nonce=1) after new warning (nonce=2) does not affect new authority', async () => { const fakeWorker = makeFakeWorker(); const ds = makeDs({ worker: fakeWorker }); diff --git a/test/tui-input-delivery.test.ts b/test/tui-input-delivery.test.ts new file mode 100644 index 000000000..9d99a3893 --- /dev/null +++ b/test/tui-input-delivery.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + sendTuiKeySequence, + submitTuiTextInput, +} from '../src/utils/tui-input-delivery.js'; + +const KEY_TO_ANSI = { + Down: '\x1b[B', + Enter: '\r', +}; + +describe('sendTuiKeySequence', () => { + it('preserves void-returning backends and sends every key in order', async () => { + const sent: string[] = []; + const target = { + write: vi.fn(), + sendSpecialKeys: vi.fn((key: string) => { sent.push(key); }), + }; + + await expect(sendTuiKeySequence(target, ['Down', 'Enter'], KEY_TO_ANSI, { + pause: async () => {}, + })).resolves.toBe(true); + expect(sent).toEqual(['Down', 'Enter']); + }); + + it('stops immediately when a backend explicitly returns false', async () => { + const target = { + write: vi.fn(), + sendSpecialKeys: vi.fn((key: string) => key !== 'Down'), + }; + + await expect(sendTuiKeySequence(target, ['Down', 'Enter'], KEY_TO_ANSI, { + pause: async () => {}, + })).resolves.toBe(false); + expect(target.sendSpecialKeys).toHaveBeenCalledTimes(1); + }); + + it('does not continue a sequence after the backend generation changes', async () => { + let current = true; + const target = { + write: vi.fn(), + sendSpecialKeys: vi.fn(() => true), + }; + + await expect(sendTuiKeySequence(target, ['Down', 'Enter'], KEY_TO_ANSI, { + isCurrent: () => current, + pause: async () => { current = false; }, + })).resolves.toBe(false); + expect(target.sendSpecialKeys).toHaveBeenCalledTimes(1); + }); +}); + +describe('submitTuiTextInput', () => { + it('keeps submission failed when the adapter reports submitted:false', async () => { + const target = { + write: vi.fn(), + sendSpecialKeys: vi.fn(() => true), + }; + const writeInput = vi.fn(async () => ({ submitted: false })); + + await expect(submitTuiTextInput({ + target, + keys: ['Down', 'Enter'], + text: 'answer', + keyToAnsi: KEY_TO_ANSI, + writeInput, + pause: async () => {}, + })).resolves.toBe(false); + expect(target.sendSpecialKeys).toHaveBeenCalledWith('Down'); + expect(target.sendSpecialKeys).not.toHaveBeenCalledWith('Enter'); + }); + + it('reports success only after navigation and adapter submission complete', async () => { + const target = { + write: vi.fn(), + sendSpecialKeys: vi.fn(() => true), + }; + const writeInput = vi.fn(async () => ({ submitted: true })); + + await expect(submitTuiTextInput({ + target, + keys: ['Down', 'Enter'], + text: 'answer', + keyToAnsi: KEY_TO_ANSI, + writeInput, + pause: async () => {}, + })).resolves.toBe(true); + expect(writeInput).toHaveBeenCalledWith(target, 'answer'); + }); +}); diff --git a/test/tui-input-failure-contract.test.ts b/test/tui-input-failure-contract.test.ts new file mode 100644 index 000000000..c51cf3b80 --- /dev/null +++ b/test/tui-input-failure-contract.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const workerSource = readFileSync(new URL('../src/worker.ts', import.meta.url), 'utf8'); +const cardHandlerSource = readFileSync( + new URL('../src/im/lark/card-handler.ts', import.meta.url), + 'utf8', +); + +function sourceRegion(source: string, start: string, end: string): string { + const startIndex = source.indexOf(start); + const endIndex = source.indexOf(end, startIndex + start.length); + expect(startIndex, `missing source anchor: ${start}`).toBeGreaterThanOrEqual(0); + expect(endIndex, `missing source anchor: ${end}`).toBeGreaterThan(startIndex); + return source.slice(startIndex, endIndex); +} + +describe('TUI input failure contract wiring', () => { + it('treats an explicit backend false as a failed key sequence', () => { + const region = sourceRegion( + workerSource, + 'async function handleTuiKeys', + '// 待注入的 TUI 命令队列', + ); + + expect(region).toMatch(/sendSpecialKeys\(key\)\s*===\s*false|sendTuiKeySequence/); + }); + + it('clears prompt blocking only after text submission succeeds', () => { + const region = sourceRegion( + workerSource, + 'async function handleTuiTextInput', + '/**\n * Drive CoCo', + ); + const writeIndex = Math.max( + region.indexOf('await cliAdapter.writeInput'), + region.indexOf('await submitTuiTextInput'), + ); + const clearIndex = region.indexOf('tuiPromptBlocking = false'); + + expect(writeIndex).toBeGreaterThanOrEqual(0); + expect(clearIndex).toBeGreaterThan(writeIndex); + expect(region).toMatch(/submitted\s*===\s*false|submitTuiTextInput/); + }); + + it('does not claim non-stuck keys were written when the worker helper failed', () => { + const region = sourceRegion( + workerSource, + "case 'tui_keys':", + "case 'inject_command':", + ); + + expect(region).not.toMatch( + /await handleTuiKeys\(msg\.keys, msg\.isFinal\);\s*wroteKeys = true;/, + ); + }); + + it('waits for IPC delivery callbacks instead of treating send return false as failure', () => { + const keysRegion = sourceRegion( + cardHandlerSource, + "if (actionType === 'tui_keys' && ds)", + "if (actionType === 'tui_text_input' && ds)", + ); + const textRegion = sourceRegion( + cardHandlerSource, + "if (actionType === 'tui_text_input' && ds)", + '// Compatibility path for cards emitted before open_local_cli', + ); + + expect(keysRegion).toContain('await sendWorkerIpc'); + expect(textRegion).toContain('await sendWorkerIpc'); + }); +}); diff --git a/test/worker-ipc.test.ts b/test/worker-ipc.test.ts new file mode 100644 index 000000000..7572c1a91 --- /dev/null +++ b/test/worker-ipc.test.ts @@ -0,0 +1,41 @@ +import type { ChildProcess } from 'node:child_process'; +import { describe, expect, it, vi } from 'vitest'; +import { sendWorkerIpc } from '../src/core/worker-ipc.js'; + +describe('sendWorkerIpc', () => { + it('treats send() false plus a successful callback as IPC backpressure, not failure', async () => { + const send = vi.fn((_message, callback) => { + callback(null); + return false; + }); + const worker = { killed: false, connected: true, send } as unknown as ChildProcess; + + await expect(sendWorkerIpc(worker, { type: 'refresh_screen' })).resolves.toBeUndefined(); + }); + + it('rejects an asynchronous IPC delivery error', async () => { + const failure = new Error('channel closed'); + const send = vi.fn((_message, callback) => { + callback(failure); + return false; + }); + const worker = { killed: false, connected: true, send } as unknown as ChildProcess; + + await expect(sendWorkerIpc(worker, { type: 'refresh_screen' })).rejects.toThrow('channel closed'); + }); + + it('rejects a synchronous send throw', async () => { + const send = vi.fn(() => { throw new Error('send threw'); }); + const worker = { killed: false, connected: true, send } as unknown as ChildProcess; + + await expect(sendWorkerIpc(worker, { type: 'refresh_screen' })).rejects.toThrow('send threw'); + }); + + it('fails before send when the IPC channel is disconnected', async () => { + const send = vi.fn(); + const worker = { killed: false, connected: false, send } as unknown as ChildProcess; + + await expect(sendWorkerIpc(worker, { type: 'refresh_screen' })).rejects.toThrow('not connected'); + expect(send).not.toHaveBeenCalled(); + }); +}); From 111a3df4393ad5ee392e2f3197d831c7afb241a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 00:18:10 +0800 Subject: [PATCH 15/26] =?UTF-8?q?fix(session):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=BB=AD=E8=B7=91=E4=B8=8E=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/command-handler.ts | 3 +- src/core/session-manager.ts | 74 ++++++++++++++--------- src/daemon.ts | 51 ++++++++++++---- test/command-handler.test.ts | 24 ++++++++ test/group-join-shared-routing.test.ts | 84 ++++++++++++++++++++++++++ test/scheduler-silent-execute.test.ts | 53 ++++++++++++++++ 6 files changed, 248 insertions(+), 41 deletions(-) diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 110ed0bb9..6c91e5d99 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1279,6 +1279,7 @@ export async function handleCommand( // Capture the closed-session card BEFORE killWorker/closeSession — // it reads the live session's identity off `ds`. const card = buildClosedSessionCard(ds, loc); + const activeKey = sessionKey(rootId, larkAppId!); try { await closeSession(ds.session.sessionId); } catch (err) { @@ -1289,7 +1290,7 @@ export async function handleCommand( ); break; } - activeSessions.delete(sessionKey(rootId, larkAppId!)); + if (activeSessions.get(activeKey) === ds) activeSessions.delete(activeKey); // 「会话已关闭」卡片优先「仅自己可见」:普通群里走 ephemeral 只发给执行 // /close 的本人;话题群不支持 ephemeral(18053) 时回退为正常的群内可见回复 // ——与流式卡片上「关闭会话」按钮的送达方式保持一致。 diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index c9448ef1d..e0a77afae 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -2213,37 +2213,57 @@ export async function executeScheduledTask( const firePrompt = silent ? `${buildSilentScheduleHint(task.name, localeForBot(larkAppId))}\n\n${task.prompt}` : task.prompt; - // Inject into a live session if one already exists at this anchor. - const existing = activeSessions.get(sessionKey(anchor, larkAppId)); - if (isContinuation && existing?.worker && !existing.worker.killed) { + // Continue through the session already registered at this anchor. A + // deliberately suspended session keeps its active registration with + // worker=null; scheduled turns must cold-resume that same session instead of + // creating a competing row that the registration CAS will reject. + const activeKey = sessionKey(anchor, larkAppId); + const existing = activeSessions.get(activeKey); + if (isContinuation && existing) { markSessionActivity(existing); - try { - ensureSessionWhiteboard(existing); - if (sharedTopicRootId) { - beginReplyTargetTurn(existing, sharedTopicRootId, scheduledTurnId); - sessionStore.updateSession(existing.session); + ensureSessionWhiteboard(existing); + if (sharedTopicRootId) { + beginReplyTargetTurn(existing, sharedTopicRootId, scheduledTurnId); + sessionStore.updateSession(existing.session); + } + const input = buildFollowUpCliInput(firePrompt, existing.session.sessionId, { + isAdoptMode: false, + cliId: existing.session.cliId ?? bot.config.cliId, + cliPathOverride: existing.session.cliPathOverride ?? bot.config.cliPathOverride, + locale: localeForBot(larkAppId), + larkAppId, + chatId: task.chatId, + whiteboardId: existing.session.whiteboardId, + }); + rememberLastCliInput(existing, task.prompt, input); + if (silent) armSilentScheduledTurn(existing, scheduledTurnId); + + if (existing.worker && !existing.worker.killed) { + try { + if (sendWorkerInput(existing, input, scheduledTurnId)) { + logger.info(`[scheduler] Task "${task.name}" injected into live session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`); + return; + } + } catch (err: any) { + logger.warn(`[scheduler] Live injection threw (${err.message}); cold-resuming registered session`); } - const input = buildFollowUpCliInput(firePrompt, existing.session.sessionId, { - isAdoptMode: false, - cliId: existing.session.cliId ?? bot.config.cliId, - cliPathOverride: existing.session.cliPathOverride ?? bot.config.cliPathOverride, - locale: localeForBot(larkAppId), - larkAppId, - chatId: task.chatId, - whiteboardId: existing.session.whiteboardId, + } + + if (activeSessions.get(activeKey) !== existing || existing.session.status !== 'active') { + if (silent) disarmSilentScheduledTurn(existing, scheduledTurnId); + throw new Error(`scheduled continuation lost active session ${existing.session.sessionId}`); + } + try { + forkWorker(existing, input, { + resume: existing.hasHistory, + turnId: scheduledTurnId, }); - rememberLastCliInput(existing, task.prompt, input); - if (silent) armSilentScheduledTurn(existing, scheduledTurnId); - if (!sendWorkerInput(existing, input, scheduledTurnId)) { - if (silent) disarmSilentScheduledTurn(existing, scheduledTurnId); - throw new Error('worker unavailable'); - } - logger.info(`[scheduler] Task "${task.name}" injected into live session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`); - return; - } catch (err: any) { + } catch (err) { if (silent) disarmSilentScheduledTurn(existing, scheduledTurnId); - logger.warn(`[scheduler] Failed to inject into live session (${err.message}); spawning fresh worker`); + throw err; } + logger.info(`[scheduler] Task "${task.name}" cold-resumed session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`); + return; } // Spawn a fresh session bound to the chosen anchor. @@ -2293,7 +2313,7 @@ export async function executeScheduledTask( } ensureSessionWhiteboard(ds); const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId }); - if (!setActiveSessionIfActive(activeSessions, sessionKey(anchor, larkAppId), ds)) { + if (!setActiveSessionIfActive(activeSessions, activeKey, ds)) { await closeSession(session.sessionId); return; } diff --git a/src/daemon.ts b/src/daemon.ts index c0cbde966..9f4e96ce2 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -15978,14 +15978,8 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 锚点已有会话,跳过`); return; } - const sharedReplyRootId = mode === 'group' && resolveRegularGroupMode(larkAppId, chatId) === 'shared' - ? await sendMessage( - larkAppId, - chatId, - tr('daemon.auto_start_join_seed', undefined, localeForBot(larkAppId)), - 'text', - ) - : undefined; + const needsSharedReply = mode === 'group' + && resolveRegularGroupMode(larkAppId, chatId) === 'shared'; const { pinnedWorkingDir, pinnedFromBotDefault } = await resolvePinnedWorkingDir({ scope, anchor, chatId, chatType, larkAppId }); const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault); @@ -16021,16 +16015,47 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined ownerOpenId: operatorOpenId, currentTurnTitle: title, workingDir: pinnedWorkingDir, - pendingTurnId: sharedReplyRootId, + pendingTurnId: undefined, }; - if (sharedReplyRootId) { - beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString()); - sessionStore.updateSession(ds.session); - } if (!setActiveSessionIfActive(activeSessions, dsKey, ds)) { await closeSessionHelper(session.sessionId); return; } + const rollbackRegisteredJoinSession = async (): Promise<void> => { + // closeSession transitions the row before its first await, but this + // handler owns a caller-supplied registry too. Drop only our identity so + // slow best-effort cleanup cannot block a retry or erase a replacement. + const closing = closeSessionHelper(session.sessionId); + if (activeSessions.get(dsKey) === ds) activeSessions.delete(dsKey); + await closing; + }; + + // Shared-mode seed/reply authority is externally visible. Publish it only + // after this candidate owns the routing key; a CAS loser must leave no + // orphaned "joined" topic and must not silently consume the first prompt. + let sharedReplyRootId: string | undefined; + if (needsSharedReply) { + try { + sharedReplyRootId = await sendMessage( + larkAppId, + chatId, + tr('daemon.auto_start_join_seed', undefined, localeForBot(larkAppId)), + 'text', + ); + } catch (err: any) { + logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} shared seed 发送失败:${err?.message ?? err}`); + await rollbackRegisteredJoinSession(); + return; + } + if (activeSessions.get(dsKey) !== ds || ds.session.status !== 'active') { + logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} shared seed 返回时会话已被替换,停止首轮`); + await rollbackRegisteredJoinSession(); + return; + } + ds.pendingTurnId = sharedReplyRootId; + beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString()); + sessionStore.updateSession(ds.session); + } // Register the anchor so a later duplicate bot.added for this chat is deduped // even in 话题群 (where dsKey is the seed id, not chatId). groupJoinAnchorByChat.set(chatLiveKey, dsKey); diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 0c9a5c3e2..89405224d 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -1454,6 +1454,30 @@ describe('handleCommand', () => { expect(vi.mocked(deps.sessionReply).mock.calls[0]?.[1]).toContain('ZMX ownership probe unavailable'); }); + it('does not delete a replacement session that wins the anchor while close awaits cleanup', async () => { + const ds = makeDaemonSession(); + const deps = makeDeps(ds); + let releaseClose!: (value: { ok: true; alreadyClosed: boolean; known: boolean }) => void; + vi.mocked(closeSession).mockImplementationOnce(() => new Promise(resolve => { + releaseClose = resolve; + })); + + const closing = handleCommand('/close', ROOT_ID, makeLarkMessage('/close'), deps, LARK_APP_ID); + await vi.waitFor(() => expect(closeSession).toHaveBeenCalledWith('sess-001')); + + const replacement = makeDaemonSession(); + replacement.session = { + ...replacement.session, + sessionId: 'sess-replacement', + title: 'replacement', + }; + deps.activeSessions.set(sessionKey(ROOT_ID, LARK_APP_ID), replacement); + releaseClose({ ok: true, alreadyClosed: false, known: true }); + await closing; + + expect(deps.activeSessions.get(sessionKey(ROOT_ID, LARK_APP_ID))).toBe(replacement); + }); + it('keeps ttadk non-interactive flags in the closed-card resume command', async () => { // A ttadk × Claude bot: the manual resume command on the closed card must // carry `-m <model> --skip-check`, else copy-pasting it hits ttadk's model diff --git a/test/group-join-shared-routing.test.ts b/test/group-join-shared-routing.test.ts index 838b90edf..3d10f9ea2 100644 --- a/test/group-join-shared-routing.test.ts +++ b/test/group-join-shared-routing.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ getAvailableBots: vi.fn(async () => []), getChatMode: vi.fn(async () => 'group' as 'group' | 'topic' | 'p2p'), getProjectScanDirs: vi.fn(() => [] as string[]), + ensureDefaultOncallBound: vi.fn(async () => undefined), listChatMemberOpenIds: vi.fn(async () => ['ou_owner']), replyMessage: vi.fn(async () => 'om_reply'), scanMultipleProjects: vi.fn(() => [] as Array<{ name: string; path: string; type: 'repo' | 'worktree'; branch: string }>), @@ -57,6 +58,11 @@ vi.mock('../src/services/project-scanner.js', async () => { return { ...actual, scanMultipleProjects: mocks.scanMultipleProjects }; }); +vi.mock('../src/services/oncall-store.js', async () => { + const actual = await vi.importActual<any>('../src/services/oncall-store.js'); + return { ...actual, ensureDefaultOncallBound: mocks.ensureDefaultOncallBound }; +}); + vi.mock('../src/core/worker-pool.js', async () => { const actual = await vi.importActual<any>('../src/core/worker-pool.js'); return { ...actual, forkWorker: mocks.forkWorker }; @@ -92,6 +98,7 @@ beforeEach(() => { vi.clearAllMocks(); mocks.getChatMode.mockResolvedValue('group'); mocks.getProjectScanDirs.mockReturnValue([]); + mocks.ensureDefaultOncallBound.mockResolvedValue(undefined); mocks.listChatMemberOpenIds.mockResolvedValue(['ou_owner']); mocks.replyMessage.mockResolvedValue('om_reply'); mocks.scanMultipleProjects.mockReturnValue([]); @@ -248,6 +255,83 @@ describe('handleBotAdded — 普通群 shared 路由', () => { ); }); + it('losing registration leaves no shared seed message or orphaned first turn', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_shared_race'; + const chatId = 'oc_join_shared_race'; + const key = types.sessionKey(chatId, appId); + const winner = { + session: { + sessionId: 'sess-race-winner', + chatId, + rootMessageId: chatId, + title: 'winner', + status: 'active', + createdAt: new Date().toISOString(), + larkAppId: appId, + scope: 'chat', + }, + worker: null, + workerPort: null, + workerToken: null, + larkAppId: appId, + chatId, + chatType: 'group', + scope: 'chat', + spawnedAt: Date.now(), + cliVersion: 'test', + lastMessageAt: Date.now(), + hasHistory: true, + } as any; + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-shared-race'), + regularGroupReplyMode: 'shared', + }); + mocks.ensureDefaultOncallBound.mockImplementationOnce(async () => { + daemon.__testOnly_activeSessions.set(key, winner); + return undefined; + }); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + + expect(daemon.__testOnly_activeSessions.get(key)).toBe(winner); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + }); + + it('rolls back the registered session when the post-CAS shared seed fails', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_shared_seed_failure'; + const chatId = 'oc_join_shared_seed_failure'; + const key = types.sessionKey(chatId, appId); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-shared-seed-failure'), + regularGroupReplyMode: 'shared', + }); + mocks.sendMessage.mockRejectedValueOnce(new Error('Lark unavailable')); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + + expect(daemon.__testOnly_activeSessions.get(key)).toBeUndefined(); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + expect(daemon.__testOnly_activeSessions.get(key)).toBeDefined(); + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + }); + it('话题群继续使用 seed 锚定的 thread-scope session', async () => { mocks.getChatMode.mockResolvedValue('topic'); const { daemon, registry, types } = modules; diff --git a/test/scheduler-silent-execute.test.ts b/test/scheduler-silent-execute.test.ts index d9c9ba903..404773a57 100644 --- a/test/scheduler-silent-execute.test.ts +++ b/test/scheduler-silent-execute.test.ts @@ -145,6 +145,7 @@ beforeEach(() => { sessionSeq = 0; forkWorkerMock.mockClear(); sendWorkerInputMock.mockClear(); + sendWorkerInputMock.mockReturnValue(true); sendMessageMock.mockClear(); replyMessageMock.mockClear(); getChatModeMock.mockClear(); @@ -410,6 +411,58 @@ describe('executeScheduledTask — live-session injection', () => { expect(existing.silentScheduledTurns?.has(turnId)).toBe(true); expect(existing.silentScheduledTurns?.has('normal-user-turn')).toBe(false); }); + + it('cold-resumes the registered worker-less session instead of losing the scheduled turn to CAS', async () => { + const active = new Map<string, DaemonSession>(); + const existing = liveSession('idle'); + existing.worker = null; + existing.workerPort = null; + existing.workerToken = null; + existing.session.suspendedColdResume = true; + active.set(sessionKey(ROOT, APP), existing); + + await executeScheduledTask( + baseTask({ rootMessageId: ROOT, scope: 'thread', silent: true }), + active, + refreshCliVersion, + ); + + expect(sendWorkerInputMock).not.toHaveBeenCalled(); + expect(active.get(sessionKey(ROOT, APP))).toBe(existing); + expect(store.size).toBe(1); + expect(forkWorkerMock).toHaveBeenCalledTimes(1); + const [, input, options] = forkWorkerMock.mock.calls[0]; + expect(typeof input === 'string' ? input : input.content).toContain('检查服务状态,挂了才报警'); + expect(options).toMatchObject({ + resume: true, + turnId: expect.stringMatching(/^schedule:task0001:/), + }); + expect(existing.silentScheduledTurns?.has(options.turnId)).toBe(true); + }); + + it('falls back from a rejected live injection by re-forking the same registered session', async () => { + const active = new Map<string, DaemonSession>(); + const existing = liveSession('idle'); + active.set(sessionKey(ROOT, APP), existing); + sendWorkerInputMock.mockReturnValueOnce(false); + + await executeScheduledTask( + baseTask({ rootMessageId: ROOT, scope: 'thread', silent: true }), + active, + refreshCliVersion, + ); + + expect(sendWorkerInputMock).toHaveBeenCalledTimes(1); + expect(active.get(sessionKey(ROOT, APP))).toBe(existing); + expect(store.size).toBe(1); + expect(forkWorkerMock).toHaveBeenCalledTimes(1); + const [, , options] = forkWorkerMock.mock.calls[0]; + expect(options).toMatchObject({ + resume: true, + turnId: expect.stringMatching(/^schedule:task0001:/), + }); + expect(existing.silentScheduledTurns?.has(options.turnId)).toBe(true); + }); }); describe('silent scheduled turn lifecycle', () => { From 7f44152819227cb6c187dd84d3e9e1bd9ecd44a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 00:18:24 +0800 Subject: [PATCH 16/26] =?UTF-8?q?fix(worker):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=A4=B1=E8=B4=A5=E4=B8=8E=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=8D=A1=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/types.ts | 4 + src/core/worker-pool.ts | 4 + src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + src/im/lark/card-handler.ts | 16 +++- src/worker.ts | 26 ++++-- test/backend-gate.test.ts | 15 ++++ test/raw-input-followup-atomicity.test.ts | 13 +++ test/stuck-card-handler.test.ts | 101 ++++++++++++++++++++++ test/stuck-warning-integration.test.ts | 5 ++ 10 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index 9bd4322b7..6e4ff2a17 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -244,6 +244,10 @@ export interface DaemonSession { vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; /** message_id of the TUI prompt interactive card (if active) */ tuiPromptCardId?: string; + /** A final ScreenAnalyzer TUI answer has been dispatched and is waiting for + * the worker's resolved/failed ACK. Claimed synchronously by card-handler so + * duplicate clicks cannot inject a second key sequence into the same CLI. */ + tuiPromptProcessing?: boolean; /** turnId of the last stuck_warning posted — dedup so we don't spam the * thread with repeated warnings for the same unresolved turn. */ stuckWarningTurnId?: string; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index f1809eaf1..28dedd736 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -3706,6 +3706,7 @@ function setupWorkerHandlers( ds.tuiPromptOptions = msg.options; ds.tuiPromptMultiSelect = msg.multiSelect; ds.tuiToggledIndices = []; + ds.tuiPromptProcessing = false; emitSessionLifecycleHook(ds, 'session.requires_attention', { reason: 'tui_prompt', description: msg.description, @@ -3765,6 +3766,7 @@ function setupWorkerHandlers( if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) { ds.tuiPromptCardId = undefined; ds.tuiPromptOptions = undefined; + ds.tuiPromptProcessing = false; break; } if (ds.tuiPromptCardId) { @@ -3774,6 +3776,7 @@ function setupWorkerHandlers( ); ds.tuiPromptCardId = undefined; ds.tuiPromptOptions = undefined; + ds.tuiPromptProcessing = false; publishAttentionPatch(ds); } break; @@ -3819,6 +3822,7 @@ function setupWorkerHandlers( ds.tuiPromptOptions = undefined; ds.tuiPromptMultiSelect = undefined; ds.tuiToggledIndices = undefined; + ds.tuiPromptProcessing = false; } if (matchesStuckCard) clearStuckWarningAuthority(ds); publishAttentionPatch(ds); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b28475b8a..c5264b286 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -726,6 +726,7 @@ export const messages: Record<string, string> = { 'worker.start_failed': '⚠️ The {cliName} session failed to start: {reason}\nCheck the Agent/backend settings in Dashboard and the installation environment on the daemon host, then resend your message to retry.', 'worker.start_exited_early': 'The worker exited before becoming ready (exit code: {code}); see the Botmux logs for details.', 'worker.tui_submit_failed': '⚠️ The TUI answer could not be confirmed as delivered to {cliName}. The CLI may still be waiting for input; open the local terminal or send a new message to recover.', + 'worker.raw_input_failed': '⚠️ The slash command could not be confirmed as delivered to {cliName}, so the follow-up text in the same message was not submitted. Check the terminal state, then resend.', 'worker.empty_final_completed': '⚠️ {cliName} reported this turn as completed, but botmux captured no final text from the terminal transcript and saw no tracked reply for this turn. If you already replied via a redirected send (--top-level / --into / --override-chat), you can ignore this. Otherwise open the web terminal to inspect the last output, or resend a message to continue the session.', // ─── CLI setup wizard / pm2 lifecycle (no per-bot context) ─────────────── diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index bc0df4fd1..ea865331c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -729,6 +729,7 @@ export const messages: Record<string, string> = { 'worker.start_failed': '⚠️ {cliName} 会话启动失败:{reason}\n请检查 Dashboard 的 Agent / 后端配置和 daemon 所在机器的安装环境,修复后重发消息即可重试。', 'worker.start_exited_early': 'worker 在就绪前退出(exit code: {code});详细错误可查看 Botmux 日志。', 'worker.tui_submit_failed': '⚠️ TUI 答案未能确认送达 {cliName}。CLI 可能仍在等待输入;请打开本机终端处理,或发送一条新消息解除并继续。', + 'worker.raw_input_failed': '⚠️ Slash 命令未能确认送达 {cliName},同一条消息中紧随其后的正文没有继续提交。请检查当前终端状态后重发。', 'worker.empty_final_completed': '⚠️ {cliName} 已报告本轮处理完成,但 botmux 没有从终端记录里捕获到最终文本,也没有追踪到本轮的回复。若你已经通过改道发送(--top-level / --into / --override-chat)回复过,可忽略本提示;否则请打开 Web 终端查看最后输出,或直接重发消息让会话继续。', // ─── CLI setup wizard / pm2 lifecycle (no per-bot context) ─────────────── diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 6704ee7ae..1e36f4626 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -1984,6 +1984,10 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe const optionType = value?.option_type ?? 'select'; const selectedIndex = Number(value?.selected_index ?? 0); const selectedText = value?.selected_text ?? `Option ${selectedIndex + 1}`; + if (isActiveTuiCard && ds.tuiPromptProcessing) { + logger.info(`[${tag(ds)}] Duplicate TUI prompt card click — dropped (processing already in flight)`); + return; + } if (optionType === 'toggle') { // Only a ScreenAnalyzer TUI card may own toggle state. A stuck-warning @@ -2077,6 +2081,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe } if (allKeys.length > 0) { + const effectiveFinal = isFinal || isFinalStuck; // Atomic processing claim: if a previous click is already in flight // (waiting for tui_keys_delivered / stuck_warning_expired ACK), drop // this duplicate. Without this, two rapid clicks could both pass the @@ -2088,6 +2093,9 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe } ds.stuckWarningProcessing = true; } + if (isActiveTuiCard && effectiveFinal) { + ds.tuiPromptProcessing = true; + } // Only the stuck-warning card's Enter action re-arms the detector // (Enter advances from the hook list to a per-hook review). Match by // the actual keys sent — NOT by optionType, since t is typed 'confirm' @@ -2103,7 +2111,6 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe const stuckNonce = isActiveStuckCard ? ds.stuckWarningNonce : undefined; const stuckCliLifetime = isActiveStuckCard ? ds.stuckWarningCliLifetime : undefined; const stuckPage = isActiveStuckCard ? ds.stuckWarningPageType : undefined; - const effectiveFinal = isFinal || isFinalStuck; const resolveText = isActiveTuiCard && ds.tuiToggledIndices?.length ? ds.tuiToggledIndices.map(i => ds.tuiPromptOptions?.[i]?.text).filter(Boolean).join(', ') : selectedText; @@ -2122,6 +2129,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe dispatchedKeys = true; } catch (err) { if (isActiveStuckCard) ds.stuckWarningProcessing = false; + if (isActiveTuiCard && effectiveFinal) ds.tuiPromptProcessing = false; logger.warn(`[${tag(ds)}] TUI key IPC delivery failed: ${err instanceof Error ? err.message : String(err)}`); return { toast: { @@ -2175,6 +2183,10 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe logger.info(`[${tag(ds)}] tui_text_input from stale card ${cardMessageId} — ignored`); return; } + if (ds.tuiPromptProcessing) { + logger.info(`[${tag(ds)}] Duplicate TUI text input — dropped (processing already in flight)`); + return; + } if (!ds.worker || !inputText || inputKeys.length === 0) { logger.info( `[${tag(ds)}] TUI text input not dispatched ` + @@ -2188,6 +2200,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe }; } // Atomic IPC — worker handles keys + text in one flow to avoid race + ds.tuiPromptProcessing = true; try { await sendWorkerIpc(ds.worker, { type: 'tui_text_input', @@ -2196,6 +2209,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe cardMessageId, } as DaemonToWorker); } catch (err) { + ds.tuiPromptProcessing = false; logger.warn(`[${tag(ds)}] TUI text IPC delivery failed: ${err instanceof Error ? err.message : String(err)}`); return { toast: { diff --git a/src/worker.ts b/src/worker.ts index b24509e94..0a7ec1fc1 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1674,6 +1674,15 @@ async function deliverRawInput(msg: Extract<DaemonToWorker, { type: 'raw_input' // Do not send another queued command against a backend whose write failed. isPromptReady = false; log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`); + const failedTurnId = msg.followUpTurnId ?? msg.turnId; + if (failedTurnId) { + emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed'); + } + send({ + type: 'user_notify', + ...(failedTurnId ? { turnId: failedTurnId } : {}), + message: t('worker.raw_input_failed', { cliName: cliName() }), + }); } // Follow-up rides on the same IPC and is enqueued only after this command's @@ -7240,13 +7249,6 @@ async function spawnCli( if (persistentTarget) killPersistentBackendTarget(persistentTarget, cfg.sessionId); else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); } - selectedBackend = selectBackend(); - isTmuxMode = selectedBackend.isTmuxMode; - isPipeMode = selectedBackend.isPipeMode; - isZellijMode = selectedBackend.isZellijMode; - backend = selectedBackend.backend; - cliLifetimeNonce++; - persistentSessionName = selectedBackend.persistentSessionName; } catch (e) { throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${(e as Error).message})`); } @@ -7266,6 +7268,16 @@ async function spawnCli( resolvedZmxSessionProbe = postKillProbe; resolvedZmxSessionPid = undefined; } + // ZMX backend selection consumes the frozen probe. Refresh it before + // re-selecting or the replacement keeps isReattach=true for the pane + // that this gate just proved was removed. + selectedBackend = selectBackend(); + isTmuxMode = selectedBackend.isTmuxMode; + isPipeMode = selectedBackend.isPipeMode; + isZellijMode = selectedBackend.isZellijMode; + backend = selectedBackend.backend; + cliLifetimeNonce++; + persistentSessionName = selectedBackend.persistentSessionName; } } } diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 35395c65a..56e0c38ab 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -160,6 +160,21 @@ describe('persistent backend cold-restart ordering', () => { expect(gate).toContain("effectiveBackendType === 'zmx'"); expect(gate).toContain('resolvedZmxSessionProbe = postKillProbe'); }); + + it('refreshes the frozen ZMX probe before read-isolation re-selects the backend', () => { + const start = workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'); + const end = workerSource.indexOf('let willReattachPersistent', start); + const gate = workerSource.slice(start, end); + const postKillProbe = gate.indexOf('const postKillProbe ='); + const frozenProbeRefresh = gate.indexOf('resolvedZmxSessionProbe = postKillProbe', postKillProbe); + const reselect = gate.indexOf('selectedBackend = selectBackend();'); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(postKillProbe).toBeGreaterThanOrEqual(0); + expect(frozenProbeRefresh).toBeGreaterThan(postKillProbe); + expect(reselect).toBeGreaterThan(frozenProbeRefresh); + }); }); describe('ZMX observer crash cleanup', () => { diff --git a/test/raw-input-followup-atomicity.test.ts b/test/raw-input-followup-atomicity.test.ts index 2c97c509d..22355ef2b 100644 --- a/test/raw-input-followup-atomicity.test.ts +++ b/test/raw-input-followup-atomicity.test.ts @@ -122,6 +122,19 @@ describe('worker raw_input delivery', () => { expect(region).not.toContain('if (!isPromptReady)'); expect(region).not.toContain('if (isPromptReady)'); }); + + it('surfaces an ambiguous terminal and user notice when the literal command write fails', () => { + const catchIdx = region.indexOf('catch (err'); + const terminalIdx = region.indexOf( + "emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed')", + catchIdx, + ); + const notifyIdx = region.indexOf("type: 'user_notify'", catchIdx); + + expect(catchIdx).toBeGreaterThanOrEqual(0); + expect(terminalIdx).toBeGreaterThan(catchIdx); + expect(notifyIdx).toBeGreaterThan(terminalIdx); + }); }); describe('worker command-line write mutex', () => { diff --git a/test/stuck-card-handler.test.ts b/test/stuck-card-handler.test.ts index 3b99f6f1e..198d98fa4 100644 --- a/test/stuck-card-handler.test.ts +++ b/test/stuck-card-handler.test.ts @@ -342,6 +342,73 @@ describe('stuck-warning card tui_keys handler', () => { ); }); + it('claims a normal TUI card synchronously so a second final click cannot inject keys twice', async () => { + const ds = makeDs({ + worker: { killed: false, connected: true, send: workerSend } as any, + tuiPromptCardId: CARD_ID, + tuiPromptOptions: [ + { text: 'Approve', selected: false, type: 'select', keys: ['1', 'Enter'] }, + { text: 'Reject', selected: false, type: 'select', keys: ['3', 'Enter'] }, + ], + }); + const deps = makeDeps(new Map([[sessionKey(ROOT_ID, APP_ID), ds]])); + + await handleCardAction( + makeTuiKeysEvent({ + keys: '["1","Enter"]', + selected_index: '0', + selected_text: 'Approve', + option_type: 'select', + is_final: '1', + }) as any, + deps, + APP_ID, + ); + await handleCardAction( + makeTuiKeysEvent({ + keys: '["3","Enter"]', + selected_index: '1', + selected_text: 'Reject', + option_type: 'select', + is_final: '1', + }) as any, + deps, + APP_ID, + ); + + expect(workerSend).toHaveBeenCalledTimes(1); + expect(ds.tuiPromptProcessing).toBe(true); + }); + + it('releases the normal TUI key claim when daemon-to-worker IPC delivery fails', async () => { + workerSend.mockImplementation((_message, callback?: (error: Error | null) => void) => { + callback?.(new Error('channel closed')); + return false; + }); + const ds = makeDs({ + worker: { killed: false, connected: true, send: workerSend } as any, + tuiPromptCardId: CARD_ID, + tuiPromptOptions: [ + { text: 'Approve', selected: false, type: 'select', keys: ['Enter'] }, + ], + }); + + const result = await handleCardAction( + makeTuiKeysEvent({ + keys: '["Enter"]', + selected_index: '0', + selected_text: 'Approve', + option_type: 'select', + is_final: '1', + }) as any, + makeDeps(new Map([[sessionKey(ROOT_ID, APP_ID), ds]])), + APP_ID, + ); + + expect(result).toMatchObject({ toast: { type: 'warning' } }); + expect(ds.tuiPromptProcessing).toBe(false); + }); + it('keeps text-input cards active while awaiting the worker/backend ACK', async () => { const ds = makeDs({ worker: { killed: false, connected: true, send: workerSend } as any, @@ -367,6 +434,40 @@ describe('stuck-warning card tui_keys handler', () => { ); }); + it('claims a normal TUI text-input card until the worker/backend ACK arrives', async () => { + const ds = makeDs({ + worker: { killed: false, connected: true, send: workerSend } as any, + tuiPromptCardId: CARD_ID, + }); + const deps = makeDeps(new Map([[sessionKey(ROOT_ID, APP_ID), ds]])); + + await handleCardAction(makeTuiTextEvent('first answer') as any, deps, APP_ID); + await handleCardAction(makeTuiTextEvent('second answer') as any, deps, APP_ID); + + expect(workerSend).toHaveBeenCalledTimes(1); + expect(ds.tuiPromptProcessing).toBe(true); + }); + + it('releases the normal TUI text-input claim when daemon-to-worker IPC delivery fails', async () => { + workerSend.mockImplementation((_message, callback?: (error: Error | null) => void) => { + callback?.(new Error('channel closed')); + return false; + }); + const ds = makeDs({ + worker: { killed: false, connected: true, send: workerSend } as any, + tuiPromptCardId: CARD_ID, + }); + + const result = await handleCardAction( + makeTuiTextEvent('retryable answer') as any, + makeDeps(new Map([[sessionKey(ROOT_ID, APP_ID), ds]])), + APP_ID, + ); + + expect(result).toMatchObject({ toast: { type: 'warning' } }); + expect(ds.tuiPromptProcessing).toBe(false); + }); + it('does not render text input as processing when nothing was dispatched', async () => { const ds = makeDs({ worker: null, diff --git a/test/stuck-warning-integration.test.ts b/test/stuck-warning-integration.test.ts index 9110389da..7eb026477 100644 --- a/test/stuck-warning-integration.test.ts +++ b/test/stuck-warning-integration.test.ts @@ -336,6 +336,7 @@ describe('stuck-warning state machine (integration)', () => { const ds = makeDs({ worker: fakeWorker, tuiPromptCardId: 'om_tui_card', + tuiPromptProcessing: true, tuiPromptOptions: [ { text: 'Approve', selected: false, type: 'select', keys: ['Enter'] }, ], @@ -350,6 +351,7 @@ describe('stuck-warning state machine (integration)', () => { await flush(); expect(updateMessageMock).not.toHaveBeenCalled(); expect(ds.tuiPromptCardId).toBe('om_tui_card'); + expect(ds.tuiPromptProcessing).toBe(true); fakeWorker.emit('message', { type: 'tui_prompt_resolved', @@ -359,6 +361,7 @@ describe('stuck-warning state machine (integration)', () => { await flush(); expect(updateMessageMock).toHaveBeenCalledTimes(1); expect(ds.tuiPromptCardId).toBeUndefined(); + expect(ds.tuiPromptProcessing).toBe(false); }); it('normal TUI backend failure renders failed instead of selected', async () => { @@ -366,6 +369,7 @@ describe('stuck-warning state machine (integration)', () => { const ds = makeDs({ worker: fakeWorker, tuiPromptCardId: 'om_tui_card', + tuiPromptProcessing: true, tuiPromptOptions: [ { text: 'Approve', selected: false, type: 'select', keys: ['Enter'] }, ], @@ -385,6 +389,7 @@ describe('stuck-warning state machine (integration)', () => { expect.stringContaining('"type":"failed"'), ); expect(ds.tuiPromptCardId).toBeUndefined(); + expect(ds.tuiPromptProcessing).toBe(false); }); it('old ACK (nonce=1) after new warning (nonce=2) does not affect new authority', async () => { From f59fb5f79c072e3c6b706d4121807284961d0139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 00:18:34 +0800 Subject: [PATCH 17/26] =?UTF-8?q?fix(zmx):=20=E6=8C=89=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E6=81=A2=E5=A4=8D=E9=83=A8=E5=88=86=E7=B2=98?= =?UTF-8?q?=E8=B4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/zmx-backend.ts | 37 ++++++++++++++++++++++++----- test/zmx-backend-recovery.test.ts | 11 +++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index 018e080ec..5fa57fbf2 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -65,6 +65,8 @@ const ZMX_SEND_CHUNK_BYTES = 1024; // one-shot payloads well below that ceiling before writing any prefix; adapters // that intentionally stream larger input already split and throttle their calls. const ZMX_SEND_MAX_BYTES = 64 * 1024; +const BRACKETED_PASTE_START_BYTES = Buffer.from('\x1b[200~'); +const BRACKETED_PASTE_END = '\x1b[201~'; const ZMX_TRANSPORT_LABEL = 'botmux.transport'; const ZMX_TRANSPORT = 'tail-send-v1'; const ZMX_SESSION_LABEL = 'botmux.session'; @@ -72,6 +74,25 @@ const ZMX_LAUNCH_PID_LABEL = 'botmux.launch_pid'; type BackendState = 'idle' | 'observing' | 'recovering' | 'stopped' | 'exited'; +function hasUnclosedBracketedPaste(bytes: Buffer): boolean { + const endBytes = Buffer.from(BRACKETED_PASTE_END); + let openPastes = 0; + let cursor = 0; + while (cursor < bytes.length) { + const nextOpen = bytes.indexOf(BRACKETED_PASTE_START_BYTES, cursor); + const nextClose = bytes.indexOf(endBytes, cursor); + if (nextOpen < 0 && nextClose < 0) break; + if (nextOpen >= 0 && (nextClose < 0 || nextOpen < nextClose)) { + openPastes += 1; + cursor = nextOpen + BRACKETED_PASTE_START_BYTES.length; + continue; + } + if (openPastes > 0) openPastes -= 1; + cursor = nextClose + endBytes.length; + } + return openPastes > 0; +} + type BackingIdentityProbe = | { state: 'compatible'; clients: number | null } | { state: 'missing' } @@ -1327,9 +1348,11 @@ export class ZmxBackend implements SessionBackend { if (stdout.trim()) { logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`); const probe = this.verifyBackingIdentity('send rejection'); - if (bracketedPaste || offset > 0) { + const closeOpenPaste = bracketedPaste + || hasUnclosedBracketedPaste(bytes.subarray(0, offset)); + if (closeOpenPaste || offset > 0) { if (probe.state === 'compatible') { - this.abortPartialSend(bracketedPaste); + this.abortPartialSend(closeOpenPaste); } else { logger.warn( `[zmx:${this.sessionName}] skipped partial-send recovery: ` + @@ -1347,9 +1370,11 @@ export class ZmxBackend implements SessionBackend { ); // Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying // can duplicate a prompt that the daemon already queued. - if (bracketedPaste || offset > 0) { + const closeOpenPaste = bracketedPaste + || hasUnclosedBracketedPaste(bytes.subarray(0, offset)); + if (closeOpenPaste || offset > 0) { if (probe.state === 'compatible') { - this.abortPartialSend(bracketedPaste); + this.abortPartialSend(closeOpenPaste); } else { logger.warn( `[zmx:${this.sessionName}] skipped partial-send recovery: ` + @@ -1364,12 +1389,12 @@ export class ZmxBackend implements SessionBackend { return true; } - private abortPartialSend(bracketedPaste: boolean): void { + private abortPartialSend(closeOpenPaste: boolean): void { if (!this.lastOpts) return; // A failed multi-frame paste may have delivered the opening marker but not // its close. Best-effort close it first, then cancel the partial composer // so a later retry cannot append to/trivially submit truncated input. - const recovery = bracketedPaste ? '\x1b[201~\x03' : '\x03'; + const recovery = closeOpenPaste ? `${BRACKETED_PASTE_END}\x03` : '\x03'; try { const stdout = execFileSync('zmx', ['send', this.sessionName], { input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]), diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index 173d89c9c..f832bc3cf 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -534,6 +534,17 @@ describe('ZmxBackend history-authoritative transport', () => { expect(state.sendInputs[2]!.toString()).toBe('\x1b[201~\x03\n'); }); + it('closes an explicit bracketed paste prefix delivered through sendText', () => { + const backend = spawnBackend(); + state.failSendAt = 2; + const ompStylePaste = `\x1b[200~${'中'.repeat(512)}\x1b[201~`; + + expect(backend.sendText(ompStylePaste)).toBe(false); + expect(state.sendInputs).toHaveLength(3); + expect(state.sendInputs[0]!.subarray(0, 6).toString()).toBe('\x1b[200~'); + expect(state.sendInputs[2]!.toString()).toBe('\x1b[201~\x03\n'); + }); + it('does not inject partial-send recovery into a replacement session', () => { const backend = spawnBackend(); state.failSendAt = 2; From 3e778a8ff3adb9c5c78816819ed45617cb16043a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 00:18:42 +0800 Subject: [PATCH 18/26] =?UTF-8?q?chore(cli):=20=E7=A7=BB=E9=99=A4=E8=AF=AF?= =?UTF-8?q?=E5=B8=A6=E7=9A=84=20title=20=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-site/docs/en/cli-commands.md | 1 - docs-site/docs/zh/cli-commands.md | 1 - src/cli.ts | 134 ------------------------------ 3 files changed, 136 deletions(-) diff --git a/docs-site/docs/en/cli-commands.md b/docs-site/docs/en/cli-commands.md index 186ee8541..4c70a5a95 100644 --- a/docs-site/docs/en/cli-commands.md +++ b/docs-site/docs/en/cli-commands.md @@ -12,7 +12,6 @@ Manage the daemon and sessions from the terminal. | `botmux status` | View daemon status | | `botmux upgrade` | Upgrade to the latest version | | `botmux list` (alias `ls`) | Interactively list active sessions; select a managed tmux / ZMX session and press Enter to attach (use `--plain` in scripts) | -| `botmux title [--session-id <id-or-prefix>] <new-title>` | Rename the current or specified session | | `botmux delete <id>` (aliases `del`/`rm`) | Close the specified session, with ID prefix matching | | `botmux delete all` | Close all active sessions | | `botmux delete stopped` | Clean up zombie sessions whose processes have exited | diff --git a/docs-site/docs/zh/cli-commands.md b/docs-site/docs/zh/cli-commands.md index 44b0fa68c..da1d7de41 100644 --- a/docs-site/docs/zh/cli-commands.md +++ b/docs-site/docs/zh/cli-commands.md @@ -12,7 +12,6 @@ | `botmux status` | 查看 daemon 状态 | | `botmux upgrade` | 升级到最新版本 | | `botmux list` (别名 `ls`) | 交互式列出活跃会话;选中受管 tmux / ZMX 会话后按 Enter 可 attach(脚本使用 `--plain`) | -| `botmux title [--session-id <id或前缀>] <新标题>` | 修改当前或指定会话的标题 | | `botmux delete <id>` (别名 `del`/`rm`) | 关闭指定会话,支持 ID 前缀匹配 | | `botmux delete all` | 关闭所有活跃会话 | | `botmux delete stopped` | 清理进程已退出的僵尸会话 | diff --git a/src/cli.ts b/src/cli.ts index 8046387e3..1cea159ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,7 +15,6 @@ * botmux device enroll|status|logout — manage the host desktop device credential * botmux list — interactive session picker (TUI), attach to managed tmux/ZMX sessions * botmux list --plain — plain table output (for piping / scripts) - * botmux title [--session-id <id>] <title> — rename a session title * botmux delete <id> — close a session by ID prefix * botmux delete all — close all active sessions * botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd / Windows Task Scheduler) @@ -175,7 +174,6 @@ import { probePersistentSessions, type PersistentBackendType, } from './core/persistent-backend.js'; -import { normalizeSessionTitle } from './core/session-board.js'; // Resolve the CLI's UI locale once from the global config file, so subsequent // CLI output (and any t() callers that don't pass an explicit locale) honour @@ -3086,8 +3084,6 @@ interface SessionData { memberEpoch: number; }; title: string; - titleUpdatedAt?: string; - titleSource?: string; status: 'active' | 'closed'; createdAt: string; lastMessageAt?: string; @@ -4820,136 +4816,6 @@ async function cmdResume(): Promise<void> { process.exit(1); } -function titleCliUsage(): never { - console.error('用法: botmux title [--session-id <session-id|prefix>] <新标题>'); - console.error(' 在 botmux 会话里运行时可省略 --session-id,会自动使用当前会话。'); - process.exit(1); -} - -function parseTitleArgs(rest: string[]): { sessionIdArg?: string; title: string; json: boolean } { - const titleParts: string[] = []; - let sessionIdArg: string | undefined; - let json = false; - for (let i = 0; i < rest.length; i++) { - const token = rest[i]!; - if (token === '--session-id' || token === '-s') { - sessionIdArg = rest[i + 1]; - i++; - continue; - } - if (token === '--json') { - json = true; - continue; - } - if (token === '--') { - titleParts.push(...rest.slice(i + 1)); - break; - } - titleParts.push(token); - } - return { sessionIdArg, title: titleParts.join(' ').trim(), json }; -} - -function resolveSessionByIdOrPrefix(sessions: SessionData[], idOrPrefix: string): SessionData | null { - const exact = sessions.find(s => s.sessionId === idOrPrefix); - if (exact) return exact; - const matches = sessions.filter(s => s.sessionId.startsWith(idOrPrefix)); - if (matches.length === 1) return matches[0]!; - if (matches.length > 1) { - console.error(`❌ "${idOrPrefix}" 匹配了 ${matches.length} 个会话,请提供更长的 ID 前缀:`); - for (const s of matches) console.error(` ${s.sessionId.substring(0, 12)} ${s.title}`); - process.exit(1); - } - return null; -} - -async function cmdTitle(rest: string[]): Promise<void> { - const parsed = parseTitleArgs(rest); - const title = normalizeSessionTitle(parsed.title); - if (!title) titleCliUsage(); - - process.env.SESSION_DATA_DIR ??= resolveDataDir(); - const sessions = [...loadSessions().values()]; - const inferredSid = parsed.sessionIdArg ?? findAncestorSessionId(); - let session: SessionData | null = null; - - if (inferredSid) { - session = resolveSessionByIdOrPrefix(sessions, inferredSid); - } else { - const active = sessions.filter(s => s.status === 'active'); - if (active.length === 1) session = active[0]!; - else titleCliUsage(); - } - - if (!session) { - console.error(`❌ 未找到会话:${inferredSid}`); - process.exit(1); - } - - if (!session.larkAppId) { - const online = listOnlineDaemons(); - if (online.length > 1) { - console.error(`❌ 会话 ${session.sessionId.substring(0, 12)} 缺少 larkAppId,多 bot 部署下无法判定归属。`); - console.error(` 在线 daemon (${online.length}): ${online.map(d => d.larkAppId).join(', ')}`); - process.exit(1); - } - if (online.length === 0) { - console.error('❌ 没有在线 daemon。改名需要 daemon 广播 SSE;请先:botmux start'); - process.exit(1); - } - } - - const daemon = findDaemon(session.larkAppId); - if (!daemon) { - console.error('❌ 未找到在线 daemon。改名需要 daemon 广播 SSE;请确认 daemon 正在运行:botmux status'); - process.exit(1); - } - - const source = process.env.BOTMUX_SESSION_ID === session.sessionId ? 'agent' : 'cli'; - let res: Response; - try { - res = source === 'agent' - ? await postSessionCliIpc( - daemon.ipcPort, - session.sessionId, - 'rename', - { title, source }, - ) - : await fetchDaemonIpc( - daemon.ipcPort, - `/api/sessions/${encodeURIComponent(session.sessionId)}/rename`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ title, source }), - }, - ); - } catch (err: any) { - console.error(`❌ 无法连接到 daemon (port=${daemon.ipcPort}): ${err?.message ?? err}`); - process.exit(1); - } - - let body: any = {}; - try { body = await res.json(); } catch { /* */ } - if (!res.ok || !body?.ok) { - console.error(`❌ 改名失败: ${body?.error ?? `HTTP ${res.status}`}`); - process.exit(1); - } - - if (parsed.json) { - console.log(JSON.stringify({ - ok: true, - sessionId: session.sessionId, - title: body.title, - titleUpdatedAt: body.titleUpdatedAt, - titleSource: body.titleSource, - })); - return; - } - console.log(`✅ 会话标题已更新: ${body.title}`); - console.log(` 会话: ${session.sessionId.substring(0, 12)}`); -} - /** * `botmux term-link [session-id|prefix]` — get the writable ("可操作") terminal * for an active session. The link carries a write token, so rather than print it From 1acac2f955bbec61438b4b1af4af1bd72f3cd9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 01:01:31 +0800 Subject: [PATCH 19/26] =?UTF-8?q?fix(session):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=A5=E7=BE=A4=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=8D=A0=E4=BD=8D=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/session-manager.ts | 23 +- src/daemon.ts | 191 ++++++++++- src/im/lark/card-handler.ts | 5 +- test/card-integration.test.ts | 49 ++- test/group-join-shared-routing.test.ts | 452 ++++++++++++++++++++++++- test/scheduler-silent-execute.test.ts | 104 +++++- 6 files changed, 811 insertions(+), 13 deletions(-) diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index e0a77afae..42a9ce653 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -2219,6 +2219,23 @@ export async function executeScheduledTask( // creating a competing row that the registration CAS will reject. const activeKey = sessionKey(anchor, larkAppId); const existing = activeSessions.get(activeKey); + const blockedDeferredSetup = existing && ( + existing.session.queued + || existing.pendingRepo + || existing.pendingRepoCommitInFlight + || existing.worktreeCreating + ); + if (isContinuation && blockedDeferredSetup) { + // Dashboard backlog and repo/worktree setup rows deliberately own the route + // while keeping their worker null. Cold-resuming one with the scheduled + // prompt either consumes queuedPrompt or races the later deferred fork. + // Reject observably while preserving the user's pending setup intact. + const setupKind = existing.session.queued ? 'queued' : 'pending setup'; + throw new Error( + `scheduled continuation ${task.id} is blocked by ${setupKind} session ${existing.session.sessionId}; ` + + 'the deferred task was preserved', + ); + } if (isContinuation && existing) { markSessionActivity(existing); ensureSessionWhiteboard(existing); @@ -2314,8 +2331,12 @@ export async function executeScheduledTask( ensureSessionWhiteboard(ds); const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId }); if (!setActiveSessionIfActive(activeSessions, activeKey, ds)) { + const winner = activeSessions.get(activeKey); await closeSession(session.sessionId); - return; + throw new Error( + `scheduled task ${task.id} lost active-session registration for ${anchor}` + + (winner ? ` to ${winner.session.sessionId}` : ''), + ); } rememberLastCliInput(ds, task.prompt, prompt); if (silent) armSilentScheduledTurn(ds, scheduledTurnId); diff --git a/src/daemon.ts b/src/daemon.ts index 9f4e96ce2..5c3c27a6d 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -43,7 +43,7 @@ import { } from './core/cli-runtime-update.js'; import { sendRestartReportIfPending } from './core/restart-report.js'; import { statSync } from 'node:fs'; -import { addReaction, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage, type EntryResolveStatus } from './im/lark/client.js'; +import { addReaction, deleteMessage, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage, type EntryResolveStatus } from './im/lark/client.js'; import { resolveGroupJoinPrompt, waitForAllowedUserInChat } from './core/auto-start.js'; import { loadBotConfigAtIndex, @@ -15855,6 +15855,85 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { // for the same chat (reconnect replay / double-delivery) can't both spawn — // claimed synchronously before the first await, released in `finally` (FR-13). const autoStartJoinInFlight = new Set<string>(); +// A join candidate becomes visible in activeSessions before its seed/reply +// target and synchronous bootstrap are ready. Inbound turns for that exact +// routing key must wait; otherwise they can re-fork the worker-null session and +// then be replaced by the join handler's own fork. Deferred pendingRepo / +// auto-worktree paths release after publishing their stable buffered state. +const AUTO_START_JOIN_READY_MAX_WAIT_MS = 5_000; +let testOnlyAutoStartJoinReadyMaxWaitMs: number | undefined; + +interface AutoStartJoinReadyBarrier { + ready: Promise<void>; + cancelled: boolean; + settle: () => void; + cancel: () => void; +} + +const autoStartJoinReadyBySessionKey = new Map<string, AutoStartJoinReadyBarrier>(); + +function beginAutoStartJoinReadyBarrier( + routingKey: string, + onCancel: () => void, +): AutoStartJoinReadyBarrier { + let resolveReady!: () => void; + const ready = new Promise<void>((resolve) => { + resolveReady = resolve; + }); + let settled = false; + let barrier!: AutoStartJoinReadyBarrier; + const settle = () => { + if (settled) return; + settled = true; + resolveReady(); + if (autoStartJoinReadyBySessionKey.get(routingKey) === barrier) { + autoStartJoinReadyBySessionKey.delete(routingKey); + } + }; + const cancel = () => { + if (settled) return; + barrier.cancelled = true; + try { + onCancel(); + } catch (err) { + logger.warn(`[auto-start:入群] bootstrap timeout cleanup failed for ${routingKey}: ${err}`); + } finally { + settle(); + } + }; + barrier = { ready, cancelled: false, settle, cancel }; + autoStartJoinReadyBySessionKey.set(routingKey, barrier); + return barrier; +} + +async function waitForAutoStartJoinReady(larkAppId: string, anchor: string): Promise<void> { + const routingKey = sessionKey(anchor, larkAppId); + while (true) { + const barrier = autoStartJoinReadyBySessionKey.get(routingKey); + if (!barrier) return; + const maxWaitMs = testOnlyAutoStartJoinReadyMaxWaitMs ?? AUTO_START_JOIN_READY_MAX_WAIT_MS; + let timer: ReturnType<typeof setTimeout> | undefined; + const outcome = await Promise.race([ + barrier.ready.then(() => 'ready' as const), + new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), maxWaitMs); + }), + ]); + if (timer) clearTimeout(timer); + if (outcome === 'timeout' && autoStartJoinReadyBySessionKey.get(routingKey) === barrier) { + logger.warn( + `[auto-start:入群] bootstrap exceeded ${maxWaitMs}ms for ${anchor.substring(0, 12)}; ` + + `yielding registration to the waiting turn`, + ); + barrier.cancel(); + } + } +} + +export function __testOnly_setAutoStartJoinReadyMaxWaitMs(value?: number): void { + testOnlyAutoStartJoinReadyMaxWaitMs = value; +} + // 主动开工 — 场景①: `${appId}:${chatId}` → the activeSessions key of the // auto-started join session. Needed because in a 话题群 the session is keyed at // the seed message id (not chatId), so a plain `activeSessions.has(chatId)` @@ -15923,6 +16002,7 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined } if (priorAnchorKey) groupJoinAnchorByChat.delete(chatLiveKey); // stale entry, will re-register below autoStartJoinInFlight.add(lockKey); + let joinReady: AutoStartJoinReadyBarrier | undefined; try { // D7 gate: an allowedUser must be a member of the chat. Alarm/oncall // platforms (e.g. Nexus) create the chat, add bots first and the human @@ -16021,6 +16101,23 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined await closeSessionHelper(session.sessionId); return; } + // Publish the barrier only after this candidate wins registration. There is + // no await between the CAS and this write, so a turn can never observe the + // worker-null session without also observing the barrier. Publishing earlier + // would unnecessarily stall real messages during membership retry and + // mode/working-dir discovery, where their own session may safely win. + joinReady = beginAutoStartJoinReadyBarrier(dsKey, () => { + // A user turn waited longer than the serializer's normal safety cap. + // Relinquish this half-bootstrapped registration instead of letting the + // turn fall back to the old "refork now, then get killed by late join" + // behavior. closeSessionHelper completes map/status/store mutation before + // its first await; the late best-effort cleanup must not hold the turn. + const closing = closeSessionHelper(session.sessionId); + if (activeSessions.get(dsKey) === ds) activeSessions.delete(dsKey); + void closing.catch((err) => { + logger.warn(`[auto-start:入群] timeout close failed for ${session.sessionId.substring(0, 8)}: ${err}`); + }); + }); const rollbackRegisteredJoinSession = async (): Promise<void> => { // closeSession transitions the row before its first await, but this // handler owns a caller-supplied registry too. Drop only our identity so @@ -16034,6 +16131,32 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined // after this candidate owns the routing key; a CAS loser must leave no // orphaned "joined" topic and must not silently consume the first prompt. let sharedReplyRootId: string | undefined; + const withdrawSharedReplySeed = (): void => { + if (!sharedReplyRootId) return; + const messageId = sharedReplyRootId; + sharedReplyRootId = undefined; + void deleteMessage(larkAppId, messageId); + }; + const joinBootstrapWasTakenOver = (): boolean => { + const takenOver = ( + joinReady?.cancelled === true + || activeSessions.get(dsKey) !== ds + || ds.session.status !== 'active' + || !!(ds.worker && !ds.worker.killed) + ); + if (takenOver && ds.worker && !ds.worker.killed) { + logger.warn( + `[auto-start:入群] ${chatId.substring(0, 12)} bootstrap 期间会话已由其它入口启动,让位且不再 refork`, + ); + } + return takenOver; + }; + const armSharedReplyTarget = (): void => { + if (!sharedReplyRootId) return; + ds.pendingTurnId = sharedReplyRootId; + beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString()); + sessionStore.updateSession(ds.session); + }; if (needsSharedReply) { try { sharedReplyRootId = await sendMessage( @@ -16047,14 +16170,23 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined await rollbackRegisteredJoinSession(); return; } + if (joinReady.cancelled) { + // The send may have reached Lark even though its response exceeded the + // local wait cap. Best-effort withdraw the now-ownerless shared seed. + withdrawSharedReplySeed(); + return; + } if (activeSessions.get(dsKey) !== ds || ds.session.status !== 'active') { logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} shared seed 返回时会话已被替换,停止首轮`); + withdrawSharedReplySeed(); await rollbackRegisteredJoinSession(); return; } - ds.pendingTurnId = sharedReplyRootId; - beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString()); - sessionStore.updateSession(ds.session); + if (ds.worker && !ds.worker.killed) { + withdrawSharedReplySeed(); + logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} shared seed 返回时会话已由其它入口启动,让位`); + return; + } } // Register the anchor so a later duplicate bot.added for this chat is deduped // even in 话题群 (where dsKey is the seed id, not chatId). @@ -16070,17 +16202,33 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined // Auto-worktree: register PENDING, build worktree off-path, commit+fork later. if (pinnedWorkingDir && autoWt) { - if (await replyInvalidWorkingDirs(anchor, larkAppId, ds)) return; + if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) { + withdrawSharedReplySeed(); + return; + } + armSharedReplyTarget(); startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title, prompt: promptBody, operatorOpenId }); return; } // Pinned working dir → spawn immediately. if (pinnedWorkingDir) { - if (await replyInvalidWorkingDirs(anchor, larkAppId, ds)) return; + if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) { + withdrawSharedReplySeed(); + return; + } ensureSessionWhiteboard(ds); const prompt = await buildPrompt(); + if (joinBootstrapWasTakenOver()) { + withdrawSharedReplySeed(); + return; + } await noteTurnReceived(ds, anchor, promptBody); + if (joinBootstrapWasTakenOver()) { + withdrawSharedReplySeed(); + return; + } + armSharedReplyTarget(); rememberLastCliInput(ds, promptBody, prompt); forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false); ds.pendingTurnId = undefined; @@ -16089,26 +16237,52 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined } // No default dir → degrade to repo-selection card (D6 / FR-4). - if (await replyInvalidWorkingDirs(anchor, larkAppId, ds)) return; + if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) { + withdrawSharedReplySeed(); + return; + } const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d)); const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : []; if (projects.length > 0) { lastRepoScan.set(chatId, projects); const cardJson = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker); - ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId); + // The repo card itself belongs under the shared join seed. Arm the target + // immediately before its single network await; pendingRepo already makes + // this a stable buffered state for normal inbound/scheduler paths. + armSharedReplyTarget(); + const repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId); + if (joinBootstrapWasTakenOver()) { + void deleteMessage(larkAppId, repoCardMessageId); + // If another entry actually started this same ds, its output still owns + // the shared seed we just armed. Preserve that root; closed/replaced + // candidates may withdraw it. + if (!ds.worker || ds.worker.killed) withdrawSharedReplySeed(); + return; + } + ds.repoCardMessageId = repoCardMessageId; announcePendingRepoSession(ds); logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录,弹 repo 选择卡(${projects.length} 个项目)`); } else { ds.pendingRepo = false; ensureSessionWhiteboard(ds); const prompt = await buildPrompt(); + if (joinBootstrapWasTakenOver()) { + withdrawSharedReplySeed(); + return; + } await noteTurnReceived(ds, anchor, promptBody); + if (joinBootstrapWasTakenOver()) { + withdrawSharedReplySeed(); + return; + } + armSharedReplyTarget(); rememberLastCliInput(ds, promptBody, prompt); forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false); ds.pendingTurnId = undefined; logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录且无可选项目,直接开工`); } } finally { + joinReady?.settle(); autoStartJoinInFlight.delete(lockKey); } } @@ -16169,6 +16343,7 @@ async function handleThreadReply( prepared?: PreparedThreadReply, ): Promise<void> { const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId, replyRootId, substituteTrigger } = ctx; + await waitForAutoStartJoinReady(larkAppId, anchor); if (!prepared) await resolveNonsupportMessage(data, larkAppId); const parsedResult = prepared ?? parseEventMessage(data); const parsed = parsedResult.parsed; diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 1e36f4626..f393c7f63 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -1838,7 +1838,10 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe }, }; } - activeSessions.delete(sKey); + // closeSession removes the captured ds before awaiting best-effort remote + // cleanup. A new session may claim the same route during that await; only + // delete here if this handler still owns the exact object it closed. + if (activeSessions.get(sKey) === ds) activeSessions.delete(sKey); // The closed card carries session title / CLI name / workingDir / resume // command. In private-card mode those must not leak to the group — send the // closed card ephemeral to the same owner audience instead. No group diff --git a/test/card-integration.test.ts b/test/card-integration.test.ts index c18b1644d..7aeadc09f 100644 --- a/test/card-integration.test.ts +++ b/test/card-integration.test.ts @@ -130,6 +130,7 @@ vi.mock('../src/core/worker-pool.js', async (importOriginal) => { const orig = await importOriginal<typeof import('../src/core/worker-pool.js')>(); return { ...orig, + closeSession: vi.fn((sessionId: string) => orig.closeSession(sessionId)), forkWorker: vi.fn(), killWorker: vi.fn(), initWorkerPool: vi.fn(), @@ -168,7 +169,13 @@ vi.mock('@larksuiteoapi/node-sdk', () => ({ // ─── Imports ────────────────────────────────────────────────────────────── import { handleCardAction, type CardHandlerDeps } from '../src/im/lark/card-handler.js'; -import { scheduleCardPatch, setActiveSessionsRegistry, forkWorker, requestSessionRestart } from '../src/core/worker-pool.js'; +import { + closeSession as closeWorkerSession, + scheduleCardPatch, + setActiveSessionsRegistry, + forkWorker, + requestSessionRestart, +} from '../src/core/worker-pool.js'; import { sessionKey } from '../src/core/types.js'; import type { DaemonSession } from '../src/core/types.js'; import { buildStreamingCard } from '../src/im/lark/card-builder.js'; @@ -871,6 +878,46 @@ describe('Card integration: full event flow', () => { ); }); + it('does not delete a replacement session that wins the route while close cleanup awaits', async () => { + const ds = makeDaemonSession({ scope: 'thread' }); + const replacement = makeDaemonSession({ + session: { + ...ds.session, + sessionId: 'uuid-replacement-after-close', + status: 'active' as any, + }, + }); + const sessions = new Map<string, DaemonSession>(); + const sKey = sessionKey(ROOT_ID, APP_ID); + sessions.set(sKey, ds); + const deps = makeDeps(sessions); + let signalCloseStarted!: () => void; + const closeStarted = new Promise<void>((resolve) => { + signalCloseStarted = resolve; + }); + let releaseClose!: () => void; + const closeCleanupPending = new Promise<void>((resolve) => { + releaseClose = resolve; + }); + vi.mocked(closeWorkerSession).mockImplementationOnce(async () => { + // Production closeSession removes and closes the captured ds before its + // first network-cleanup await. A new session may then claim the key. + sessions.delete(sKey); + ds.session.status = 'closed'; + signalCloseStarted(); + await closeCleanupPending; + return { ok: true, alreadyClosed: false, known: true }; + }); + + const closeAction = handleCardAction(makeCloseEvent(ROOT_ID), deps, APP_ID); + await closeStarted; + sessions.set(sKey, replacement); + releaseClose(); + await closeAction; + + expect(sessions.get(sKey)).toBe(replacement); + }); + it('resume notice in a thread-scope session is replied in-thread, never ephemeral', async () => { const clientMod = await import('../src/im/lark/client.js'); const sessionId = 'closed-uuid-thread'; diff --git a/test/group-join-shared-routing.test.ts b/test/group-join-shared-routing.test.ts index 3d10f9ea2..b32aa4c07 100644 --- a/test/group-join-shared-routing.test.ts +++ b/test/group-join-shared-routing.test.ts @@ -6,7 +6,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ forkWorker: vi.fn(), @@ -14,6 +14,11 @@ const mocks = vi.hoisted(() => ({ getChatMode: vi.fn(async () => 'group' as 'group' | 'topic' | 'p2p'), getProjectScanDirs: vi.fn(() => [] as string[]), ensureDefaultOncallBound: vi.fn(async () => undefined), + downloadResources: vi.fn(async () => ({ attachments: [] as any[], needLogin: false })), + deleteMessage: vi.fn(async () => true), + resolveSender: vi.fn(async (_appId: string, openId: string | undefined) => ( + openId ? { openId, type: 'user' as const, name: 'Owner' } : undefined + )), listChatMemberOpenIds: vi.fn(async () => ['ou_owner']), replyMessage: vi.fn(async () => 'om_reply'), scanMultipleProjects: vi.fn(() => [] as Array<{ name: string; path: string; type: 'repo' | 'worktree'; branch: string }>), @@ -36,6 +41,7 @@ vi.mock('../src/im/lark/client.js', async () => { const actual = await vi.importActual<any>('../src/im/lark/client.js'); return { ...actual, + deleteMessage: mocks.deleteMessage, getChatMode: mocks.getChatMode, listChatMemberOpenIds: mocks.listChatMemberOpenIds, replyMessage: mocks.replyMessage, @@ -47,12 +53,18 @@ vi.mock('../src/core/session-manager.js', async () => { const actual = await vi.importActual<any>('../src/core/session-manager.js'); return { ...actual, + downloadResources: mocks.downloadResources, ensureSessionWhiteboard: vi.fn(), getAvailableBots: mocks.getAvailableBots, getProjectScanDirs: mocks.getProjectScanDirs, }; }); +vi.mock('../src/im/lark/identity-cache.js', async () => { + const actual = await vi.importActual<any>('../src/im/lark/identity-cache.js'); + return { ...actual, resolveSender: mocks.resolveSender }; +}); + vi.mock('../src/services/project-scanner.js', async () => { const actual = await vi.importActual<any>('../src/services/project-scanner.js'); return { ...actual, scanMultipleProjects: mocks.scanMultipleProjects }; @@ -95,16 +107,27 @@ beforeAll(async () => { beforeEach(() => { modules.registry.__testOnly_resetBotRegistry(); modules.daemon.__testOnly_activeSessions.clear(); + modules.daemon.__testOnly_setAutoStartJoinReadyMaxWaitMs(); vi.clearAllMocks(); + mocks.forkWorker.mockReset(); mocks.getChatMode.mockResolvedValue('group'); mocks.getProjectScanDirs.mockReturnValue([]); mocks.ensureDefaultOncallBound.mockResolvedValue(undefined); + mocks.downloadResources.mockResolvedValue({ attachments: [], needLogin: false }); + mocks.deleteMessage.mockResolvedValue(true); + mocks.resolveSender.mockImplementation(async (_appId: string, openId: string | undefined) => ( + openId ? { openId, type: 'user', name: 'Owner' } : undefined + )); mocks.listChatMemberOpenIds.mockResolvedValue(['ou_owner']); mocks.replyMessage.mockResolvedValue('om_reply'); mocks.scanMultipleProjects.mockReturnValue([]); mocks.sendMessage.mockResolvedValue('om_join_seed'); }); +afterEach(() => { + modules.daemon.__testOnly_setAutoStartJoinReadyMaxWaitMs(); +}); + afterAll(() => { delete process.env.SESSION_DATA_DIR; rmSync(tempRoot, { recursive: true, force: true }); @@ -332,6 +355,433 @@ describe('handleBotAdded — 普通群 shared 路由', () => { expect(mocks.forkWorker).toHaveBeenCalledTimes(1); }); + it('serializes a chat turn behind the registered join session initialization', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_shared_inbound_race'; + const chatId = 'oc_join_shared_inbound_race'; + const userMessageId = 'om_user_during_join'; + const key = types.sessionKey(chatId, appId); + let releaseSeed!: (messageId: string) => void; + const seedPending = new Promise<string>((resolve) => { + releaseSeed = resolve; + }); + let seedStarted!: () => void; + const seedStartedPromise = new Promise<void>((resolve) => { + seedStarted = resolve; + }); + mocks.sendMessage.mockImplementationOnce(async () => { + seedStarted(); + return await seedPending; + }); + mocks.forkWorker.mockImplementation((ds: any) => { + ds.worker = { killed: false, send: vi.fn() }; + }); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-shared-inbound-race'), + regularGroupReplyMode: 'shared', + }); + + const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + await seedStartedPromise; + expect(daemon.__testOnly_activeSessions.get(key)?.worker).toBeNull(); + + const replyPromise = daemon.__testOnly_handleThreadReply( + { + sender: { sender_id: { open_id: 'ou_owner' }, sender_type: 'user' }, + message: { + message_id: userMessageId, + chat_id: chatId, + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: '加入期间的用户消息' }), + create_time: String(Date.now()), + }, + }, + { + chatId, + messageId: userMessageId, + chatType: 'group', + scope: 'chat', + anchor: chatId, + replyRootId: userMessageId, + larkAppId: appId, + }, + ); + await new Promise<void>((resolve) => setImmediate(resolve)); + + expect(mocks.forkWorker).not.toHaveBeenCalled(); + + releaseSeed('om_join_seed'); + await Promise.all([joinPromise, replyPromise]); + + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + const ds = daemon.__testOnly_activeSessions.get(key); + expect(ds?.worker?.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'message', + turnId: userMessageId, + })); + expect(ds?.session.currentReplyTarget).toMatchObject({ + rootMessageId: userMessageId, + turnId: userMessageId, + }); + }); + + it('also covers the non-shared post-registration fork window', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_chat_inbound_race'; + const chatId = 'oc_join_chat_inbound_race'; + const userMessageId = 'om_user_during_chat_join'; + const key = types.sessionKey(chatId, appId); + let releaseAvailableBots!: () => void; + const availableBotsPending = new Promise<void>((resolve) => { + releaseAvailableBots = resolve; + }); + let availableBotsStarted!: () => void; + const availableBotsStartedPromise = new Promise<void>((resolve) => { + availableBotsStarted = resolve; + }); + mocks.getAvailableBots.mockImplementationOnce(async () => { + availableBotsStarted(); + await availableBotsPending; + return []; + }); + mocks.forkWorker.mockImplementation((ds: any) => { + ds.worker = { killed: false, send: vi.fn() }; + }); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-chat-inbound-race'), + regularGroupReplyMode: 'chat', + }); + + const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + await availableBotsStartedPromise; + expect(daemon.__testOnly_activeSessions.get(key)?.worker).toBeNull(); + + const replyPromise = daemon.__testOnly_handleThreadReply( + { + sender: { sender_id: { open_id: 'ou_owner' }, sender_type: 'user' }, + message: { + message_id: userMessageId, + chat_id: chatId, + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: '普通 chat 模式并发消息' }), + create_time: String(Date.now()), + }, + }, + { + chatId, + messageId: userMessageId, + chatType: 'group', + scope: 'chat', + anchor: chatId, + larkAppId: appId, + }, + ); + await new Promise<void>((resolve) => setImmediate(resolve)); + + expect(mocks.forkWorker).not.toHaveBeenCalled(); + + releaseAvailableBots(); + await Promise.all([joinPromise, replyPromise]); + + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + const ds = daemon.__testOnly_activeSessions.get(key); + expect(ds?.worker?.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'message', + turnId: userMessageId, + })); + }); + + it('yields without re-forking when a non-message entry starts the registered session', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_external_fork_race'; + const chatId = 'oc_join_external_fork_race'; + const key = types.sessionKey(chatId, appId); + let releaseAvailableBots!: () => void; + const availableBotsPending = new Promise<void>((resolve) => { + releaseAvailableBots = resolve; + }); + let availableBotsStarted!: () => void; + const availableBotsStartedPromise = new Promise<void>((resolve) => { + availableBotsStarted = resolve; + }); + mocks.getAvailableBots.mockImplementationOnce(async () => { + availableBotsStarted(); + await availableBotsPending; + return []; + }); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-external-fork-race'), + regularGroupReplyMode: 'chat', + }); + + const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + await availableBotsStartedPromise; + const ds = daemon.__testOnly_activeSessions.get(key)!; + const externalWorker = { killed: false, send: vi.fn(), pid: 4321 } as any; + ds.worker = externalWorker; + + releaseAvailableBots(); + await joinPromise; + + expect(mocks.forkWorker).not.toHaveBeenCalled(); + expect(daemon.__testOnly_activeSessions.get(key)).toBe(ds); + expect(ds.worker).toBe(externalWorker); + }); + + it('releases a waiting chat turn when shared seed setup rolls back', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_shared_inbound_seed_failure'; + const chatId = 'oc_join_shared_inbound_seed_failure'; + const userMessageId = 'om_user_after_join_seed_failure'; + const key = types.sessionKey(chatId, appId); + let rejectSeed!: (error: Error) => void; + const seedPending = new Promise<string>((_resolve, reject) => { + rejectSeed = reject; + }); + let seedStarted!: () => void; + const seedStartedPromise = new Promise<void>((resolve) => { + seedStarted = resolve; + }); + mocks.sendMessage.mockImplementationOnce(async () => { + seedStarted(); + return await seedPending; + }); + mocks.forkWorker.mockImplementation((ds: any) => { + ds.worker = { killed: false, send: vi.fn() }; + }); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-shared-inbound-seed-failure'), + regularGroupReplyMode: 'shared', + }); + + const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + await seedStartedPromise; + const rejectedJoinSessionId = daemon.__testOnly_activeSessions.get(key)?.session.sessionId; + + const replyPromise = daemon.__testOnly_handleThreadReply( + { + sender: { sender_id: { open_id: 'ou_owner' }, sender_type: 'user' }, + message: { + message_id: userMessageId, + chat_id: chatId, + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: 'seed 失败后仍需处理' }), + create_time: String(Date.now()), + }, + }, + { + chatId, + messageId: userMessageId, + chatType: 'group', + scope: 'chat', + anchor: chatId, + replyRootId: userMessageId, + larkAppId: appId, + }, + ); + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + + rejectSeed(new Error('Lark unavailable during join')); + await Promise.all([joinPromise, replyPromise]); + + const ds = daemon.__testOnly_activeSessions.get(key); + expect(ds?.session.sessionId).not.toBe(rejectedJoinSessionId); + expect(ds?.session.status).toBe('active'); + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + expect(mocks.forkWorker).toHaveBeenCalledWith( + ds, + expect.objectContaining({ content: expect.stringContaining('seed 失败后仍需处理') }), + { turnId: userMessageId }, + ); + expect(ds?.session.currentReplyTarget).toMatchObject({ + rootMessageId: userMessageId, + turnId: userMessageId, + }); + }); + + it('cancels a hung bootstrap so the waiting turn can create the authoritative session', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_shared_inbound_timeout'; + const chatId = 'oc_join_shared_inbound_timeout'; + const userMessageId = 'om_user_after_join_timeout'; + const key = types.sessionKey(chatId, appId); + let releaseSeed!: (messageId: string) => void; + const seedPending = new Promise<string>((resolve) => { + releaseSeed = resolve; + }); + let seedStarted!: () => void; + const seedStartedPromise = new Promise<void>((resolve) => { + seedStarted = resolve; + }); + mocks.sendMessage.mockImplementationOnce(async () => { + seedStarted(); + return await seedPending; + }); + mocks.forkWorker.mockImplementation((ds: any) => { + ds.worker = { killed: false, send: vi.fn() }; + }); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-shared-inbound-timeout'), + regularGroupReplyMode: 'shared', + }); + daemon.__testOnly_setAutoStartJoinReadyMaxWaitMs(20); + + const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + await seedStartedPromise; + const timedOutJoinSessionId = daemon.__testOnly_activeSessions.get(key)?.session.sessionId; + + await daemon.__testOnly_handleThreadReply( + { + sender: { sender_id: { open_id: 'ou_owner' }, sender_type: 'user' }, + message: { + message_id: userMessageId, + chat_id: chatId, + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: 'bootstrap 超时后接管' }), + create_time: String(Date.now()), + }, + }, + { + chatId, + messageId: userMessageId, + chatType: 'group', + scope: 'chat', + anchor: chatId, + replyRootId: userMessageId, + larkAppId: appId, + }, + ); + + const ds = daemon.__testOnly_activeSessions.get(key); + expect(ds?.session.sessionId).not.toBe(timedOutJoinSessionId); + expect(ds?.session.status).toBe('active'); + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + expect(mocks.forkWorker).toHaveBeenCalledWith( + ds, + expect.objectContaining({ content: expect.stringContaining('bootstrap 超时后接管') }), + { turnId: userMessageId }, + ); + + releaseSeed('om_late_join_seed'); + await joinPromise; + + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + expect(mocks.deleteMessage).toHaveBeenCalledWith(appId, 'om_late_join_seed'); + expect(daemon.__testOnly_activeSessions.get(key)).toBe(ds); + }); + + it('serializes replies to a topic-group join seed by the exact session key', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_topic_inbound_race'; + const chatId = 'oc_join_topic_inbound_race'; + const seedId = 'om_join_seed'; + const userMessageId = 'om_user_during_topic_join'; + const key = types.sessionKey(seedId, appId); + mocks.getChatMode.mockResolvedValue('topic'); + let releaseAvailableBots!: () => void; + const availableBotsPending = new Promise<void>((resolve) => { + releaseAvailableBots = resolve; + }); + let availableBotsStarted!: () => void; + const availableBotsStartedPromise = new Promise<void>((resolve) => { + availableBotsStarted = resolve; + }); + mocks.getAvailableBots.mockImplementationOnce(async () => { + availableBotsStarted(); + await availableBotsPending; + return []; + }); + mocks.forkWorker.mockImplementation((ds: any) => { + ds.worker = { killed: false, send: vi.fn() }; + }); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-topic-inbound-race'), + regularGroupReplyMode: 'shared', + }); + + const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + await availableBotsStartedPromise; + expect(daemon.__testOnly_activeSessions.get(key)?.worker).toBeNull(); + + const replyPromise = daemon.__testOnly_handleThreadReply( + { + sender: { sender_id: { open_id: 'ou_owner' }, sender_type: 'user' }, + message: { + message_id: userMessageId, + root_id: seedId, + thread_id: 'omt_join_topic', + chat_id: chatId, + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: 'seed 话题内的并发回复' }), + create_time: String(Date.now()), + }, + }, + { + chatId, + messageId: userMessageId, + chatType: 'group', + scope: 'thread', + anchor: seedId, + replyRootId: seedId, + larkAppId: appId, + }, + ); + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + + releaseAvailableBots(); + await Promise.all([joinPromise, replyPromise]); + + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + const ds = daemon.__testOnly_activeSessions.get(key); + expect(ds?.worker?.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'message', + turnId: userMessageId, + })); + }); + it('话题群继续使用 seed 锚定的 thread-scope session', async () => { mocks.getChatMode.mockResolvedValue('topic'); const { daemon, registry, types } = modules; diff --git a/test/scheduler-silent-execute.test.ts b/test/scheduler-silent-execute.test.ts index 404773a57..2e00055a5 100644 --- a/test/scheduler-silent-execute.test.ts +++ b/test/scheduler-silent-execute.test.ts @@ -60,7 +60,20 @@ vi.mock('../src/im/lark/client.js', () => ({ const forkWorkerMock = vi.fn(); const sendWorkerInputMock = vi.fn(() => true); vi.mock('../src/core/worker-pool.js', () => ({ - forkWorker: (...a: any[]) => forkWorkerMock(...a), + forkWorker: (...a: any[]) => { + // Faithfully model the production queued-session transition. A loose + // no-op mock would hide the exact regression this suite guards: forking a + // parked session permanently consumes its dashboard task. + const ds = a[0] as DaemonSession; + if (ds.session.queued) { + ds.session.queued = false; + ds.session.queuedPrompt = undefined; + ds.session.queuedCodexAppText = undefined; + ds.session.queuedCodexAppMessageContext = undefined; + store.set(ds.session.sessionId, ds.session); + } + return forkWorkerMock(...a); + }, sendWorkerInput: (...a: any[]) => sendWorkerInputMock(...a), forkAdoptWorker: vi.fn(), adoptSandboxBlocked: vi.fn((botCfg, session) => botCfg?.sandbox === true || botCfg?.readIsolation === true || session?.sandbox === true || process.env.BOTMUX_SANDBOX === '1'), @@ -463,6 +476,95 @@ describe('executeScheduledTask — live-session injection', () => { }); expect(existing.silentScheduledTurns?.has(options.turnId)).toBe(true); }); + + it('fails visibly instead of consuming a parked dashboard task', async () => { + const active = new Map<string, DaemonSession>(); + const existing = liveSession('idle'); + existing.worker = null; + existing.workerPort = null; + existing.workerToken = null; + existing.hasHistory = false; + existing.session.queued = true; + existing.session.queuedPrompt = '用户排进待办池的任务'; + active.set(sessionKey(ROOT, APP), existing); + + await expect(executeScheduledTask( + baseTask({ rootMessageId: ROOT, scope: 'thread', silent: true }), + active, + refreshCliVersion, + )).rejects.toThrow(/queued|parked|待办池/i); + + expect(sendWorkerInputMock).not.toHaveBeenCalled(); + expect(forkWorkerMock).not.toHaveBeenCalled(); + expect(active.get(sessionKey(ROOT, APP))).toBe(existing); + expect(existing.session.queued).toBe(true); + expect(existing.session.queuedPrompt).toBe('用户排进待办池的任务'); + expect(store.size).toBe(1); + }); + + it('fails visibly instead of forking a pending repo/worktree setup', async () => { + const active = new Map<string, DaemonSession>(); + const existing = liveSession('idle'); + existing.worker = null; + existing.workerPort = null; + existing.workerToken = null; + existing.hasHistory = false; + existing.pendingRepo = true; + existing.worktreeCreating = true; + existing.pendingPrompt = '等待 worktree 后执行的首轮'; + active.set(sessionKey(ROOT, APP), existing); + + await expect(executeScheduledTask( + baseTask({ rootMessageId: ROOT, scope: 'thread', silent: true }), + active, + refreshCliVersion, + )).rejects.toThrow(/pending|setup|repo|worktree/i); + + expect(sendWorkerInputMock).not.toHaveBeenCalled(); + expect(forkWorkerMock).not.toHaveBeenCalled(); + expect(active.get(sessionKey(ROOT, APP))).toBe(existing); + expect(existing.pendingRepo).toBe(true); + expect(existing.worktreeCreating).toBe(true); + expect(existing.pendingPrompt).toBe('等待 worktree 后执行的首轮'); + }); +}); + +describe('executeScheduledTask — registration rejection', () => { + it('throws after closing the rejected candidate instead of reporting a false success', async () => { + const active = new Map<string, DaemonSession>(); + const winner: DaemonSession = { + session: { + sessionId: 'sess-registration-winner', + chatId: CHAT, + rootMessageId: 'om_banner_123', + title: 'winner', + status: 'active', + createdAt: new Date('2026-01-01T00:00:00Z').toISOString(), + }, + worker: null, + workerPort: null, + workerToken: null, + larkAppId: APP, + chatId: CHAT, + chatType: 'group', + scope: 'thread', + spawnedAt: 0, + cliVersion: 'test-cli-v1', + lastMessageAt: 0, + hasHistory: false, + workingDir: '/tmp', + }; + active.set(sessionKey('om_banner_123', APP), winner); + + await expect(executeScheduledTask( + baseTask({ executionPosition: 'new-topic', silent: false }), + active, + refreshCliVersion, + )).rejects.toThrow(/registration|active session|注册/i); + + expect(active.get(sessionKey('om_banner_123', APP))).toBe(winner); + expect(forkWorkerMock).not.toHaveBeenCalled(); + }); }); describe('silent scheduled turn lifecycle', () => { From 2e0d3ec86511cf03b1266aa5251b064d29adff2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 01:01:39 +0800 Subject: [PATCH 20/26] =?UTF-8?q?fix(cli):=20=E6=B8=85=E7=90=86=E9=81=97?= =?UTF-8?q?=E7=95=99=E5=91=BD=E4=BB=A4=E5=B9=B6=E6=A0=A1=E5=87=86=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 7 +------ src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + src/worker.ts | 5 ++++- test/raw-input-followup-atomicity.test.ts | 13 +++++++++++++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 1cea159ac..a3ed93ea1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3767,7 +3767,6 @@ function interactiveSessionPicker(active: SessionData[], probeSnapshot: BackingP let cursor = 0; let confirmDelete = false; // true when waiting for y/n confirmation - let deleting = false; let flashMsg = ''; function render(): void { @@ -4228,7 +4227,7 @@ async function cmdSuspend(): Promise<void> { async function postSessionCliIpc( ipcPort: number, sessionId: string, - route: 'slash' | 'cd' | 'close' | 'rename', + route: 'slash' | 'cd' | 'close', payload: Record<string, unknown>, ): Promise<Response> { const requestBody: Record<string, unknown> = { ...payload }; @@ -4363,10 +4362,6 @@ function listDaemonDescriptors(): DaemonDescriptorLite[] { return all; } -function daemonDescriptorDefinitelyDead(d: DaemonDescriptorLite): boolean { - return typeof d.pid === 'number' && d.pid > 0 && !isProcessAlive(d.pid); -} - function listOnlineDaemons(): DaemonDescriptorLite[] { const STALE_MS = 90_000; const now = Date.now(); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c5264b286..3bd900447 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -727,6 +727,7 @@ export const messages: Record<string, string> = { 'worker.start_exited_early': 'The worker exited before becoming ready (exit code: {code}); see the Botmux logs for details.', 'worker.tui_submit_failed': '⚠️ The TUI answer could not be confirmed as delivered to {cliName}. The CLI may still be waiting for input; open the local terminal or send a new message to recover.', 'worker.raw_input_failed': '⚠️ The slash command could not be confirmed as delivered to {cliName}, so the follow-up text in the same message was not submitted. Check the terminal state, then resend.', + 'worker.raw_input_failed_command_only': '⚠️ The slash command could not be confirmed as delivered to {cliName}. Check the terminal state, then resend.', 'worker.empty_final_completed': '⚠️ {cliName} reported this turn as completed, but botmux captured no final text from the terminal transcript and saw no tracked reply for this turn. If you already replied via a redirected send (--top-level / --into / --override-chat), you can ignore this. Otherwise open the web terminal to inspect the last output, or resend a message to continue the session.', // ─── CLI setup wizard / pm2 lifecycle (no per-bot context) ─────────────── diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ea865331c..b357617e4 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -730,6 +730,7 @@ export const messages: Record<string, string> = { 'worker.start_exited_early': 'worker 在就绪前退出(exit code: {code});详细错误可查看 Botmux 日志。', 'worker.tui_submit_failed': '⚠️ TUI 答案未能确认送达 {cliName}。CLI 可能仍在等待输入;请打开本机终端处理,或发送一条新消息解除并继续。', 'worker.raw_input_failed': '⚠️ Slash 命令未能确认送达 {cliName},同一条消息中紧随其后的正文没有继续提交。请检查当前终端状态后重发。', + 'worker.raw_input_failed_command_only': '⚠️ Slash 命令未能确认送达 {cliName}。请检查当前终端状态后重发。', 'worker.empty_final_completed': '⚠️ {cliName} 已报告本轮处理完成,但 botmux 没有从终端记录里捕获到最终文本,也没有追踪到本轮的回复。若你已经通过改道发送(--top-level / --into / --override-chat)回复过,可忽略本提示;否则请打开 Web 终端查看最后输出,或直接重发消息让会话继续。', // ─── CLI setup wizard / pm2 lifecycle (no per-bot context) ─────────────── diff --git a/src/worker.ts b/src/worker.ts index 0a7ec1fc1..6a2eacd9c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1678,10 +1678,13 @@ async function deliverRawInput(msg: Extract<DaemonToWorker, { type: 'raw_input' if (failedTurnId) { emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed'); } + const failureMessageKey = msg.followUpContent + ? 'worker.raw_input_failed' + : 'worker.raw_input_failed_command_only'; send({ type: 'user_notify', ...(failedTurnId ? { turnId: failedTurnId } : {}), - message: t('worker.raw_input_failed', { cliName: cliName() }), + message: t(failureMessageKey, { cliName: cliName() }), }); } diff --git a/test/raw-input-followup-atomicity.test.ts b/test/raw-input-followup-atomicity.test.ts index 22355ef2b..47922895d 100644 --- a/test/raw-input-followup-atomicity.test.ts +++ b/test/raw-input-followup-atomicity.test.ts @@ -135,6 +135,19 @@ describe('worker raw_input delivery', () => { expect(terminalIdx).toBeGreaterThan(catchIdx); expect(notifyIdx).toBeGreaterThan(terminalIdx); }); + + it('only claims follow-up text was skipped when that IPC actually carried one', () => { + const catchIdx = region.indexOf('catch (err'); + const conditionIdx = region.indexOf('msg.followUpContent', catchIdx); + const followUpKeyIdx = region.indexOf("'worker.raw_input_failed'", conditionIdx); + const commandOnlyKeyIdx = region.indexOf("'worker.raw_input_failed_command_only'", conditionIdx); + const notifyIdx = region.indexOf("type: 'user_notify'", catchIdx); + + expect(conditionIdx).toBeGreaterThan(catchIdx); + expect(followUpKeyIdx).toBeGreaterThan(conditionIdx); + expect(commandOnlyKeyIdx).toBeGreaterThan(followUpKeyIdx); + expect(notifyIdx).toBeGreaterThan(commandOnlyKeyIdx); + }); }); describe('worker command-line write mutex', () => { From f47447089ab473bd686f85b49f86280edc7040b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 01:29:40 +0800 Subject: [PATCH 21/26] =?UTF-8?q?fix(session):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=85=A5=E7=BE=A4=E8=B6=85=E6=97=B6=E8=AF=AF=E6=9D=80=E6=8E=A5?= =?UTF-8?q?=E7=AE=A1=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/daemon.ts | 41 ++++++++----- test/group-join-shared-routing.test.ts | 81 +++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 5c3c27a6d..ce7100d0b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -16101,12 +16101,39 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined await closeSessionHelper(session.sessionId); return; } + const joinBootstrapExternallyTakenOver = (): boolean => ( + activeSessions.get(dsKey) !== ds + || ds.session.status !== 'active' + || !!(ds.worker && !ds.worker.killed) + ); + const joinBootstrapWasTakenOver = (): boolean => { + const takenOver = ( + joinReady?.cancelled === true + || joinBootstrapExternallyTakenOver() + ); + if (takenOver && ds.worker && !ds.worker.killed) { + logger.warn( + `[auto-start:入群] ${chatId.substring(0, 12)} bootstrap 期间会话已由其它入口启动,让位且不再 refork`, + ); + } + return takenOver; + }; // Publish the barrier only after this candidate wins registration. There is // no await between the CAS and this write, so a turn can never observe the // worker-null session without also observing the barrier. Publishing earlier // would unnecessarily stall real messages during membership retry and // mode/working-dir discovery, where their own session may safely win. joinReady = beginAutoStartJoinReadyBarrier(dsKey, () => { + // Scheduler/dashboard/trigger paths do not wait on the inbound-message + // barrier and may legitimately start this exact ds while join bootstrap + // is awaiting Lark/network I/O. Once that happens, timeout cancellation + // must yield to the new worker instead of closing its active turn. + if (joinBootstrapExternallyTakenOver()) { + logger.warn( + `[auto-start:入群] ${chatId.substring(0, 12)} bootstrap timeout 时会话已被接管,跳过候选回收`, + ); + return; + } // A user turn waited longer than the serializer's normal safety cap. // Relinquish this half-bootstrapped registration instead of letting the // turn fall back to the old "refork now, then get killed by late join" @@ -16137,20 +16164,6 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined sharedReplyRootId = undefined; void deleteMessage(larkAppId, messageId); }; - const joinBootstrapWasTakenOver = (): boolean => { - const takenOver = ( - joinReady?.cancelled === true - || activeSessions.get(dsKey) !== ds - || ds.session.status !== 'active' - || !!(ds.worker && !ds.worker.killed) - ); - if (takenOver && ds.worker && !ds.worker.killed) { - logger.warn( - `[auto-start:入群] ${chatId.substring(0, 12)} bootstrap 期间会话已由其它入口启动,让位且不再 refork`, - ); - } - return takenOver; - }; const armSharedReplyTarget = (): void => { if (!sharedReplyRootId) return; ds.pendingTurnId = sharedReplyRootId; diff --git a/test/group-join-shared-routing.test.ts b/test/group-join-shared-routing.test.ts index b32aa4c07..6e2535e58 100644 --- a/test/group-join-shared-routing.test.ts +++ b/test/group-join-shared-routing.test.ts @@ -662,7 +662,8 @@ describe('handleBotAdded — 普通群 shared 路由', () => { const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); await seedStartedPromise; - const timedOutJoinSessionId = daemon.__testOnly_activeSessions.get(key)?.session.sessionId; + const timedOutJoinDs = daemon.__testOnly_activeSessions.get(key)!; + const timedOutJoinSessionId = timedOutJoinDs.session.sessionId; await daemon.__testOnly_handleThreadReply( { @@ -688,6 +689,7 @@ describe('handleBotAdded — 普通群 shared 路由', () => { ); const ds = daemon.__testOnly_activeSessions.get(key); + expect(timedOutJoinDs.session.status).toBe('closed'); expect(ds?.session.sessionId).not.toBe(timedOutJoinSessionId); expect(ds?.session.status).toBe('active'); expect(mocks.forkWorker).toHaveBeenCalledTimes(1); @@ -705,6 +707,83 @@ describe('handleBotAdded — 普通群 shared 路由', () => { expect(daemon.__testOnly_activeSessions.get(key)).toBe(ds); }); + it('does not close a live worker that took over before bootstrap timeout', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_shared_timeout_takeover'; + const chatId = 'oc_join_shared_timeout_takeover'; + const userMessageId = 'om_user_after_external_takeover'; + const key = types.sessionKey(chatId, appId); + let releaseSeed!: (messageId: string) => void; + const seedPending = new Promise<string>((resolve) => { + releaseSeed = resolve; + }); + let seedStarted!: () => void; + const seedStartedPromise = new Promise<void>((resolve) => { + seedStarted = resolve; + }); + mocks.sendMessage.mockImplementationOnce(async () => { + seedStarted(); + return await seedPending; + }); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-shared-timeout-takeover'), + regularGroupReplyMode: 'shared', + }); + daemon.__testOnly_setAutoStartJoinReadyMaxWaitMs(20); + + const joinPromise = daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + await seedStartedPromise; + const ds = daemon.__testOnly_activeSessions.get(key)!; + const externalWorker = { killed: false, send: vi.fn(), pid: 4321 } as any; + ds.worker = externalWorker; + + await daemon.__testOnly_handleThreadReply( + { + sender: { sender_id: { open_id: 'ou_owner' }, sender_type: 'user' }, + message: { + message_id: userMessageId, + chat_id: chatId, + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: '接管后仍要保留 worker' }), + create_time: String(Date.now()), + }, + }, + { + chatId, + messageId: userMessageId, + chatType: 'group', + scope: 'chat', + anchor: chatId, + replyRootId: userMessageId, + larkAppId: appId, + }, + ); + + expect(daemon.__testOnly_activeSessions.get(key)).toBe(ds); + expect(ds.session.status).toBe('active'); + expect(ds.worker).toBe(externalWorker); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + expect(externalWorker.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'message', + turnId: userMessageId, + })); + + releaseSeed('om_late_join_seed_after_takeover'); + await joinPromise; + + expect(daemon.__testOnly_activeSessions.get(key)).toBe(ds); + expect(ds.session.status).toBe('active'); + expect(ds.worker).toBe(externalWorker); + expect(mocks.deleteMessage).toHaveBeenCalledWith(appId, 'om_late_join_seed_after_takeover'); + }); + it('serializes replies to a topic-group join seed by the exact session key', async () => { const { daemon, registry, types } = modules; const appId = 'app_join_topic_inbound_race'; From efc90d116c3f41223d0f3f00e00a8239d8198080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 01:51:12 +0800 Subject: [PATCH 22/26] =?UTF-8?q?fix(worker):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=90=8E=E7=AB=AF=E5=86=B7=E5=90=AF=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worker.ts | 27 +++++++++++---------- test/backend-gate.test.ts | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 6a2eacd9c..230d4bab1 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -7197,8 +7197,9 @@ async function spawnCli( const zmxOwnedProbe = effectiveBackendType === 'zmx' ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid) : undefined; - // An unverifiable pane must never be treated as absent: falling through to - // "no pane" would cold-spawn a second CLI beside a live unisolated one. + // ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe must + // fail closed. Other persistent backends retain the upstream semantics: + // their target probe returning unknown is not proof that a pane exists. const paneProbe = zmxOwnedProbe?.probe ?? (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing'); if ( @@ -7211,7 +7212,7 @@ async function spawnCli( 'ZMX session appeared after the frozen launch probe', ); } - if (paneProbe === 'unknown') { + if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') { throw new Error( `[read-isolation] refusing to start session ${cfg.sessionId}: ` + `could not verify existing ${effectiveBackendType} pane`, @@ -7237,6 +7238,7 @@ async function spawnCli( // `string | undefined`, but the backing name we are tearing down is // this one and does not change. const staleSessionName = persistentSessionName; + const stalePersistentTarget = selectedBackend.persistentBackendTarget; try { // ZMX keeps its own call here rather than going through the target // helper: only this path holds the frozen PID, which makes the @@ -7248,8 +7250,7 @@ async function spawnCli( resolvedZmxSessionPid, ); } else { - const persistentTarget = selectedBackend.persistentBackendTarget; - if (persistentTarget) killPersistentBackendTarget(persistentTarget, cfg.sessionId); + if (stalePersistentTarget) killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId); else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); } } catch (e) { @@ -7257,10 +7258,12 @@ async function spawnCli( } const postKillProbe = effectiveBackendType === 'zmx' ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe - : probePersistentSession( - effectiveBackendType as PersistentBackendType, - staleSessionName, - ); + : (stalePersistentTarget + ? probePersistentBackendTarget(stalePersistentTarget) + : probePersistentSession( + effectiveBackendType as PersistentBackendType, + staleSessionName, + )); if (postKillProbe !== 'missing') { throw new Error( `[read-isolation] refusing to start session ${cfg.sessionId}: ` + @@ -7287,8 +7290,8 @@ async function spawnCli( let willReattachPersistent = selectedBackend.isReattach === true; if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') { const persistentTarget = selectedBackend.persistentBackendTarget; - // Fail closed on an inconclusive probe: treating 'unknown' as "no pane" - // would cold-spawn a second CLI next to a live one holding MCP state. + // ZMX ownership is label/PID-sensitive, so only its inconclusive probe + // fails closed. Other backends keep the pre-ZMX target-probe semantics. const paneProbe = effectiveBackendType === 'zmx' ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid).probe : (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing'); @@ -7302,7 +7305,7 @@ async function spawnCli( 'ZMX session appeared after the frozen launch probe', ); } - if (paneProbe === 'unknown') { + if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') { throw new Error( `[mcp-gateway] refusing to start session ${cfg.sessionId}: ` + `could not verify existing ${effectiveBackendType} pane`, diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 56e0c38ab..9093bc67d 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -161,6 +161,56 @@ describe('persistent backend cold-restart ordering', () => { expect(gate).toContain('resolvedZmxSessionProbe = postKillProbe'); }); + it('limits inconclusive-probe startup rejection to ZMX in both persistent gates', () => { + const readIsolationStart = workerSource.indexOf( + 'if (appliedIsolationCapabilities.length > 0 && persistentSessionName', + ); + const readIsolationEnd = workerSource.indexOf('let willReattachPersistent', readIsolationStart); + const mcpStart = workerSource.indexOf( + 'if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length', + ); + const mcpEnd = workerSource.indexOf('// The plugin set is stable only', mcpStart); + const gates = [ + workerSource.slice(readIsolationStart, readIsolationEnd), + workerSource.slice(mcpStart, mcpEnd), + ]; + + expect(readIsolationStart).toBeGreaterThan(-1); + expect(readIsolationEnd).toBeGreaterThan(readIsolationStart); + expect(mcpStart).toBeGreaterThan(-1); + expect(mcpEnd).toBeGreaterThan(mcpStart); + for (const gate of gates) { + expect(gate).toContain( + "if (effectiveBackendType === 'zmx' && paneProbe === 'unknown')", + ); + expect(gate).not.toContain("if (paneProbe === 'unknown')"); + } + }); + + it('verifies read-isolation teardown against the exact captured backend target', () => { + const start = workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'); + const end = workerSource.indexOf('let willReattachPersistent', start); + const gate = workerSource.slice(start, end); + const capture = gate.indexOf( + 'const stalePersistentTarget = selectedBackend.persistentBackendTarget;', + ); + const kill = gate.indexOf( + 'killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId)', + ); + const postKillProbe = gate.indexOf( + 'probePersistentBackendTarget(stalePersistentTarget)', + kill, + ); + const reselect = gate.indexOf('selectedBackend = selectBackend();'); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(capture).toBeGreaterThanOrEqual(0); + expect(kill).toBeGreaterThan(capture); + expect(postKillProbe).toBeGreaterThan(kill); + expect(reselect).toBeGreaterThan(postKillProbe); + }); + it('refreshes the frozen ZMX probe before read-isolation re-selects the backend', () => { const start = workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'); const end = workerSource.indexOf('let willReattachPersistent', start); From 83b12c7a5d6e8c6a38750cdefeb34e3edbb1e218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 01:51:12 +0800 Subject: [PATCH 23/26] =?UTF-8?q?fix(zmx):=20=E8=A1=A5=E5=81=BF=E9=A6=96?= =?UTF-8?q?=E5=B8=A7=E6=A8=A1=E7=B3=8A=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/zmx-backend.ts | 14 ++++++++++---- test/zmx-backend-recovery.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index 5fa57fbf2..747b2f3bd 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -1334,6 +1334,14 @@ export class ZmxBackend implements SessionBackend { // framing LF therefore preserves the caller's original bytes exactly, // including an original trailing LF, while keeping secrets out of argv. const input = Buffer.concat([chunk, Buffer.from('\n')]); + // A failed send has no PTY-level ACK: the current chunk may or may not + // have reached the CLI. Track both possible states. Looking only at the + // confirmed prefix misses an opening marker in a first ambiguous frame; + // looking only through the current frame misses an opening marker whose + // matching close sits in that ambiguous frame. + const mayHaveOpenPasteAfterAmbiguousSend = bracketedPaste + || hasUnclosedBracketedPaste(bytes.subarray(0, offset)) + || hasUnclosedBracketedPaste(bytes.subarray(0, offset + chunk.length)); try { const stdout = execFileSync('zmx', ['send', this.sessionName], { input, @@ -1348,8 +1356,7 @@ export class ZmxBackend implements SessionBackend { if (stdout.trim()) { logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`); const probe = this.verifyBackingIdentity('send rejection'); - const closeOpenPaste = bracketedPaste - || hasUnclosedBracketedPaste(bytes.subarray(0, offset)); + const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend; if (closeOpenPaste || offset > 0) { if (probe.state === 'compatible') { this.abortPartialSend(closeOpenPaste); @@ -1370,8 +1377,7 @@ export class ZmxBackend implements SessionBackend { ); // Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying // can duplicate a prompt that the daemon already queued. - const closeOpenPaste = bracketedPaste - || hasUnclosedBracketedPaste(bytes.subarray(0, offset)); + const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend; if (closeOpenPaste || offset > 0) { if (probe.state === 'compatible') { this.abortPartialSend(closeOpenPaste); diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index f832bc3cf..586ad2df8 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -107,6 +107,7 @@ interface FakeZmxState { historyStderr: string; sendInputs: Buffer[]; failSendAt: number | null; + throwOnFailedSend: boolean; replaceOnFailedSend: boolean; deferHistory: boolean; readyPath: string | null; @@ -187,6 +188,7 @@ describe('ZmxBackend history-authoritative transport', () => { historyStderr: '', sendInputs: [], failSendAt: null, + throwOnFailedSend: false, replaceOnFailedSend: false, deferHistory: false, readyPath: null, @@ -229,6 +231,9 @@ describe('ZmxBackend history-authoritative transport', () => { if (state.replaceOnFailedSend) { state.sessionId = 'replacement-session-id'; } + if (state.throwOnFailedSend) { + throw new Error(`zmx send ${SESSION} timed out`); + } return `session ${SESSION} is unresponsive\n`; } return ''; @@ -545,6 +550,24 @@ describe('ZmxBackend history-authoritative transport', () => { expect(state.sendInputs[2]!.toString()).toBe('\x1b[201~\x03\n'); }); + it.each([ + { failure: 'stdout rejection', throwOnFailedSend: false }, + { failure: 'thrown transport error', throwOnFailedSend: true }, + ])( + 'closes a possibly delivered sendText paste when its first frame has a $failure', + ({ throwOnFailedSend }) => { + const backend = spawnBackend(); + state.failSendAt = 1; + state.throwOnFailedSend = throwOnFailedSend; + const ompStylePaste = `\x1b[200~${'中'.repeat(512)}\x1b[201~`; + + expect(backend.sendText(ompStylePaste)).toBe(false); + expect(state.sendInputs).toHaveLength(2); + expect(state.sendInputs[0]!.subarray(0, 6).toString()).toBe('\x1b[200~'); + expect(state.sendInputs[1]!.toString()).toBe('\x1b[201~\x03\n'); + }, + ); + it('does not inject partial-send recovery into a replacement session', () => { const backend = spawnBackend(); state.failSendAt = 2; From 5899bc3e60516952be80bfcdf8ce7a85fc620021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 02:39:58 +0800 Subject: [PATCH 24/26] =?UTF-8?q?fix(worker):=20=E9=97=AD=E5=90=88?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=90=8E=E7=AB=AF=E6=A8=A1=E7=B3=8A=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/types.ts | 11 ++ src/adapters/backend/zmx-backend.ts | 130 ++++++++++++++++++++---- src/adapters/cli/oh-my-pi.ts | 9 +- src/adapters/cli/types.ts | 6 ++ src/core/persistent-backend.ts | 15 +++ src/worker.ts | 42 +++++++- test/backend-gate.test.ts | 4 +- test/cli-adapters.test.ts | 29 ++++++ test/persistent-backend-type.test.ts | 23 +++++ test/worker-zmx-submit-recovery.test.ts | 56 ++++++++++ test/zmx-backend-recovery.test.ts | 69 +++++++++++++ 11 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 test/worker-zmx-submit-recovery.test.ts diff --git a/src/adapters/backend/types.ts b/src/adapters/backend/types.ts index 5c98b4563..1702db49e 100644 --- a/src/adapters/backend/types.ts +++ b/src/adapters/backend/types.ts @@ -54,6 +54,17 @@ export interface SpawnOpts { export interface SessionBackend { spawn(bin: string, args: string[], opts: SpawnOpts): void; write(data: string): void; + /** + * Snapshot the backend's text-write failure generation before one logical + * adapter submission. Backends without ambiguous transport writes omit it. + */ + captureAmbiguousSubmissionFence?(): number; + /** + * Best-effort cleanup for a text write that became ambiguous after `fence`. + * The backend owns deduplication against any frame-level recovery it already + * injected. Control/navigation-key failures are deliberately excluded. + */ + cancelAmbiguousSubmission?(fence: number): void; resize(cols: number, rows: number): void; onData(cb: (data: string) => void): void; /** diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index 747b2f3bd..e89d8c643 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -65,32 +65,58 @@ const ZMX_SEND_CHUNK_BYTES = 1024; // one-shot payloads well below that ceiling before writing any prefix; adapters // that intentionally stream larger input already split and throttle their calls. const ZMX_SEND_MAX_BYTES = 64 * 1024; +const ZMX_AMBIGUOUS_CANCEL_DEDUPE_MS = 550; const BRACKETED_PASTE_START_BYTES = Buffer.from('\x1b[200~'); const BRACKETED_PASTE_END = '\x1b[201~'; +const BRACKETED_PASTE_END_BYTES = Buffer.from(BRACKETED_PASTE_END); const ZMX_TRANSPORT_LABEL = 'botmux.transport'; const ZMX_TRANSPORT = 'tail-send-v1'; const ZMX_SESSION_LABEL = 'botmux.session'; const ZMX_LAUNCH_PID_LABEL = 'botmux.launch_pid'; type BackendState = 'idle' | 'observing' | 'recovering' | 'stopped' | 'exited'; +type ZmxSendIntent = 'text' | 'control'; -function hasUnclosedBracketedPaste(bytes: Buffer): boolean { - const endBytes = Buffer.from(BRACKETED_PASTE_END); +function bytesMatchAt(bytes: Buffer, marker: Buffer, cursor: number, boundary: number): boolean { + if (cursor + marker.length > boundary) return false; + for (let i = 0; i < marker.length; i += 1) { + if (bytes[cursor + i] !== marker[i]) return false; + } + return true; +} + +function bracketedPasteOpenStatesAtChunkBoundaries(bytes: Buffer): boolean[] { + const states = [false]; let openPastes = 0; let cursor = 0; - while (cursor < bytes.length) { - const nextOpen = bytes.indexOf(BRACKETED_PASTE_START_BYTES, cursor); - const nextClose = bytes.indexOf(endBytes, cursor); - if (nextOpen < 0 && nextClose < 0) break; - if (nextOpen >= 0 && (nextClose < 0 || nextOpen < nextClose)) { - openPastes += 1; - cursor = nextOpen + BRACKETED_PASTE_START_BYTES.length; - continue; + const shortestMarkerLength = Math.min( + BRACKETED_PASTE_START_BYTES.length, + BRACKETED_PASTE_END_BYTES.length, + ); + + for ( + let boundary = Math.min(ZMX_SEND_CHUNK_BYTES, bytes.length); + boundary > 0; + boundary = Math.min(boundary + ZMX_SEND_CHUNK_BYTES, bytes.length) + ) { + while (cursor + shortestMarkerLength <= boundary) { + if (bytesMatchAt(bytes, BRACKETED_PASTE_START_BYTES, cursor, boundary)) { + openPastes += 1; + cursor += BRACKETED_PASTE_START_BYTES.length; + continue; + } + if (bytesMatchAt(bytes, BRACKETED_PASTE_END_BYTES, cursor, boundary)) { + if (openPastes > 0) openPastes -= 1; + cursor += BRACKETED_PASTE_END_BYTES.length; + continue; + } + cursor += 1; } - if (openPastes > 0) openPastes -= 1; - cursor = nextClose + endBytes.length; + states.push(openPastes > 0); + if (boundary === bytes.length) break; } - return openPastes > 0; + + return states; } type BackingIdentityProbe = @@ -236,6 +262,12 @@ export class ZmxBackend implements SessionBackend { private backingPid: number | null = null; /** Stable direct child of the PTY root; wrapper resolution may refine cliPid. */ private launchPid: number | null = null; + /** Monotonic generation of compatible, transport-ambiguous text writes. */ + private ambiguousTextFailureSequence = 0; + /** Highest ambiguous text generation already covered by logical recovery. */ + private handledAmbiguousTextFailureSequence = 0; + /** Conservative timestamp: the Ctrl+C send may have landed even on timeout. */ + private lastInjectedCancelAtMs = 0; claudeJsonlPath?: string; cliPid?: number; @@ -478,6 +510,38 @@ export class ZmxBackend implements SessionBackend { return this.reattaching; } + get lastInjectedCancelAt(): number { + return this.lastInjectedCancelAtMs; + } + + captureAmbiguousSubmissionFence(): number { + return this.ambiguousTextFailureSequence; + } + + cancelAmbiguousSubmission(fence: number): void { + const failedThrough = this.ambiguousTextFailureSequence; + if ( + failedThrough <= fence + || failedThrough <= this.handledAmbiguousTextFailureSequence + ) { + return; + } + this.handledAmbiguousTextFailureSequence = failedThrough; + + // Frame-level paste recovery or an adapter-level clear may already have + // injected Ctrl+C. With no transport ACK, even a timed-out attempt must be + // treated as possibly delivered so OMP's double-Ctrl+C exit gesture is not + // triggered by an immediate duplicate. + const sinceLastCancel = Date.now() - this.lastInjectedCancelAtMs; + if ( + this.lastInjectedCancelAtMs > 0 + && sinceLastCancel < ZMX_AMBIGUOUS_CANCEL_DEDUPE_MS + ) { + return; + } + this.abortPartialSend(false); + } + spawn(bin: string, args: string[], opts: SpawnOpts): void { const frozenOpts: SpawnOpts = { ...opts, @@ -544,15 +608,23 @@ export class ZmxBackend implements SessionBackend { } sendText(text: string): boolean { - return this.sendBytes(Buffer.from(text, 'utf8')); + return this.sendBytes(Buffer.from(text, 'utf8'), false, 'text'); } sendSpecialKeys(...keys: string[]): boolean { - return this.sendText(keys.map(tmuxKeyToBytes).join('')); + return this.sendBytes( + Buffer.from(keys.map(tmuxKeyToBytes).join(''), 'utf8'), + false, + 'control', + ); } pasteText(text: string): void { - if (!this.sendBytes(Buffer.from(`\x1b[200~${text}\x1b[201~`, 'utf8'), true)) { + if (!this.sendBytes( + Buffer.from(`\x1b[200~${text}\x1b[201~`, 'utf8'), + true, + 'text', + )) { throw new Error(`ZMX 会话 ${this.sessionName} 粘贴发送失败`); } } @@ -1311,7 +1383,11 @@ export class ZmxBackend implements SessionBackend { } } - private sendBytes(bytes: Buffer, bracketedPaste = false): boolean { + private sendBytes( + bytes: Buffer, + bracketedPaste = false, + intent: ZmxSendIntent = 'text', + ): boolean { if (bytes.length === 0) return true; if (this.exited || this.intentionalExit || !this.lastOpts) return false; if (bytes.length > ZMX_SEND_MAX_BYTES) { @@ -1328,6 +1404,9 @@ export class ZmxBackend implements SessionBackend { return false; } + const explicitPasteOpenAtBoundary = bracketedPaste + ? null + : bracketedPasteOpenStatesAtChunkBoundaries(bytes); for (let offset = 0; offset < bytes.length; offset += ZMX_SEND_CHUNK_BYTES) { const chunk = bytes.subarray(offset, Math.min(offset + ZMX_SEND_CHUNK_BYTES, bytes.length)); // ZMX strips exactly one trailing LF from piped stdin. Appending our own @@ -1339,10 +1418,14 @@ export class ZmxBackend implements SessionBackend { // confirmed prefix misses an opening marker in a first ambiguous frame; // looking only through the current frame misses an opening marker whose // matching close sits in that ambiguous frame. + const chunkIndex = offset / ZMX_SEND_CHUNK_BYTES; const mayHaveOpenPasteAfterAmbiguousSend = bracketedPaste - || hasUnclosedBracketedPaste(bytes.subarray(0, offset)) - || hasUnclosedBracketedPaste(bytes.subarray(0, offset + chunk.length)); + || explicitPasteOpenAtBoundary?.[chunkIndex] === true + || explicitPasteOpenAtBoundary?.[chunkIndex + 1] === true; try { + if (intent === 'control' && chunk.includes(0x03)) { + this.lastInjectedCancelAtMs = Date.now(); + } const stdout = execFileSync('zmx', ['send', this.sessionName], { input, encoding: 'utf8', @@ -1357,6 +1440,9 @@ export class ZmxBackend implements SessionBackend { logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`); const probe = this.verifyBackingIdentity('send rejection'); const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend; + if (probe.state === 'compatible' && intent === 'text') { + this.ambiguousTextFailureSequence += 1; + } if (closeOpenPaste || offset > 0) { if (probe.state === 'compatible') { this.abortPartialSend(closeOpenPaste); @@ -1378,6 +1464,9 @@ export class ZmxBackend implements SessionBackend { // Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying // can duplicate a prompt that the daemon already queued. const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend; + if (probe.state === 'compatible' && intent === 'text') { + this.ambiguousTextFailureSequence += 1; + } if (closeOpenPaste || offset > 0) { if (probe.state === 'compatible') { this.abortPartialSend(closeOpenPaste); @@ -1402,6 +1491,9 @@ export class ZmxBackend implements SessionBackend { // so a later retry cannot append to/trivially submit truncated input. const recovery = closeOpenPaste ? `${BRACKETED_PASTE_END}\x03` : '\x03'; try { + // A timeout after this point is ambiguous: the Ctrl+C may already have + // reached the TUI, so downstream recovery must respect its cooldown. + this.lastInjectedCancelAtMs = Date.now(); const stdout = execFileSync('zmx', ['send', this.sessionName], { input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]), encoding: 'utf8', diff --git a/src/adapters/cli/oh-my-pi.ts b/src/adapters/cli/oh-my-pi.ts index 5930e44a8..16aaf8e45 100644 --- a/src/adapters/cli/oh-my-pi.ts +++ b/src/adapters/cli/oh-my-pi.ts @@ -95,8 +95,13 @@ export function createOhMyPiAdapter(pathOverride?: string): CliAdapter { const clearComposer = async (pty: PtyHandle): Promise<boolean> => { // OMP treats a second Ctrl+C within 500 ms as exit. Keep recovery clears - // outside that window even when consecutive terminal writes fail quickly. - const waitMs = OMP_CLEAR_COOLDOWN_MS - (Date.now() - lastClearAttemptAt); + // outside that window even when the backend already injected the first one + // while recovering an ambiguous ZMX frame. + const mostRecentCancelAt = Math.max( + lastClearAttemptAt, + pty.lastInjectedCancelAt ?? 0, + ); + const waitMs = OMP_CLEAR_COOLDOWN_MS - (Date.now() - mostRecentCancelAt); if (waitMs > 0) await delay(waitMs); lastClearAttemptAt = Date.now(); try { diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index b8701e3de..21f3128da 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -10,6 +10,12 @@ export interface PtyHandle { /** Send special keys via tmux send-keys, e.g. 'Enter', 'Escape', 'C-c' (tmux mode only). * Returns `false` on a dropped write (see sendText). */ sendSpecialKeys?(...keys: string[]): void | boolean; + /** + * Epoch-ms timestamp of the most recent Ctrl+C the backend may have injected. + * Snapshot transports record this before an ambiguous send so adapters with + * double-Ctrl+C exit gestures can keep their own recovery outside the window. + */ + readonly lastInjectedCancelAt?: number; /** Paste text via tmux load-buffer + paste-buffer (auto-brackets if terminal supports it). */ pasteText?(text: string): void; /** Absolute path to Claude Code's session JSONL; set by worker for claude-code adapter. diff --git a/src/core/persistent-backend.ts b/src/core/persistent-backend.ts index ea83e309d..3c1eba607 100644 --- a/src/core/persistent-backend.ts +++ b/src/core/persistent-backend.ts @@ -22,6 +22,21 @@ import type { Session } from '../types.js'; export type PersistentBackendType = Extract<BackendType, 'tmux' | 'herdr' | 'zellij' | 'zmx'>; +/** + * Decide whether a post-kill probe still blocks a cold replacement. + * + * ZMX owns sessions by labels + frozen PID, so an inconclusive confirmation + * must remain fail-closed. The older mux backends only have best-effort + * process/session probes: after a successful kill, `unknown` is not proof that + * the target survived (notably zellij reports zero live sessions with exit 1). + */ +export function shouldRejectPersistentPostKillProbe( + backendType: PersistentBackendType, + probe: SessionProbe, +): boolean { + return probe === 'exists' || (backendType === 'zmx' && probe === 'unknown'); +} + export function isSuspendableBackendType( backendType: BackendType | undefined, ): backendType is PersistentBackendType { diff --git a/src/worker.ts b/src/worker.ts index 230d4bab1..0dff3cf29 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -33,7 +33,7 @@ import { type IsolationCapability, } from './adapters/cli/read-isolation.js'; import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths } from './adapters/cli/fs-policy.js'; -import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, type PersistentBackendType } from './core/persistent-backend.js'; +import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe, type PersistentBackendType } from './core/persistent-backend.js'; import { readProcessStartIdentity } from './core/session-marker.js'; import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText, type TranscriptEvent } from './services/claude-transcript.js'; import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js'; @@ -5322,6 +5322,27 @@ function adapterInputHandle(target: SessionBackend): PtyHandle { : target; } +function captureAmbiguousSubmissionFence(target: SessionBackend): number | undefined { + try { + return target.captureAmbiguousSubmissionFence?.(); + } catch (err) { + log(`Unable to capture ambiguous-submission fence: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } +} + +function cancelAmbiguousSubmissionAfterFailure( + target: SessionBackend | null, + fence: number | undefined, +): void { + if (!target || fence === undefined || backend !== target) return; + try { + target.cancelAmbiguousSubmission?.(fence); + } catch (err) { + log(`Unable to cancel ambiguous submission: ${err instanceof Error ? err.message : String(err)}`); + } +} + function onPtyData(data: string): void { data = splitCodexAppControl(data); if (data.length === 0) return; @@ -6093,6 +6114,8 @@ async function flushPending(): Promise<void> { // escaping rejection would become an unhandledRejection and crash the // worker — exactly the failure mode this change is closing. Contain it. let result: Awaited<ReturnType<typeof cliAdapter.writeInput>> | undefined; + let submissionBackend: SessionBackend | null = null; + let submissionFence: number | undefined; try { if (codexRpcEngine) { // RPC input mode: deliver via JSON-RPC turn/start (its ack IS the @@ -6103,6 +6126,8 @@ async function flushPending(): Promise<void> { await codexRpcEngine.sendTurn(msg, item.turnId); result = { submitted: true }; } else if (item.codexAppInput && cliAdapter.writeStructuredInput) { + submissionBackend = backend; + submissionFence = captureAmbiguousSubmissionFence(backend); result = await cliAdapter.writeStructuredInput( adapterInputHandle(backend), msg, @@ -6110,6 +6135,8 @@ async function flushPending(): Promise<void> { { turnId: item.turnId }, ); } else { + submissionBackend = backend; + submissionFence = captureAmbiguousSubmissionFence(backend); result = await cliAdapter.writeInput( adapterInputHandle(backend), msg, @@ -6118,6 +6145,7 @@ async function flushPending(): Promise<void> { } scheduleBusyPatternIdleProbe(`${cliName()} post-submit`); } catch (err: any) { + cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence); log(`writeInput threw: ${err?.message ?? err}`); if (durableWrite && item.turnId) { // A throwing backend cannot prove whether bytes reached the CLI. @@ -6166,6 +6194,7 @@ async function flushPending(): Promise<void> { // nulled backend) the user already got a "CLI exited" notice; don't also // nag that the submit wasn't confirmed. if (result && result.submitted === false && backend) { + cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence); scheduleSubmitFailureNotify( logicalMsg, result.recheck, @@ -7264,7 +7293,10 @@ async function spawnCli( effectiveBackendType as PersistentBackendType, staleSessionName, )); - if (postKillProbe !== 'missing') { + if (shouldRejectPersistentPostKillProbe( + effectiveBackendType as PersistentBackendType, + postKillProbe, + )) { throw new Error( `[read-isolation] refusing to start session ${cfg.sessionId}: ` + `could not confirm stale ${effectiveBackendType} pane termination`, @@ -7340,7 +7372,7 @@ async function spawnCli( : (persistentTarget ? probePersistentBackendTarget(persistentTarget) : probePersistentSession(persistentBackendType, persistentSessionName)); - if (postKillProbe !== 'missing') { + if (shouldRejectPersistentPostKillProbe(persistentBackendType, postKillProbe)) { throw new Error( `[mcp-gateway] refusing to start session ${cfg.sessionId}: ` + `could not confirm stale ${effectiveBackendType} pane termination`, @@ -10729,6 +10761,8 @@ process.on('message', async (raw: unknown) => { // message handler. Errors are best-effort logged; the bridge // ingest path is unaffected because mark already happened // above (codexBridgeMarkPendingTurn / bridgeMarkPendingTurn). + const submissionBackend = backend; + const submissionFence = captureAmbiguousSubmissionFence(backend); try { const result = await cliAdapter.writeInput(adapterInputHandle(backend), content); if (result?.cliSessionId) { @@ -10736,11 +10770,13 @@ process.on('message', async (raw: unknown) => { codexBridgeNotifyCliSessionId(result.cliSessionId); } if (result && result.submitted === false) { + cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence); scheduleSubmitFailureNotify(content, result.recheck, 'submit history', undefined, result.failureReason, turnSeq); } else { acknowledgeTurnInputCommitted(msg.turnId); } } catch (err: any) { + cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence); log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`); } } else if ('sendText' in backend && 'sendSpecialKeys' in backend) { diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 9093bc67d..d88c27a45 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -156,7 +156,7 @@ describe('persistent backend cold-restart ordering', () => { expect(gate).toContain('probeOwnedZmxSession('); expect(gate).toContain('probePersistentBackendTarget('); expect(gate).toContain("paneProbe === 'unknown'"); - expect(gate).toContain("postKillProbe !== 'missing'"); + expect(gate).toContain('shouldRejectPersistentPostKillProbe('); expect(gate).toContain("effectiveBackendType === 'zmx'"); expect(gate).toContain('resolvedZmxSessionProbe = postKillProbe'); }); @@ -184,6 +184,8 @@ describe('persistent backend cold-restart ordering', () => { "if (effectiveBackendType === 'zmx' && paneProbe === 'unknown')", ); expect(gate).not.toContain("if (paneProbe === 'unknown')"); + expect(gate).toContain('shouldRejectPersistentPostKillProbe('); + expect(gate).not.toContain("postKillProbe !== 'missing'"); } }); diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts index 00a2ef660..4a8872f00 100644 --- a/test/cli-adapters.test.ts +++ b/test/cli-adapters.test.ts @@ -916,6 +916,35 @@ describe('oh-my-pi buildArgs', () => { expect(events).toEqual(['text:524', 'text:524', 'keys:C-c']); }); + it('keeps its recovery Ctrl+C outside the backend-injected cancel window', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T00:00:00.000Z')); + try { + const isolatedAdapter = createOhMyPiAdapter('/usr/bin/omp'); + const events: Array<{ key: string; at: number }> = []; + const backendCancelAt = Date.now(); + const pty = { + write() {}, + sendText() { return false; }, + sendSpecialKeys(...keys: string[]) { + events.push({ key: keys.join(','), at: Date.now() }); + return true; + }, + lastInjectedCancelAt: backendCancelAt, + } as PtyHandle & { readonly lastInjectedCancelAt: number }; + + const write = isolatedAdapter.writeInput(pty, 'ambiguous paste'); + await vi.advanceTimersByTimeAsync(549); + expect(events).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + await expect(write).resolves.toEqual({ submitted: false }); + expect(events).toEqual([{ key: 'C-c', at: backendCancelAt + 550 }]); + } finally { + vi.useRealTimers(); + } + }); + it('clears the OMP composer when Enter retries are all dropped', async () => { const events: string[] = []; const pty = { diff --git a/test/persistent-backend-type.test.ts b/test/persistent-backend-type.test.ts index 9527cd034..04313331d 100644 --- a/test/persistent-backend-type.test.ts +++ b/test/persistent-backend-type.test.ts @@ -29,6 +29,7 @@ import { resolvePersistentBackendTarget, resolvePairedSpawnBackendType, resolveSpawnBackendType, + shouldRejectPersistentPostKillProbe, shutdownBackendDisposition, } from '../src/core/persistent-backend.js'; import { HerdrBackend } from '../src/adapters/backend/herdr-backend.js'; @@ -206,6 +207,28 @@ describe('probePersistentSessions', () => { }); }); +describe('persistent post-kill probe policy', () => { + it.each([ + { backendType: 'tmux', probe: 'missing', reject: false }, + { backendType: 'tmux', probe: 'unknown', reject: false }, + { backendType: 'tmux', probe: 'exists', reject: true }, + { backendType: 'herdr', probe: 'missing', reject: false }, + { backendType: 'herdr', probe: 'unknown', reject: false }, + { backendType: 'herdr', probe: 'exists', reject: true }, + { backendType: 'zellij', probe: 'missing', reject: false }, + { backendType: 'zellij', probe: 'unknown', reject: false }, + { backendType: 'zellij', probe: 'exists', reject: true }, + { backendType: 'zmx', probe: 'missing', reject: false }, + { backendType: 'zmx', probe: 'unknown', reject: true }, + { backendType: 'zmx', probe: 'exists', reject: true }, + ] as const)( + '$backendType + $probe → reject=$reject', + ({ backendType, probe, reject }) => { + expect(shouldRejectPersistentPostKillProbe(backendType, probe)).toBe(reject); + }, + ); +}); + describe('killPersistentSession ZMX ownership fence', () => { it('refuses name-only deletion and delegates the complete UUID to the managed kill', () => { expect(() => killPersistentSession('zmx', 'bmx-abcdef12')).toThrow(/name-only ZMX kill/); diff --git a/test/worker-zmx-submit-recovery.test.ts b/test/worker-zmx-submit-recovery.test.ts new file mode 100644 index 000000000..91951b957 --- /dev/null +++ b/test/worker-zmx-submit-recovery.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const workerSource = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + +describe('worker ZMX logical submission recovery', () => { + it('fences normal adapter writes and recovers both thrown and explicit failures', () => { + const start = workerSource.indexOf('async function flushPending(): Promise<void>'); + const end = workerSource.indexOf('function sendToPty(', start); + const flush = workerSource.slice(start, end); + const capture = flush.indexOf('captureAmbiguousSubmissionFence(backend)'); + const write = flush.indexOf('result = await cliAdapter.writeInput('); + const caughtRecovery = flush.indexOf( + 'cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence);', + write, + ); + const explicitFailure = flush.indexOf('result.submitted === false', write); + const explicitRecovery = flush.indexOf( + 'cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence);', + caughtRecovery + 1, + ); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(capture).toBeGreaterThan(-1); + expect(capture).toBeLessThan(write); + expect(caughtRecovery).toBeGreaterThan(write); + expect(explicitFailure).toBeGreaterThan(caughtRecovery); + expect(explicitRecovery).toBeGreaterThan(explicitFailure); + }); + + it('applies the same recovery fence to structured adopt writes', () => { + const start = workerSource.indexOf('// Adopt mode write:'); + const end = workerSource.indexOf('case \'close\':', start); + const adopt = workerSource.slice(start, end); + const capture = adopt.indexOf('captureAmbiguousSubmissionFence(backend)'); + const write = adopt.indexOf('await cliAdapter.writeInput('); + const explicitFailure = adopt.indexOf('result.submitted === false', write); + const explicitRecovery = adopt.indexOf( + 'cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence);', + explicitFailure, + ); + const caughtRecovery = adopt.indexOf( + 'cancelAmbiguousSubmissionAfterFailure(submissionBackend, submissionFence);', + explicitRecovery + 1, + ); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(capture).toBeGreaterThan(-1); + expect(capture).toBeLessThan(write); + expect(explicitRecovery).toBeGreaterThan(explicitFailure); + expect(caughtRecovery).toBeGreaterThan(explicitRecovery); + }); +}); diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index 586ad2df8..4a5b34b44 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -512,6 +512,36 @@ describe('ZmxBackend history-authoritative transport', () => { expect(state.sendInputs[0]!.subarray(0, -1).toString()).toBe('\r'); }); + it('cancels a logical prompt whose later independent sendText call fails ambiguously', () => { + const backend = spawnBackend(); + const fence = backend.captureAmbiguousSubmissionFence(); + + expect(backend.sendText('first confirmed chunk')).toBe(true); + expect(backend.sendText('second confirmed chunk')).toBe(true); + state.failSendAt = 3; + expect(backend.sendText('ambiguous third chunk')).toBe(false); + expect(state.sendInputs).toHaveLength(3); + + backend.cancelAmbiguousSubmission(fence); + expect(state.sendInputs).toHaveLength(4); + expect(state.sendInputs[3]!.toString()).toBe('\x03\n'); + + backend.cancelAmbiguousSubmission(fence); + expect(state.sendInputs).toHaveLength(4); + }); + + it('does not cancel a failed control key as though it were prompt text', () => { + const backend = spawnBackend(); + const fence = backend.captureAmbiguousSubmissionFence(); + state.failSendAt = 1; + + expect(backend.sendSpecialKeys('Enter')).toBe(false); + backend.cancelAmbiguousSubmission(fence); + + expect(state.sendInputs).toHaveLength(1); + expect(state.sendInputs[0]!.toString()).toBe('\r\n'); + }); + it('rejects input above 64 KiB before sending any prefix', () => { const backend = spawnBackend(); @@ -568,6 +598,45 @@ describe('ZmxBackend history-authoritative transport', () => { }, ); + it('deduplicates logical recovery after frame recovery already injected Ctrl+C', () => { + const backend = spawnBackend(); + const fence = backend.captureAmbiguousSubmissionFence(); + state.failSendAt = 1; + const ompStylePaste = `\x1b[200~${'中'.repeat(512)}\x1b[201~`; + + expect(backend.sendText(ompStylePaste)).toBe(false); + expect(state.sendInputs).toHaveLength(2); + expect(state.sendInputs[1]!.toString()).toBe('\x1b[201~\x03\n'); + + backend.cancelAmbiguousSubmission(fence); + expect(state.sendInputs).toHaveLength(2); + }); + + it('detects an opening paste marker split across an ambiguous chunk boundary', () => { + const backend = spawnBackend(); + state.failSendAt = 2; + const splitMarkerPaste = `${'x'.repeat(1_021)}\x1b[200~${'y'.repeat(1_021)}\x1b[201~`; + + expect(backend.sendText(splitMarkerPaste)).toBe(false); + expect(state.sendInputs).toHaveLength(3); + expect(state.sendInputs[0]!.subarray(-4).toString()).toBe('\x1b[2\n'); + expect(state.sendInputs[1]!.subarray(0, 3).toString()).toBe('00~'); + expect(state.sendInputs[2]!.toString()).toBe('\x1b[201~\x03\n'); + }); + + it('keeps marker-dense maximum-size recovery analysis bounded', () => { + const backend = spawnBackend(); + const closingMarker = '\x1b[201~'; + const markerDenseInput = closingMarker.repeat( + Math.floor((64 * 1_024) / Buffer.byteLength(closingMarker)), + ); + const startedAt = performance.now(); + + expect(backend.sendText(markerDenseInput)).toBe(true); + expect(performance.now() - startedAt).toBeLessThan(1_000); + expect(state.sendInputs).toHaveLength(Math.ceil(Buffer.byteLength(markerDenseInput) / 1_024)); + }); + it('does not inject partial-send recovery into a replacement session', () => { const backend = spawnBackend(); state.failSendAt = 2; From 8f6069914fd2ab39aa659248fff22cc499ac02ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 03:29:23 +0800 Subject: [PATCH 25/26] =?UTF-8?q?fix(zmx):=20=E4=BF=AE=E5=A4=8D=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E6=A8=A1=E7=B3=8A=E5=8F=91=E9=80=81=E7=9A=84=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E8=AE=B0=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/critical-control-key.ts | 9 ++ src/adapters/backend/zmx-backend.ts | 62 +++++++++---- src/adapters/cli/oh-my-pi.ts | 4 +- test/cli-adapters.test.ts | 8 +- test/zmx-backend-recovery.test.ts | 98 ++++++++++++++++++++ 5 files changed, 159 insertions(+), 22 deletions(-) diff --git a/src/adapters/backend/critical-control-key.ts b/src/adapters/backend/critical-control-key.ts index 5aea74608..8309b480d 100644 --- a/src/adapters/backend/critical-control-key.ts +++ b/src/adapters/backend/critical-control-key.ts @@ -1,3 +1,12 @@ +/** + * Minimum spacing between botmux-generated Ctrl+C writes. + * + * Oh My Pi treats two Ctrl+C events within 500 ms as an exit gesture. Keep a + * small margin so backend recovery and adapter cleanup cannot accidentally + * terminate the CLI when consecutive transport failures happen quickly. + */ +export const TERMINAL_CANCEL_COOLDOWN_MS = 550; + export function isCriticalInterruptKey(key: string): boolean { return key === 'ctrlc' || key === 'esc'; } diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index e89d8c643..cb74d04b2 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -29,6 +29,7 @@ import { resolveUserShell, SHELL_WRAPPER_SCRIPT, } from './tmux-backend.js'; +import { TERMINAL_CANCEL_COOLDOWN_MS } from './critical-control-key.js'; import { logger } from '../../utils/logger.js'; const EARLY_BUFFER_MAX = 1024 * 1024; @@ -65,7 +66,6 @@ const ZMX_SEND_CHUNK_BYTES = 1024; // one-shot payloads well below that ceiling before writing any prefix; adapters // that intentionally stream larger input already split and throttle their calls. const ZMX_SEND_MAX_BYTES = 64 * 1024; -const ZMX_AMBIGUOUS_CANCEL_DEDUPE_MS = 550; const BRACKETED_PASTE_START_BYTES = Buffer.from('\x1b[200~'); const BRACKETED_PASTE_END = '\x1b[201~'; const BRACKETED_PASTE_END_BYTES = Buffer.from(BRACKETED_PASTE_END); @@ -264,8 +264,8 @@ export class ZmxBackend implements SessionBackend { private launchPid: number | null = null; /** Monotonic generation of compatible, transport-ambiguous text writes. */ private ambiguousTextFailureSequence = 0; - /** Highest ambiguous text generation already covered by logical recovery. */ - private handledAmbiguousTextFailureSequence = 0; + /** Highest ambiguous text generation covered by any Ctrl+C attempt. */ + private cancelledAmbiguousTextFailureSequence = 0; /** Conservative timestamp: the Ctrl+C send may have landed even on timeout. */ private lastInjectedCancelAtMs = 0; @@ -522,24 +522,32 @@ export class ZmxBackend implements SessionBackend { const failedThrough = this.ambiguousTextFailureSequence; if ( failedThrough <= fence - || failedThrough <= this.handledAmbiguousTextFailureSequence + || failedThrough <= this.cancelledAmbiguousTextFailureSequence ) { return; } - this.handledAmbiguousTextFailureSequence = failedThrough; + this.abortPartialSend(false); + } - // Frame-level paste recovery or an adapter-level clear may already have - // injected Ctrl+C. With no transport ACK, even a timed-out attempt must be - // treated as possibly delivered so OMP's double-Ctrl+C exit gesture is not - // triggered by an immediate duplicate. - const sinceLastCancel = Date.now() - this.lastInjectedCancelAtMs; - if ( - this.lastInjectedCancelAtMs > 0 - && sinceLastCancel < ZMX_AMBIGUOUS_CANCEL_DEDUPE_MS - ) { - return; + /** + * Space terminal writes themselves so two valid cancellation debts cannot + * become OMP's double-Ctrl+C exit gesture. + */ + private waitForInjectedCancelCooldown(): void { + if (this.lastInjectedCancelAtMs > 0) { + const elapsed = Math.max(0, Date.now() - this.lastInjectedCancelAtMs); + const waitMs = TERMINAL_CANCEL_COOLDOWN_MS - elapsed; + if (waitMs > 0) sleepSync(waitMs); } - this.abortPartialSend(false); + } + + /** Record which ambiguous generation the imminent Ctrl+C can clean. */ + private noteInjectedCancelAttempt(): void { + this.lastInjectedCancelAtMs = Date.now(); + this.cancelledAmbiguousTextFailureSequence = Math.max( + this.cancelledAmbiguousTextFailureSequence, + this.ambiguousTextFailureSequence, + ); } spawn(bin: string, args: string[], opts: SpawnOpts): void { @@ -1424,7 +1432,16 @@ export class ZmxBackend implements SessionBackend { || explicitPasteOpenAtBoundary?.[chunkIndex + 1] === true; try { if (intent === 'control' && chunk.includes(0x03)) { - this.lastInjectedCancelAtMs = Date.now(); + this.waitForInjectedCancelCooldown(); + const postCooldownIdentity = this.verifyBackingIdentity('control-key cooldown'); + if (postCooldownIdentity.state !== 'compatible') { + logger.warn( + `[zmx:${this.sessionName}] skipped Ctrl+C after cooldown: ` + + `backing identity is ${postCooldownIdentity.state}`, + ); + return false; + } + this.noteInjectedCancelAttempt(); } const stdout = execFileSync('zmx', ['send', this.sessionName], { input, @@ -1491,9 +1508,18 @@ export class ZmxBackend implements SessionBackend { // so a later retry cannot append to/trivially submit truncated input. const recovery = closeOpenPaste ? `${BRACKETED_PASTE_END}\x03` : '\x03'; try { + this.waitForInjectedCancelCooldown(); + const postCooldownIdentity = this.verifyBackingIdentity('partial-send recovery cooldown'); + if (postCooldownIdentity.state !== 'compatible') { + logger.warn( + `[zmx:${this.sessionName}] skipped partial-send recovery after cooldown: ` + + `backing identity is ${postCooldownIdentity.state}`, + ); + return; + } // A timeout after this point is ambiguous: the Ctrl+C may already have // reached the TUI, so downstream recovery must respect its cooldown. - this.lastInjectedCancelAtMs = Date.now(); + this.noteInjectedCancelAttempt(); const stdout = execFileSync('zmx', ['send', this.sessionName], { input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]), encoding: 'utf8', diff --git a/src/adapters/cli/oh-my-pi.ts b/src/adapters/cli/oh-my-pi.ts index 16aaf8e45..2fc04ddca 100644 --- a/src/adapters/cli/oh-my-pi.ts +++ b/src/adapters/cli/oh-my-pi.ts @@ -1,13 +1,13 @@ import { resolveCommand } from './registry.js'; import { BOTMUX_SHELL_HINTS } from './shared-hints.js'; import type { CliAdapter, PtyHandle } from './types.js'; +import { TERMINAL_CANCEL_COOLDOWN_MS } from '../backend/critical-control-key.js'; import { delay } from '../../utils/timing.js'; const OMP_INPUT_CHUNK_CHARS = 512; const OMP_INPUT_CHUNK_NEWLINES = 9; const OMP_INPUT_THROTTLE_MS = 20; -const OMP_CLEAR_COOLDOWN_MS = 550; const BRACKETED_PASTE_START = '\x1b[200~'; const BRACKETED_PASTE_END = '\x1b[201~'; @@ -101,7 +101,7 @@ export function createOhMyPiAdapter(pathOverride?: string): CliAdapter { lastClearAttemptAt, pty.lastInjectedCancelAt ?? 0, ); - const waitMs = OMP_CLEAR_COOLDOWN_MS - (Date.now() - mostRecentCancelAt); + const waitMs = TERMINAL_CANCEL_COOLDOWN_MS - (Date.now() - mostRecentCancelAt); if (waitMs > 0) await delay(waitMs); lastClearAttemptAt = Date.now(); try { diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts index 4a8872f00..82776eb80 100644 --- a/test/cli-adapters.test.ts +++ b/test/cli-adapters.test.ts @@ -23,6 +23,7 @@ vi.mock('node:child_process', () => ({ })); import { createCliAdapterSync } from '../src/adapters/cli/registry.js'; +import { TERMINAL_CANCEL_COOLDOWN_MS } from '../src/adapters/backend/critical-control-key.js'; import { createClaudeCodeAdapter } from '../src/adapters/cli/claude-code.js'; import { createAidenAdapter } from '../src/adapters/cli/aiden.js'; import { createCocoAdapter } from '../src/adapters/cli/coco.js'; @@ -934,12 +935,15 @@ describe('oh-my-pi buildArgs', () => { } as PtyHandle & { readonly lastInjectedCancelAt: number }; const write = isolatedAdapter.writeInput(pty, 'ambiguous paste'); - await vi.advanceTimersByTimeAsync(549); + await vi.advanceTimersByTimeAsync(TERMINAL_CANCEL_COOLDOWN_MS - 1); expect(events).toEqual([]); await vi.advanceTimersByTimeAsync(1); await expect(write).resolves.toEqual({ submitted: false }); - expect(events).toEqual([{ key: 'C-c', at: backendCancelAt + 550 }]); + expect(events).toEqual([{ + key: 'C-c', + at: backendCancelAt + TERMINAL_CANCEL_COOLDOWN_MS, + }]); } finally { vi.useRealTimers(); } diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index 4a5b34b44..9f30f3ee6 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -90,6 +90,7 @@ vi.mock('node:fs', async (importOriginal) => { }); import { ZmxBackend } from '../src/adapters/backend/zmx-backend.js'; +import { TERMINAL_CANCEL_COOLDOWN_MS } from '../src/adapters/backend/critical-control-key.js'; const SESSION = 'bmx-test0001'; const SESSION_ID = 'test0001-1111-2222-3333-444444444444'; @@ -106,6 +107,7 @@ interface FakeZmxState { history: string; historyStderr: string; sendInputs: Buffer[]; + sendTimes: number[]; failSendAt: number | null; throwOnFailedSend: boolean; replaceOnFailedSend: boolean; @@ -187,6 +189,7 @@ describe('ZmxBackend history-authoritative transport', () => { history: '', historyStderr: '', sendInputs: [], + sendTimes: [], failSendAt: null, throwOnFailedSend: false, replaceOnFailedSend: false, @@ -227,6 +230,7 @@ describe('ZmxBackend history-authoritative transport', () => { } if (command === 'send') { state.sendInputs.push(Buffer.from(options?.input ?? '')); + state.sendTimes.push(Date.now()); if (state.failSendAt === state.sendInputs.length) { if (state.replaceOnFailedSend) { state.sessionId = 'replacement-session-id'; @@ -530,6 +534,83 @@ describe('ZmxBackend history-authoritative transport', () => { expect(state.sendInputs).toHaveLength(4); }); + it('does not let a previous failure cancellation suppress a new ambiguous generation', () => { + const backend = spawnBackend(); + const firstFence = backend.captureAmbiguousSubmissionFence(); + + state.failSendAt = 1; + expect(backend.sendText('first ambiguous prompt')).toBe(false); + backend.cancelAmbiguousSubmission(firstFence); + expect(state.sendInputs.map(input => input.toString())).toEqual([ + 'first ambiguous prompt\n', + '\x03\n', + ]); + + const secondFence = backend.captureAmbiguousSubmissionFence(); + state.failSendAt = 4; + expect(backend.sendText('second confirmed prefix')).toBe(true); + expect(backend.sendText('second ambiguous suffix')).toBe(false); + backend.cancelAmbiguousSubmission(secondFence); + + expect(state.sendInputs.map(input => input.toString())).toEqual([ + 'first ambiguous prompt\n', + '\x03\n', + 'second confirmed prefix\n', + 'second ambiguous suffix\n', + '\x03\n', + ]); + expect(state.sendTimes[4]! - state.sendTimes[1]!).toBeGreaterThanOrEqual( + TERMINAL_CANCEL_COOLDOWN_MS, + ); + }); + + it('treats an adapter Ctrl+C after a text failure as recovery for that generation', () => { + const backend = spawnBackend(); + const fence = backend.captureAmbiguousSubmissionFence(); + + state.failSendAt = 1; + expect(backend.sendText('ambiguous prompt')).toBe(false); + state.failSendAt = null; + expect(backend.sendSpecialKeys('C-c')).toBe(true); + backend.cancelAmbiguousSubmission(fence); + + expect(state.sendInputs.map(input => input.toString())).toEqual([ + 'ambiguous prompt\n', + '\x03\n', + ]); + }); + + it('does not pay a pending cancellation debt into a replacement session', () => { + const backend = spawnBackend(); + const firstFence = backend.captureAmbiguousSubmissionFence(); + + state.failSendAt = 1; + expect(backend.sendText('first ambiguous prompt')).toBe(false); + backend.cancelAmbiguousSubmission(firstFence); + + const secondFence = backend.captureAmbiguousSubmissionFence(); + state.failSendAt = 4; + expect(backend.sendText('second confirmed prefix')).toBe(true); + expect(backend.sendText('second ambiguous suffix')).toBe(false); + + const wait = vi.spyOn(Atomics, 'wait').mockImplementation(() => { + state.sessionId = 'replacement-session-id'; + return 'timed-out'; + }); + try { + backend.cancelAmbiguousSubmission(secondFence); + } finally { + wait.mockRestore(); + } + + expect(state.sendInputs.map(input => input.toString())).toEqual([ + 'first ambiguous prompt\n', + '\x03\n', + 'second confirmed prefix\n', + 'second ambiguous suffix\n', + ]); + }); + it('does not cancel a failed control key as though it were prompt text', () => { const backend = spawnBackend(); const fence = backend.captureAmbiguousSubmissionFence(); @@ -612,6 +693,23 @@ describe('ZmxBackend history-authoritative transport', () => { expect(state.sendInputs).toHaveLength(2); }); + it('spaces frame recovery cancels across consecutive ambiguous generations', () => { + const backend = spawnBackend(); + const ompStylePaste = `\x1b[200~${'中'.repeat(512)}\x1b[201~`; + + state.failSendAt = 1; + expect(backend.sendText(ompStylePaste)).toBe(false); + state.failSendAt = 3; + expect(backend.sendText(ompStylePaste)).toBe(false); + + expect(state.sendInputs).toHaveLength(4); + expect(state.sendInputs[1]!.toString()).toBe('\x1b[201~\x03\n'); + expect(state.sendInputs[3]!.toString()).toBe('\x1b[201~\x03\n'); + expect(state.sendTimes[3]! - state.sendTimes[1]!).toBeGreaterThanOrEqual( + TERMINAL_CANCEL_COOLDOWN_MS, + ); + }); + it('detects an opening paste marker split across an ambiguous chunk boundary', () => { const backend = spawnBackend(); state.failSendAt = 2; From 846ec239dcd1ba3c3565b386db15113433184d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= <fancyicarus@gmail.com> Date: Wed, 29 Jul 2026 03:51:49 +0800 Subject: [PATCH 26/26] =?UTF-8?q?fix(zmx):=20=E4=BB=8E=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=97=B6=E5=88=BB=E8=AE=A1=E7=AE=97=E5=86=B7?= =?UTF-8?q?=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/zmx-backend.ts | 62 ++++++++++++++++++----------- test/zmx-backend-recovery.test.ts | 59 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts index cb74d04b2..5bb4b0729 100644 --- a/src/adapters/backend/zmx-backend.ts +++ b/src/adapters/backend/zmx-backend.ts @@ -266,7 +266,7 @@ export class ZmxBackend implements SessionBackend { private ambiguousTextFailureSequence = 0; /** Highest ambiguous text generation covered by any Ctrl+C attempt. */ private cancelledAmbiguousTextFailureSequence = 0; - /** Conservative timestamp: the Ctrl+C send may have landed even on timeout. */ + /** Completion-time timestamp: a timed-out Ctrl+C may have landed just before return. */ private lastInjectedCancelAtMs = 0; claudeJsonlPath?: string; @@ -542,14 +542,18 @@ export class ZmxBackend implements SessionBackend { } /** Record which ambiguous generation the imminent Ctrl+C can clean. */ - private noteInjectedCancelAttempt(): void { - this.lastInjectedCancelAtMs = Date.now(); + private markAmbiguousGenerationCovered(): void { this.cancelledAmbiguousTextFailureSequence = Math.max( this.cancelledAmbiguousTextFailureSequence, this.ambiguousTextFailureSequence, ); } + /** Start the next terminal-side cooldown after this attempt has settled. */ + private noteInjectedCancelAttemptSettled(): void { + this.lastInjectedCancelAtMs = Date.now(); + } + spawn(bin: string, args: string[], opts: SpawnOpts): void { const frozenOpts: SpawnOpts = { ...opts, @@ -1431,6 +1435,7 @@ export class ZmxBackend implements SessionBackend { || explicitPasteOpenAtBoundary?.[chunkIndex] === true || explicitPasteOpenAtBoundary?.[chunkIndex + 1] === true; try { + let injectedCancelAttempt = false; if (intent === 'control' && chunk.includes(0x03)) { this.waitForInjectedCancelCooldown(); const postCooldownIdentity = this.verifyBackingIdentity('control-key cooldown'); @@ -1441,16 +1446,22 @@ export class ZmxBackend implements SessionBackend { ); return false; } - this.noteInjectedCancelAttempt(); + this.markAmbiguousGenerationCovered(); + injectedCancelAttempt = true; + } + let stdout: string; + try { + stdout = execFileSync('zmx', ['send', this.sessionName], { + input, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: zmxControlEnv(this.lastOpts), + }); + } finally { + if (injectedCancelAttempt) this.noteInjectedCancelAttemptSettled(); } - const stdout = execFileSync('zmx', ['send', this.sessionName], { - input, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: ZMX_COMMAND_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - env: zmxControlEnv(this.lastOpts), - }); // Several ZMX control-plane failures are reported on stdout with exit // status 0. Empty stdout is part of the transport contract. if (stdout.trim()) { @@ -1517,17 +1528,22 @@ export class ZmxBackend implements SessionBackend { ); return; } - // A timeout after this point is ambiguous: the Ctrl+C may already have - // reached the TUI, so downstream recovery must respect its cooldown. - this.noteInjectedCancelAttempt(); - const stdout = execFileSync('zmx', ['send', this.sessionName], { - input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]), - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: ZMX_COMMAND_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - env: zmxControlEnv(this.lastOpts), - }); + this.markAmbiguousGenerationCovered(); + let stdout: string; + try { + stdout = execFileSync('zmx', ['send', this.sessionName], { + input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: zmxControlEnv(this.lastOpts), + }); + } finally { + // A timeout is ambiguous: the Ctrl+C may have landed at any point + // before the command returned, so start downstream cooldown now. + this.noteInjectedCancelAttemptSettled(); + } if (stdout.trim()) throw new Error(stdout.trim()); logger.warn(`[zmx:${this.sessionName}] cancelled a partially delivered input sequence`); } catch (err) { diff --git a/test/zmx-backend-recovery.test.ts b/test/zmx-backend-recovery.test.ts index 9f30f3ee6..5851b0bb5 100644 --- a/test/zmx-backend-recovery.test.ts +++ b/test/zmx-backend-recovery.test.ts @@ -564,6 +564,65 @@ describe('ZmxBackend history-authoritative transport', () => { ); }); + it.each([ + 'logical recovery', + 'adapter control', + ] as const)( + 'starts the next cancel cooldown after a slow $firstAttempt settles', + (firstAttempt) => { + const backend = spawnBackend(); + const originalExecFileSync = childMocks.execFileSync.getMockImplementation()!; + let now = 10_000; + let firstRecoverySettledAt = 0; + let delayedFirstRecovery = false; + const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => now); + const atomicsWait = vi.spyOn(Atomics, 'wait').mockImplementation( + (_array, _index, _value, timeout) => { + now += typeof timeout === 'number' ? timeout : 0; + return 'timed-out'; + }, + ); + childMocks.execFileSync.mockImplementation((...args: any[]) => { + const result = originalExecFileSync(...args); + const input = Buffer.from(args[2]?.input ?? ''); + if (args[1]?.[0] === 'send' && input.includes(0x03) && !delayedFirstRecovery) { + delayedFirstRecovery = true; + now += TERMINAL_CANCEL_COOLDOWN_MS + 100; + firstRecoverySettledAt = now; + } + return result; + }); + + try { + const firstFence = backend.captureAmbiguousSubmissionFence(); + state.failSendAt = 1; + expect(backend.sendText('first ambiguous prompt')).toBe(false); + if (firstAttempt === 'logical recovery') { + backend.cancelAmbiguousSubmission(firstFence); + } else { + state.failSendAt = null; + expect(backend.sendSpecialKeys('C-c')).toBe(true); + backend.cancelAmbiguousSubmission(firstFence); + } + + const secondFence = backend.captureAmbiguousSubmissionFence(); + state.failSendAt = 4; + expect(backend.sendText('second confirmed prefix')).toBe(true); + expect(backend.sendText('second ambiguous suffix')).toBe(false); + backend.cancelAmbiguousSubmission(secondFence); + + expect(firstRecoverySettledAt).toBeGreaterThan(0); + expect(state.sendTimes[4]! - firstRecoverySettledAt).toBeGreaterThanOrEqual( + TERMINAL_CANCEL_COOLDOWN_MS, + ); + } finally { + childMocks.execFileSync.mockImplementation(originalExecFileSync); + atomicsWait.mockRestore(); + dateNow.mockRestore(); + } + }, + ); + it('treats an adapter Ctrl+C after a text failure as recovery for that generation', () => { const backend = spawnBackend(); const fence = backend.captureAmbiguousSubmissionFence();