diff --git a/docs/node-liveness-criterion.md b/docs/node-liveness-criterion.md new file mode 100644 index 000000000..31cf46eb2 --- /dev/null +++ b/docs/node-liveness-criterion.md @@ -0,0 +1,224 @@ +# 节点存活判据 + +判断一个节点「是不是还在干活」。这份文档的第一版给了三条判据,**三条都是错的** +——它们全部是「桥还活着」就会满足的信号,而桥可以活着 9.5 天、同时一个任务都 +处理不了。改法与证据见本文末尾的「第一版为什么全错」。 + +## 不能用的信号 + +| 信号 | 为什么不能用 | +|---|---| +| 进程还在 / `pgrep` 有命中 | 桥进程可以活着 9.5 天,同时一个任务都处理不了 | +| tmux session 还在 | session 存在,pane 里的前台命令可能早就落回 `bash` 了 | +| 超时次数 | 长任务本身可能十几分钟;600s 是桥的同步等待上限,**桥放弃 ≠ 节点死** | +| `sessions.updated_at` 在动 | 桥每 3 分钟 `reportStatus("idle")`;派任务也会从服务端侧更新时间戳。**只证明桥↔Hub 连通** | +| `task_events` 里 `actor == 该节点` | `processTask`(`agent-node/src/cli.ts`)一进函数就 `reportStatus("working")`,**早于任何运行时调用**。运行时已死的节点照样产生新鲜且匹配的事件 | +| 桥日志出现 `processTask returned` | **超时也会打这一行**,见下方实例 | + +## 唯一可用的正向判据:`tasks.consumed_at` + +`#520` 给 exact 任务定义了两级运行时证据(见 `server/src/tools.ts` 里 `markTaskRuntimeEvidence` 上方的 `#520` 注释): + +| 字段 | 含义 | +|---|---| +| `runtime_submitted_at` | agent-node 把正文**交给**了厂商运行时 | +| `consumed_at` | **更强**:一个可归因的 turn-start / 首个活动事件**回流**了 | + +源码注释里的两句是这条判据成立的全部理由: + +> **Merely fetching/acking an inbox row sets neither.** +> **Node identity comes exclusively from the ntok; callers cannot self-report an alias or node_id.** + +第一句正是上表所有信号缺的:取任务、ack、回状态都点不亮它。 +第二句意味着它**伪造不了** —— 身份只来自 ntok。 + +写入点 `markTaskRuntimeEvidence()`(`server/src/tools.ts`);读出侧 `server/src/server.ts` +有 `consumedAt` 投影。**这里刻意只钉符号不钉行号** —— 行号每次重构都会漂,见 [#831](https://github.com/sleep2agi/agent-network/issues/831)。 + +> 🔴 **`consumed_at` 只在正方向可信,而且它指向的是「任务」不是「节点」。** +> **有值且新** → **这个任务**确实被某个当时的属主开工过,可跨 vantage,不需要 SSH。 +> **为空** → **不能推出任何结论**,原因见下面第二节。 +> **要把它算到某个具体节点头上,先读下面第一节。** + +### 归属:`consumed_at` 不跟着任务改派走 + +`reassign_task` 改派任务时,只动归属与投递字段,**不清运行时证据** +(`server/src/tools.ts`,那条 `UPDATE tasks SET to_name = ?1, to_node_id = ?2, +status = 'delivered', started_at = NULL, delivered_at = datetime('now')`): + +``` +to_name / to_node_id → 改成新属主 +started_at → 置 NULL +delivered_at → 刷新 +runtime_submitted_at → 不动 ← 仍是旧属主盖的 +consumed_at → 不动 ← 仍是旧属主盖的 +``` + +这不是疏漏,是被测试钉住的契约 —— `server/src/task-consumption.test.ts` 的 +「reassign preserves task-lifetime evidence and binds the new inbox row to the +same task」逐字断言改派后 `consumedAt(taskId)` 与改派前**相等**。 + +**后果:一个任务可以同时满足「`consumed_at` 新鲜」和「当前属主一次都没开工」。** +按上面那条判据去看,新属主会被判成正在干活,于是该做的恢复被压掉。 +盖章本身是有权限的(只有当前属主能盖 —— 旧属主改派后再调 +`mark_tasks_consumed` 会拿到 `task_not_owned`),但**已经盖下的章会留在原地**。 + +> 🔴 **所以准确的读法是:`consumed_at` 证明「这个逻辑任务被消费过至少一次」, +> 不证明「`to_name` 现在指的那个节点开过工」。** +> 拿它判某个节点是否存活之前,先确认这个任务**没有被改派过**。 + +一条**尚未验证**的线索:改派会把 `started_at` 置 NULL,而正常开工路径会把它写成 +`datetime('now')`(同文件那条 `UPDATE tasks SET status = 'running', started_at = datetime('now')`)。 +所以「`consumed_at` 有值 + `started_at` 为空」**看起来**能识别出「章是前任盖的」。 +**但我没有测过它**,而且 requeue 路径也会把 `started_at` 置 NULL —— +**在有人真正测出它之前,不要把这条当判据用。** + +这一段是审查指出来的,不是我自己查出来的;第一版把一个任务级的时间戳当成了 +节点级的判据 —— 与本文末尾「第一版为什么全错」里那个根因**是同一个**: +没有回到源码确认这个字段是谁写的、跟着谁走。 + +## 为什么「`consumed_at` 为空」现在不能当故障证据 + +`agent-node/src/cli.ts` 里 `evidence?.submitted()` 上方的注释(codex 直连 stdio 通道): + +``` +// Direct stdio does not yet send/echo clientUserMessageId, so the +// response turn id is admission evidence only (the #587 race proved it +// is not authoritative ownership). Report submitted, never consumed, +// until this lane grows an exact identity echo. +evidence?.submitted(); +``` + +**这条通道按设计永不报 consumed。** 所以它上面一个完全健康的节点,长期表现为 +`runtime_submitted_at` 有值 + `consumed_at` 为空 —— 与「桥交出去了、下游运行时死了」 +**无法区分**。 + +而通道是不是这一条,**hub 侧看不到**:它由节点本地环境变量 +`ANET_CODEX_STDIO_DIRECT`(`agent-node/src/cli.ts`)决定,服务端对此零感知 +(`git grep -nE 'codex-stdio|codexStdio' -- 'server/src/**'` 为空),上报的 `RUNTIME` +只到 `claude`/`codex`/`grok`/`opencode` 家族粒度。 + +**跟踪:[#832](https://github.com/sleep2agi/agent-network/issues/832)** —— 在节点自报 +「本通道是否发 consumed 级证据」之前,负向判据无法从 hub 侧给出。 + +> 🔴 **在此之前:不要用「`consumed_at` 为空」去判定运行时已死。** +> 这个方向的误判会把一个健康节点判成故障,而下一步动作通常是重启。 +> **宁可判不出,不要判错这一边。** + +## 一条容易漏的:桥活着 ≠ 运行时活着 + +``` +桥进程 pid 2408382,连续运行 818134s ≈ 9.5 天 ← 活着 +``` + +桥日志把机制写得很清楚: + +``` +[07:09:21] [codex-app-server] task 893fdbf4 queued (a turn is in flight) +[07:19:21] processTask returned: "…超时(600s 内无最终回复)" +``` + +任务是**被排队、根本没开始处理**。 + +> ⚠️ 措辞要收住:`queued` + 600s 超时只证明**排队了、且超时了**, +> **不单独证明**「那一轮永远飞不完」—— 超时也可能是下游很慢,或那一轮本来就长。 +> 这个例子里「下游没有人」是由**结构性缺席**(有桥、无 appsrv)支持的,不是由超时推出的。 +> **两条证据合起来才指向这个故障,单独任何一条都不够。** + +**注意第二行:`processTask returned` 在这里出现了,而这正是运行时已死的情形。** +所以它不能当存活证据 —— 上表已列。 + +在 `#832` 落地之前,判这类故障只能靠**结构性对照**(不是判据,是旁证):本机 +21 个 `<名字>-桥`,19 个有对应的 `<名字>-appsrv` 且跑着 node。缺的两个里, +`opencode-指挥狗` 是 opencode 运行时、本就不需要 codex app-server(正常), +另一个是**可疑对象**。 + +> ⚠️ 「有桥、无 appsrv」**不等于**「就是这个故障」。准确的说法是: +> **对于 codex、且契约上应当有 appsrv 的节点,长期有桥无 appsrv,高度可疑为此故障类。** +> 定性仍要结合上面的运行时证据 —— 单凭结构性缺席不足以下结论。 + +**这条旁证只在同机、同运行时的节点之间成立**,跨机器或跨运行时不能这样比。 + +## 只读排查三步 + +```bash +node=<别名> + +# 1. 进程还在不在(只说明"在",不说明"在干活") +ps -eo pid,etimes,args --no-headers | grep -F "$node" | grep -v grep + +# 2. 桥与 app-server 的结构性对照 +# 行格式是 |,所以要匹配 `桥\|` 而不是 `桥$` +tmux list-panes -a -F '#{session_name}|#{pane_current_command}' | grep -E '桥\||appsrv' + +# 3. 桥最近在做什么 +# -t 要用 = 精确匹配,并且**要带窗口索引**: +# `-t '=名字-桥'` 会报 can't find pane,`-t '=名字-桥:0'` 才可用。 +# 这两条都由 tests/test812-tmux-target-semantics 在容器里跑出来, +# 报告在 docs/tests/report-test812.txt。 +tmux capture-pane -p -t "=${node}-桥:0" | tail -20 +``` + +三步都是只读的。**看完再决定动不动。** + +> 🔴 **第 3 步会把 pane 里的内容原样打出来 —— 那里面有任务正文和回执正文。** +> 实测一个真实节点是 **71 行、首行 102 字符**。任务正文里可能有调用方粘进来的凭据、 +> 内部地址、他人对话。**别把它整段贴进 issue / 聊天 / 汇报**;要引用就只引你确实 +> 需要的那一两行,并先看一眼有没有该遮的东西。 +> (写这一条时我自己就没打印那 71 行 —— 只报了退出码和行数。) + +> 🔴 **`=` 挡的到底是什么(实测,别按直觉猜)** +> +> 它**不是**在防「节点名互为前缀」。`A站狗-桥` 并不是 `A站狗2-桥` 的前缀 —— +> 这套 `<别名>-桥` 命名本身就把那个危险削掉了,两种写法都会红。 +> 名字歧义要靠上面第 2 步的 session 名对照解决。 +> +> 它防的是**残留 session**:存在 `<节点>-桥-old` 而 `<节点>-桥` 已经没了。 +> 这时不带 `=` 的前缀匹配会解析到那个残留 session,**退出码 0**, +> 把一个陈旧 pane 的内容当成活节点的现状交给你: +> +> ``` +> 只存在 A站狗-桥-old 时 +> -t 'A站狗-桥:0' rc=0 ← 静默抓到 A站狗-桥-old +> -t '=A站狗-桥:0' rc=1 can't find session: A站狗-桥 +> ``` +> +> `=` 顺带还会关掉 fnmatch:`-t '甲-*:0'` 能命中 `甲-桥`,`-t '=甲-*:0'` 不会。 +> +> 以上每一条都是 `tests/test812-tmux-target-semantics` 的断言,去掉 `=` 那道 +> witnessed-red 会让「静默抓错」重新出现。 + +> 🔴 **别把 `capture-pane` 换成 `display-message`。** +> 同一个缺 `:0` 的畸形 target,`capture-pane` 是 rc=1 报错,而 +> `display-message` 是 **rc=0 + 空输出** —— 一个响一个哑。哑的那个会让 +> 「查不到」看起来像「查到了、内容是空」。 + +> ⚠️ 另外两条使用边界: +> - 第 1 步的 `grep -F "$node"` 是**子串匹配**,节点名互为前缀时会互相命中 +> (`A站狗` 会匹配到 `A站狗2`)。名字有歧义时用第 2 步的 session 名对照确认; +> - 第 2 步只看得到**当前存在的 pane**。命名不符合 `<名字>-桥` / `<名字>-appsrv` +> 约定的节点根本不会出现在结果里 —— **它的「缺席」不是证据**。 + +> ⚠️ **不要照抄第一版里那句「重启桥会丢 threadId」——那是错的。** +> `codexThreadId` 持久化在 `config.json`(`agent-network/bin/cli.ts` 里 `rawCfg.codexThreadId = threadId` +> 一带,注释写明重启路径从 config 读回;`agent-node/src/cli.ts` 亦落盘), +> **重启桥会读回并 resume,不丢。** +> 另外「起回缺失的 app-server」**没有对应的支持命令** —— app-server 由 +> `anet node start --copresence` 作为 `{appsrv, bridge, tui}` 三件套一起创建 +> (`agent-network/bin/cli.ts` 的 `copresenceTmuxSessions()`),没有单独入口。 + +## 第一版为什么全错 + +第一版的三条判据是 `task_events.actor` 匹配、桥日志 `processTask returned`、 +`sessions.updated_at` 差分,并且把第一条标成「最强、伪造不了」。 + +三条全部是**桥或服务端在运行时之外**产生的信号: + +- `actor` 事件在 `reportStatus("working")` 时就写,早于运行时调用; +- `processTask returned` 超时也打 —— **被本文档自己举的死亡实例推翻**; +- `updated_at` 由 3 分钟一次的 idle 心跳推进。 + +**这份文档的立意是「别看进程数,看产物推进」,而第一版选的三个"产物"没有一个 +是产物。** 根因是写的时候没有回到源码确认:每条信号是谁写的、什么时候写的。 + +`#520` 早就建好了满足这个要求的机制,第一版没有用它。 diff --git a/docs/tests/report-test812.txt b/docs/tests/report-test812.txt new file mode 100644 index 000000000..9c34cfe7f --- /dev/null +++ b/docs/tests/report-test812.txt @@ -0,0 +1,33 @@ +# report-test812 — tmux target semantics +# docker run --rm --network none anet-t812 (镜像按 SOURCE_COMMIT=287e9b895e487b637aa8e7ffe319c0b264fb2d23 构建) +# 本文件为套件原样输出,未手工编辑。 + +# test812 — tmux target semantics, bound to the doc's own commands +source_commit=287e9b895e487b637aa8e7ffe319c0b264fb2d23 +runsh_blob=9c02fa752a3f73bde3a0fc403a6de934232b6822 +doc_blob=31cf46eb27ca89a561fce6721c11f5d05caa42c9 +tmux=tmux 3.4 +[L0] extract the commands from the doc + doc_capture=tmux capture-pane -p -t "=${node}-桥:0" | tail -20 + doc_listpanes=tmux list-panes -a -F '#{session_name}|#{pane_current_command}' | grep -E '桥\||appsrv' + doc_target==${node}-桥:0 +[L1] the doc's target resolves a session that exists + rendered==测试-桥:0 + OK rc=0 +[L2] dropping the window index makes capture-pane fail loudly + OK rc=1 msg=can't find pane +[L3] '=' guards against a stale session that has your target as a prefix + loose -t 'A站狗-桥:0' rc=0 ← 抓到的是残留的 A站狗-桥-old,不报错 + exact -t '=A站狗-桥:0' rc=1 msg=can't find session + 对照:A站狗2-桥 在场时问 A站狗-桥 → 两种写法都红(= 不是防这个的) + glob -t '甲-*:0' rc=0 / -t '=甲-*:0' rc=1 ← '=' 也关掉 fnmatch +[L4] the same malformed target fails loudly for capture-pane, silently for display-message + display-message -t '=A站狗-桥' rc=0 out=(空) ← 静默 + display-message -t '=A站狗-桥:0' rc=0 out=A站狗-桥 +[L5] list-panes rows are |, so a '桥$' anchor misses them + rows=甲-appsrv|sleep 甲-桥|sleep + OK '桥\|' 命中,'桥$' 不命中 +[L6] witnessed-red: strip '=' from the doc's own target and the silent-wrong-node bug returns + MUTATION_RED doc-target-without-equals target='A站狗-桥:0' rc=0 (静默命中残留的 A站狗-桥-old) +RESULT: PASS +exit_code=0 diff --git a/tests/test812-tmux-target-semantics/Dockerfile b/tests/test812-tmux-target-semantics/Dockerfile new file mode 100644 index 000000000..2bef43e30 --- /dev/null +++ b/tests/test812-tmux-target-semantics/Dockerfile @@ -0,0 +1,29 @@ +# test812 —— tmux target 语义门 +# +# 由 PR #812 的审查发现催生:那份文档把 tmux 的 target 写法标成「实测」, +# 但仓库里既没有覆盖这些命令的 Docker 套件,也没有留存报告 —— 那条声明 +# 在别人的机器上无法复现。 +# +# 这个套件不测机群,只测 tmux 自身的 target 解析语义。它是自足的: +# 容器里现造 session,不碰任何真实节点。 +# +# 🔴 它绑的对象是 docs/node-liveness-criterion.md 里那几行命令**本身** —— +# 命令从文档里抽出来实跑,而不是在套件里另写一份副本。文档改错就红。 + +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc + +# tmux 的 target 解析在 3.x 内是稳定的,但仍把版本打进报告,便于日后归因。 +RUN apk add --no-cache tmux bash coreutils >/dev/null + +WORKDIR /repo +ARG TEST812_SOURCE_COMMIT=unknown +ENV TEST812_SOURCE_COMMIT=$TEST812_SOURCE_COMMIT +ARG TEST812_RUNSH_BLOB=unknown +ENV TEST812_RUNSH_BLOB=$TEST812_RUNSH_BLOB + +# 被测对象:文档本身 +COPY docs/node-liveness-criterion.md /repo/docs/node-liveness-criterion.md +COPY tests/test812-tmux-target-semantics /repo/tests/test812-tmux-target-semantics +RUN chmod +x /repo/tests/test812-tmux-target-semantics/run.sh + +CMD ["bash", "/repo/tests/test812-tmux-target-semantics/run.sh"] diff --git a/tests/test812-tmux-target-semantics/run.sh b/tests/test812-tmux-target-semantics/run.sh new file mode 100755 index 000000000..9c02fa752 --- /dev/null +++ b/tests/test812-tmux-target-semantics/run.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +set -euo pipefail + +# test812 —— tmux target 语义,以及 docs/node-liveness-criterion.md 里那几行命令 +# +# 起因:审查指出该文档把 tmux 的 target 写法标成「实测」,但仓里没有对应的 +# Docker 套件和留存报告,那条声明重建之后无法复现。 +# +# 这个套件的做法不是「另写一份命令来验证文档里的命令」—— 那只能证明两份副本 +# 互相自洽。它把命令**从文档里抽出来**,替换掉节点名之后实跑。文档改错就红。 + +ROOT=/repo +DOC="$ROOT/docs/node-liveness-criterion.md" +export LANG=C.UTF-8 LC_ALL=C.UTF-8 + +SOURCE_COMMIT=${TEST812_SOURCE_COMMIT:-} +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: SOURCE_COMMIT must be one full lowercase Git SHA" >&2; exit 1; } + +# 与 test798 / test823 同:光验 SHA 的格式不够 —— 任何 40 位十六进制都能过, +# 而报告里那个 SHA 可能根本不含镜像里被测的字节。把 run.sh 在该 commit 下的 +# git blob 哈希传进来,就地重算并比对。blob = sha1("blob \0" + 内容)。 +RUNSH_BLOB=${TEST812_RUNSH_BLOB:-} +[[ "$RUNSH_BLOB" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: TEST812_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节" >&2; exit 1; } +_self="$ROOT/tests/test812-tmux-target-semantics/run.sh" +_actual=$( { printf 'blob %d\0' "$(wc -c < "$_self")"; cat "$_self"; } | sha1sum | cut -d' ' -f1 ) +[[ "$_actual" == "$RUNSH_BLOB" ]] || { + echo "FAIL: 镜像里的 run.sh 与 SOURCE_COMMIT=$SOURCE_COMMIT 声称的不是同一份" >&2 + echo " 期望 blob $RUNSH_BLOB,实际 $_actual" >&2; exit 1; } + +echo "# test812 — tmux target semantics, bound to the doc's own commands" +echo "source_commit=$SOURCE_COMMIT" +# 把校验过的 blob 打进报告 —— 否则读报告的人只能看到一个 SHA,无法独立确认 +# 这次跑真的把它绑到了被测字节上。有了这一行,任何人都能用 +# git rev-parse :tests/test812-tmux-target-semantics/run.sh +# 自己比对。 +echo "runsh_blob=$_actual" +echo "doc_blob=$( { printf 'blob %d\0' "$(wc -c < "$DOC")"; cat "$DOC"; } | sha1sum | cut -d' ' -f1 )" +echo "tmux=$(tmux -V)" + +fail() { echo "FAIL: $*" >&2; exit 1; } +kill_all() { tmux kill-server 2>/dev/null || true; sleep 0.2; } +mk() { tmux -u new-session -d -s "$1" "sleep 600"; } + +# --------------------------------------------------------------------------- +# L0 — 把命令从文档里抽出来。抽不到就红:那说明文档改了形状, +# 而这道门再跑下去测的就是一份它自己编的命令,不是文档里的那条。 +# --------------------------------------------------------------------------- +echo "[L0] extract the commands from the doc" +[[ -f "$DOC" ]] || fail "文档不在镜像里:$DOC" + +# 只取 tmux 那两行;ps 那行不测(它依赖真实进程表,不是 tmux 语义)。 +CAPTURE_LINE=$(grep -F 'tmux capture-pane' "$DOC" | head -1 || true) +LISTPANES_LINE=$(grep -F 'tmux list-panes' "$DOC" | head -1 || true) +[[ -n "$CAPTURE_LINE" ]] || fail "文档里找不到 tmux capture-pane 那行" +[[ -n "$LISTPANES_LINE" ]] || fail "文档里找不到 tmux list-panes 那行" +echo " doc_capture=$CAPTURE_LINE" +echo " doc_listpanes=$LISTPANES_LINE" + +# 抽出 -t 后面那个 target 表达式(允许单双引号)。 +DOC_TARGET=$(printf '%s' "$CAPTURE_LINE" | sed -nE 's/.*-t[[:space:]]+"([^"]+)".*/\1/p') +[[ -n "$DOC_TARGET" ]] || DOC_TARGET=$(printf '%s' "$CAPTURE_LINE" | sed -nE "s/.*-t[[:space:]]+'([^']+)'.*/\1/p") +[[ -n "$DOC_TARGET" ]] || fail "从文档那行里抽不出 -t 的 target 表达式" +echo " doc_target=$DOC_TARGET" + +# 文档里写的是 ${node}-桥:0,这里把 node 绑成一个测试名再展开。 +render_target() { node="$1" eval "printf '%s' \"$DOC_TARGET\""; } + +# --------------------------------------------------------------------------- +# L1 — 文档那条 target 对存在的 session 必须真的能抓到 +# --------------------------------------------------------------------------- +echo "[L1] the doc's target resolves a session that exists" +kill_all +mk "测试-桥" +T=$(render_target "测试") +echo " rendered=$T" +tmux -u capture-pane -p -t "$T" >/dev/null 2>/tmp/e1 \ + || fail "文档那条 target 抓不到已存在的 session:rc≠0,stderr=[$(cat /tmp/e1)]" +echo " OK rc=0" + +# --------------------------------------------------------------------------- +# L2 — 缺窗口索引会报错(这正是文档里那句 ':0 才可用' 说的事) +# 注意 capture-pane 在这里是**响的**(rc=1),下面 L4 会看到不是所有 +# tmux 子命令都这么友好。 +# --------------------------------------------------------------------------- +echo "[L2] dropping the window index makes capture-pane fail loudly" +set +e +out=$(tmux -u capture-pane -p -t "=测试-桥" 2>&1); rc=$? +set -e +[[ "$rc" -ne 0 ]] || fail "缺 :0 时 capture-pane 竟然成功了 —— 文档那句 ':0 才可用' 就不成立了" +printf '%s' "$out" | grep -qF "can't find pane" \ + || fail "缺 :0 时红了,但不是红在 'can't find pane' 上,而是:[$out]" +echo " OK rc=$rc msg=can't find pane" + +# --------------------------------------------------------------------------- +# L3 — '=' 到底挡住了什么。 +# 先说清楚它**挡不住**什么:节点名互为前缀(A站狗 / A站狗2)在这套 +# `<别名>-桥` 命名下并不构成危险 —— A站狗-桥 不是 A站狗2-桥 的前缀, +# 两种写法都会红。第一版这道断言就是照着那个错场景写的。 +# +# 真正的危险是**残留 session**:存在 <节点>-桥-old 而 <节点>-桥 已经没了。 +# 这时 tmux 的前缀匹配会把 <节点>-桥 解析到 <节点>-桥-old,**退出码 0**, +# 把一个陈旧 pane 的内容当成活节点的现状交给你。'=' 让它红。 +# --------------------------------------------------------------------------- +echo "[L3] '=' guards against a stale session that has your target as a prefix" +kill_all +mk "A站狗-桥-old" # 残留;真正的 A站狗-桥 不存在 + +set +e +tmux -u capture-pane -p -t "A站狗-桥:0" >/dev/null 2>/tmp/e3; rc_loose=$? +set -e +[[ "$rc_loose" -eq 0 ]] \ + || fail "前提不成立:不带 = 时本应前缀命中残留的 A站狗-桥-old 并 rc=0,实际 rc=$rc_loose" +echo " loose -t 'A站狗-桥:0' rc=0 ← 抓到的是残留的 A站狗-桥-old,不报错" + +T2=$(render_target "A站狗") +set +e +tmux -u capture-pane -p -t "$T2" >/dev/null 2>/tmp/e4; rc_exact=$? +set -e +[[ "$rc_exact" -ne 0 ]] \ + || fail "文档那条 target 在节点不存在时竟然 rc=0 —— 它没能挡住前缀命中残留 session" +grep -qF "can't find session" /tmp/e4 \ + || fail "精确形式红了,但不是红在 \"can't find session\" 上:[$(cat /tmp/e4)]" +echo " exact -t '$T2' rc=$rc_exact msg=can't find session" + +# 对照:节点名互为前缀时,两种写法都红 —— 记下来是为了不让人以为 '=' 是在 +# 防这个。名字歧义要靠文档里第 2 步的 session 名对照,不是靠 '='。 +kill_all +mk "A站狗2-桥" +set +e +tmux -u capture-pane -p -t "A站狗-桥:0" >/dev/null 2>&1; rc_a=$? +tmux -u capture-pane -p -t "=A站狗-桥:0" >/dev/null 2>&1; rc_b=$? +set -e +[[ "$rc_a" -ne 0 && "$rc_b" -ne 0 ]] \ + || fail "对照场景变了:A站狗2-桥 在场时问 A站狗-桥 本应两种写法都红,实际 loose=$rc_a exact=$rc_b" +echo " 对照:A站狗2-桥 在场时问 A站狗-桥 → 两种写法都红(= 不是防这个的)" + +# '=' 同时会关掉 fnmatch。不带 '=' 时 '甲-*' 能解析成 '甲-桥'。 +kill_all +mk "甲-桥" +set +e +tmux -u capture-pane -p -t "甲-*:0" >/dev/null 2>&1; rc_glob=$? +tmux -u capture-pane -p -t "=甲-*:0" >/dev/null 2>&1; rc_noglob=$? +set -e +[[ "$rc_glob" -eq 0 ]] || fail "不带 = 时 '甲-*:0' 本应 fnmatch 命中 甲-桥,实际 rc=$rc_glob" +[[ "$rc_noglob" -ne 0 ]] || fail "带 = 时 '甲-*:0' 不应再被当成通配,实际 rc=$rc_noglob" +echo " glob -t '甲-*:0' rc=0 / -t '=甲-*:0' rc=$rc_noglob ← '=' 也关掉 fnmatch" + +# --------------------------------------------------------------------------- +# L4 — 同一个畸形 target,不同子命令红得不一样。 +# capture-pane 缺 :0 会 rc=1 报错(L2);display-message 缺 :0 却是 +# **rc=0 + 空输出**。照着文档抄命令的人如果把 capture-pane 换成 +# display-message,就会拿到一个静默的空结果。 +# --------------------------------------------------------------------------- +echo "[L4] the same malformed target fails loudly for capture-pane, silently for display-message" +kill_all +mk "A站狗-桥" +set +e +o_nowin=$(tmux -u display-message -p -t "=A站狗-桥" "#{session_name}" 2>/dev/null); rc_nowin=$? +o_win=$(tmux -u display-message -p -t "=A站狗-桥:0" "#{session_name}" 2>/dev/null); rc_win=$? +set -e +[[ "$rc_nowin" -eq 0 && -z "$o_nowin" ]] \ + || fail "display-message 缺 :0 的行为变了:rc=$rc_nowin out=[$o_nowin](本套件记录的是 rc=0 + 空)" +[[ "$rc_win" -eq 0 && "$o_win" == "A站狗-桥" ]] \ + || fail "display-message 带 :0 应返回 session 名,实际 rc=$rc_win out=[$o_win]" +echo " display-message -t '=A站狗-桥' rc=0 out=(空) ← 静默" +echo " display-message -t '=A站狗-桥:0' rc=0 out=A站狗-桥" + +# --------------------------------------------------------------------------- +# L5 — 文档里 list-panes 那行的过滤形状:行是 |, +# 所以锚 '桥$' 匹配不到,必须匹配 '桥\|'。文档正文里解释了这一点, +# 这里把它变成可执行的断言。 +# --------------------------------------------------------------------------- +echo "[L5] list-panes rows are |, so a '桥\$' anchor misses them" +kill_all +mk "甲-桥"; mk "甲-appsrv" +rows=$(tmux -u list-panes -a -F '#{session_name}|#{pane_current_command}') +printf '%s\n' "$rows" | grep -qE '桥\|' || fail "'桥\\|' 匹配不到任何行,实际内容:[$rows]" +if printf '%s\n' "$rows" | grep -qE '桥$'; then + fail "'桥\$' 竟然匹配到了 —— 说明行格式不再是 |,文档那句解释就过期了" +fi +echo " rows=$(printf '%s' "$rows" | tr '\n' ' ')" +echo " OK '桥\\|' 命中,'桥\$' 不命中" + +# --------------------------------------------------------------------------- +# L6 — witnessed-red:证明这道门真的绑在文档上,而不是自说自话。 +# 把从文档抽出来的 target 去掉 '=',用 L3 的场景重跑 —— 必须由红转绿, +# 也就是「静默抓错人」重新出现。它要是仍然红,说明 L3 根本没在测 '='。 +# --------------------------------------------------------------------------- +echo "[L6] witnessed-red: strip '=' from the doc's own target and the silent-wrong-node bug returns" +MUT_TARGET=${DOC_TARGET/=/} +[[ "$MUT_TARGET" != "$DOC_TARGET" ]] || fail "变异是字节 no-op —— 文档那条 target 里根本没有 '='" +kill_all +mk "A站狗-桥-old" +render_mut() { node="$1" eval "printf '%s' \"$MUT_TARGET\""; } +TM=$(render_mut "A站狗") +set +e +tmux -u capture-pane -p -t "$TM" >/dev/null 2>&1; rc_mut=$? +set -e +[[ "$rc_mut" -eq 0 ]] \ + || fail "去掉 '=' 之后仍然红(rc=$rc_mut)—— 那 L3 的绿就不是 '=' 挣来的" +echo " MUTATION_RED doc-target-without-equals target='$TM' rc=$rc_mut (静默命中残留的 A站狗-桥-old)" + +kill_all +echo "RESULT: PASS"