Files
local-rig/scripts/15-criterion4-live.sh
hermes-mac13 93758f3720 feat: 从 tutti-selfhost 拆出 local_rig 独立仓
- local_rig/ 完整包(7 源文件 + ui.html + CHANGELOG)
- tests/test_local_rig.py + test_rig_real_agent.py(纯 local_rig 测试)
- scripts/15-criterion4-live.sh(判据④活环境门)
- README.md(定位/快速开始/与 tutti-selfhost 的关系)
- requirements.txt(唯一外部依赖 pyyaml)
- .gitignore

来源: figmar/tutti-selfhost @ 3b23853 (2026-10-06)
原 PR #43 已合入 tutti-selfhost main
2026-10-06 13:36:14 +08:00

550 lines
28 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 15-criterion4-live.sh —— 契约 §6 **判据④** 的活环境验收门
#
# 判据④ 原文:≥2 台物理机的 Agent **在同一房间互见**,且各自被 @ 时命中。
#
# 为什么值得单独一个脚本:判据④ 是**阶段 1 的唯一硬阻塞**,而它此前只能肉眼看房间 -
# 谁在跑、谁被命中、有没有串台,全靠人记。本脚本把它变成"跑一次、看一张表、退出码说话"。
#
# 用法(凭据只走环境变量,不落盘、不打印):
# MATRIX_BASE_URL=https://imbot.apescale.com \
# MATRIX_ACCESS_TOKEN=<本节点 bot token> \
# BRIDGE_ROOM='!xxxx:matrix.113.249.102.nip.io' \
# TARGET_A='@tutti-hermes:matrix.113.249.102.nip.io' \
# TARGET_B='@tutti-pi:matrix.113.249.102.nip.io' \
# BRIDGE_A='@bridge3:...' BRIDGE_B='@bridge2:...' \
# NODE_NAME=mac13 \
# bash scripts/15-criterion4-live.sh
#
# bash scripts/15-criterion4-live.sh --selftest # 离线自测(含"门必须会红"的对照)
# bash scripts/15-criterion4-live.sh --pair a.log b.log # 两台机各跑一次后, 机器判定"两台物理机"
#
# 退出码:0 = 判据④ 通过 | 1 = 断言失败(门会红)| 2 = 前置缺失/取数不可信(拒绝判读)
# 边界:只向 BRIDGE_ROOM 白名单房间发消息(R-8);不发任何其它房间;不打印任何凭据值。
set -uo pipefail
BASE="${MATRIX_BASE_URL:-}"
TOKEN="${MATRIX_ACCESS_TOKEN:-}"
ROOM="${BRIDGE_ROOM:-}"
TA="${TARGET_A:-}"
TB="${TARGET_B:-}"
BA="${BRIDGE_A:-}"
BB="${BRIDGE_B:-}"
NODE="${NODE_NAME:-$(hostname -s 2>/dev/null || echo unknown)}"
WAIT="${P4_WAIT:-45}" # 等回执链的总超时(秒)
NEG_WAIT="${P4_NEG_WAIT:-8}" # 阴性对照观察窗(秒)
pass=0; fail=0; skip=0
ok() { printf ' \033[32m✅\033[0m %s\n' "$*"; pass=$((pass+1)); }
bad() { printf ' \033[31m❌\033[0m %s\n' "$*"; fail=$((fail+1)); }
skp() { printf ' \033[33m⏭\033[0m %s\n' "$*"; skip=$((skip+1)); }
sec() { printf '\n\033[1m── %s\033[0m\n' "$*"; }
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
FETCH_FAIL=0 # 取数失败 ⇒ 拒绝判读(exit 2), 不与"断言失败(exit 1)"混为一谈
enc() { python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$1"; }
api() { # api <METHOD> <path> [json-file] -> stdout; 返回 curl 退出码
local m="$1" p="$2" f="${3:-}"
if [ -n "$f" ]; then
curl -sS --max-time 20 -X "$m" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' --data-binary "@$f" "$BASE$p"
else
curl -sS --max-time 20 -X "$m" -H "Authorization: Bearer $TOKEN" "$BASE$p"
fi
}
need() { [ -n "${1:-}" ] || { echo "🔴 缺少环境变量 $2 —— 判读不可信,拒绝判读(exit 2)" >&2; return 2; }; }
preflight() {
sec "前置(只读)"
need "$BASE" MATRIX_BASE_URL; need "$TOKEN" MATRIX_ACCESS_TOKEN
need "$ROOM" BRIDGE_ROOM; need "$TA" TARGET_A; need "$TB" TARGET_B
local r
r="$(api GET /_matrix/client/v3/account/whoami)" || { bad "whoami 请求失败(curl)"; return 2; }
ME="$(printf '%s' "$r" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('user_id','') if isinstance(d,dict) else '')")"
[ -n "$ME" ] && ok "桥身份可认证: $ME" || { bad "whoami 拿不到 user_id(令牌失效?) —— 判读不可信"; return 2; }
r="$(api GET /_matrix/client/v3/joined_rooms)"
printf '%s' "$r" | python3 -c "
import json,sys
d=json.load(sys.stdin)
sys.exit(0 if isinstance(d,dict) and sys.argv[1] in (d.get('joined_rooms') or []) else 1)" "$ROOM" \
&& ok "已加入白名单房间 $ROOM" || { bad "该 token 未加入 ${ROOM}(或 joined_rooms 取数失败)"; return 2; }
r="$(api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/joined_members")"
MISS="$(printf '%s' "$r" | python3 -c "
import json,sys
d=json.load(sys.stdin)
if not isinstance(d,dict) or 'joined' not in d:
print('APIERR'); sys.exit(0)
j=set(d['joined'])
print(','.join(t for t in sys.argv[1:3] if t not in j))" "$TA" "$TB")"
case "$MISS" in
APIERR) bad "joined_members 取数失败 —— 判读不可信"; return 2;;
"") ok "两个 target 都在房间里(R-7 前置): $(basename "${TA%%:*}") / $(basename "${TB%%:*}")";;
*) bad "房间缺 target: $MISS —— 判据④ 前置不满足(先做 T-1.10 步骤 1)"; return 2;;
esac
return 0
}
send() { # send <text> -> 打印 event_id
local text="$1" f="$TMP/send.json" txn="p4-$$-$RANDOM"
python3 -c "
import json,sys
json.dump({'msgtype':'m.text','body':sys.argv[1]}, open(sys.argv[2],'w',encoding='utf-8'), ensure_ascii=False)" \
"$text" "$f"
api PUT "/_matrix/client/v3/rooms/$(enc "$ROOM")/send/m.room.message/$(enc "$txn")" "$f" \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('event_id','') if isinstance(d,dict) else '')"
}
receipts() { # receipts <根事件id> -> 每行 "<sender> <status>"; 取数失败输出 __FETCH_FAILED__ 哨兵
local root="$1" f="$TMP/msgs.json"
api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/messages?dir=b&limit=100" > "$f"
python3 - "$root" "$f" <<'PY'
import json, sys
root, path = sys.argv[1], sys.argv[2]
# 缺陷L(workbuddy-mac13 报): 取数失败**不许**与"确实 0 条"同形 —— 阴性对照的判据就是"0 条",
# 吞掉错误会让它 fail-open(403/401/网络故障都会判绿)。故用哨兵, 由消费处拒绝判读。
try:
d = json.load(open(path, encoding="utf-8"))
except Exception:
print("__FETCH_FAILED__"); sys.exit(0)
if not isinstance(d, dict) or "chunk" not in d:
print("__FETCH_FAILED__"); sys.exit(0)
rows = []
for ev in (d.get('chunk') or []):
c = ev.get('content') or {}
rel = c.get('m.relates_to') or {}
if rel.get('rel_type') != 'm.thread' or rel.get('event_id') != root:
continue
st = c.get('status') or c.get('tutti_status')
if st:
rows.append((ev.get('origin_server_ts', 0), ev.get('sender', '?'), st))
for _, sender, st in sorted(rows): # 按服务端时间戳正序, 不信服务端返回顺序
print(f"{sender} {st}")
PY
}
expect_chain() { # expect_chain <标签> <根事件id> <期望 sender(可空)> <禁止 sender(可空)>
local label="$1" root="$2" want="$3" forbid="$4" got=""
local deadline=$(( $(date +%s) + WAIT ))
while :; do
got="$(receipts "$root")"
if printf '%s' "$got" | grep -q '__FETCH_FAILED__'; then
bad "$label 取数失败(/messages) —— 判读不可信, 拒绝给结论"; FETCH_FAIL=1; return 2
fi
printf '%s' "$got" | grep -qE ' (succeeded|failed|timeout)$' && break
[ "$(date +%s)" -ge "$deadline" ] && break
sleep 2
done
local states senders
states="$(printf '%s' "$got" | awk 'NF{print $2}' | paste -sd' ' - 2>/dev/null)"
senders="$(printf '%s' "$got" | awk 'NF{print $1}' | sort -u | paste -sd' ' - 2>/dev/null)"
printf ' %s 回执: [%s] 来自: [%s]\n' "$label" "${states:-无}" "${senders:-无}"
# 1) 必须是 accepted -> running -> 终态, 且 running 不许缺(R-3)
printf '%s' "$states" | grep -q "accepted" || { bad "$label 缺 accepted"; return 1; }
printf '%s' "$states" | grep -q "running" || { bad "$label 缺 running(R-3: 禁止静默完成)"; return 1; }
printf '%s' "$states" | grep -qE "succeeded|failed|timeout" || { bad "$label 未达终态(超时 ${WAIT}s)"; return 1; }
python3 -c "
import sys
seq=sys.argv[1].split()
# 【dsh@mac13】R-1 与 R-3/R-4 的**表面冲突**:契约 R-1 明写"重复投递返回上一次的回执、attempt 递增",
# 于是合法序列可能是 `accepted accepted running succeeded`。老断言要求 accepted 只能在第 0 位,
# 会把这种**合法重放**误判成顺序错。改判为:首条必须是 accepted;accepted 不得晚于 running;
# running 至多一条;终态后不得再有回执。(真链路实测出现过 attempt 1→2 两条 accepted。)
first_run = next((i for i, s in enumerate(seq) if s == 'running'), None)
first_acc = next((i for i, s in enumerate(seq) if s == 'accepted'), None)
bad = []
if first_acc != 0: bad.append('首条不是 accepted')
if any(s == 'accepted' and first_run is not None and i > first_run for i, s in enumerate(seq)):
bad.append('accepted 出现在 running 之后')
if seq.count('running') > 1: bad.append('running 出现多次')
term=[i for i,s in enumerate(seq) if s in ('succeeded','failed','timeout')]
assert not bad, 'accepted/running 顺序不合 R-3/R-4: '+'; '.join(bad)+' | '+' '.join(seq)
assert not term or term[0]==len(seq)-1, '终态后仍有回执: '+sys.argv[1]" "$states" \
|| { bad "$label 回执顺序不合 R-3/R-4"; return 1; }
ok "$label 回执链完整(到达终态, R-3): $states"
# 【dsh@mac13】补第二档判定:上面只保证"出现了终态"(R-3 不许静默完成),
# 但 **A2 的真实语义是"结果回到房间"** ⇒ 失败/超时不该与成功同等计为通过。
# 实测依据:接真 agent 后出现过 `[accepted running failed]` 被旧门判 ✅。
if printf '%s' "$states" | grep -qE " (failed|timeout)$"; then
bad "$label 终态是失败/超时(A2 未达成): $states"
return 1
fi
ok "$label 终态为成功(A2 结果已回到房间)"
# 2) 归属: 只应由服务该 target 的桥回执(R-7 一 target 一账号, 不许串台)
if [ -n "$want" ]; then
[ "$senders" = "$want" ] && ok "$label 回执归属正确: $want" \
|| { bad "$label 期望来自 $want, 实际 [$senders]"; return 1; }
fi
if [ -n "$forbid" ]; then
case " $senders " in
*" $forbid "*) bad "$label 出现**另一节点**的桥($forbid)回执 ⇒ 串台(R-7 违反)"; return 1;;
*) ok "$label 未串台(无 $forbid 回执)";;
esac
fi
return 0
}
probe() { # probe <label> <target> <期望sender> <禁止sender>
local label="$1" target="$2" want="$3" forbid="$4" eid
# 【dsh@mac13】探针正文可配置(**默认行为不变**):实测 hermes CLI 对"短促+编号+方括号标签"
# 这类文本会长时间不退出(对照:自然语言 ~40-100s 正常返回),导致 A2 只能拿到 failed。
# 节点归属信息不丢 —— 两台机比对用的是下面的 P4-FINGERPRINT 行,不是消息尾巴。
eid="$(send "$target ${P4_LABEL_PREFIX:-判据④ 探针} $(date +%H%M%S)${P4_PROBE_SUFFIX- [$NODE]}")"
[ -n "$eid" ] || { bad "$label 发送失败(无 event_id)"; return 1; }
printf ' 根事件: %s\n' "$eid"
ROOTS="$ROOTS $label=$eid"
if expect_chain "$label" "$eid" "$want" "$forbid"; then
# 机器可读行: 供 --pair 做"两台物理机"的**自动**判定, 不再靠人比对两行指纹。
printf ' P4-HIT node=%s target=%s bridge=%s ok=1\n' "$NODE" "$target" "${want:-?}"
return 0
fi
printf ' P4-HIT node=%s target=%s bridge=%s ok=0\n' "$NODE" "$target" "${want:-?}"
return 1
}
negative_control() {
local eid got n
eid="$(send "判据④ 阴性对照:本条没有 @ 任何 target${P4_PROBE_SUFFIX- [$NODE]}")"
[ -n "$eid" ] || { bad "阴性对照发送失败"; return 1; }
sleep "$NEG_WAIT"
got="$(receipts "$eid")"
if printf '%s' "$got" | grep -q '__FETCH_FAILED__'; then
bad "阴性对照取数失败(/messages) ⇒ 不能拿\"0 条\"当通过(缺陷L)"; FETCH_FAIL=1; return 2
fi
n="$(printf '%s' "$got" | grep -c . )"
if [ "$n" -eq 0 ]; then ok "阴性对照: 无 @ 的消息在 ${NEG_WAIT}s 内 0 条回执(判据③ 活环境侧)";
else bad "阴性对照失败: 无 @ 却收到 $n 条回执 ⇒ 误触发"; printf '%s\n' "$got" | sed 's/^/ /'; return 1; fi
}
pair_check() { # pair_check <fileA> <fileB>: 两台机各跑一次的原始输出
local a="$1" b="$2"
for f in "$a" "$b"; do
[ -f "$f" ] || { echo "🔴 找不到文件: $f" >&2; exit 2; }
done
python3 - "$a" "$b" <<'PY'
import re, sys
HIT = re.compile(r"P4-HIT node=(\S+) target=(\S+) bridge=(\S+) ok=(\d)")
FP = re.compile(r"P4-FINGERPRINT node=(\S+) user=(\S+) room=(\S+)")
def parse(path):
hits, node, user, room = [], None, None, None
for ln in open(path, encoding="utf-8", errors="replace"):
m = HIT.search(ln)
if m:
hits.append(m.groups())
f = FP.search(ln)
if f:
node, user, room = f.group(1), f.group(2), f.group(3)
return hits, node, user, room
ha, na, ua, ra = parse(sys.argv[1])
hb, nb, ub, rb = parse(sys.argv[2])
fail, ok = [], []
if not ha or not hb:
print("🔴 至少一侧没有 P4-HIT 行(该侧未跑或未通过) ⇒ 判读不可信"); sys.exit(2)
if not (na and ua and ra and nb and ub and rb):
print("🔴 至少一侧缺指纹行(node/user/room) ⇒ 判读不可信"); sys.exit(2)
if na == nb:
fail.append(f"两侧 node 相同({na}) ⇒ **不是两台物理机**")
else:
ok.append(f"两个节点不同: {na} / {nb}")
if ua == ub:
fail.append(f"两侧自称的桥身份相同({ua}) ⇒ 无法证明是两个桥进程")
else:
ok.append(f"两个桥身份不同: {ua} / {ub}")
if ra != rb:
fail.append(f"两侧房间不同: {ra} vs {rb} ⇒ 不在同一房间, 判据④ 不成立")
else:
ok.append(f"同一房间: {ra}")
# 每轮: 所有探针必须 ok, 且每个 target 在**该轮内**只被一个桥应答(R-7)
maps, bad_probe = [], False
for tag, hits in (("A", ha), ("B", hb)):
if [h for h in hits if h[3] != "1"]:
fail.append(f"第 {tag} 轮存在未通过的探针: "
+ ",".join(f"{h[0]}:{h[1]}" for h in hits if h[3] != "1"))
bad_probe = True
per = {}
for _node, target, bridge, _ok in hits:
per.setdefault(target, set()).add(bridge)
for target, brs in sorted(per.items()):
if len(brs) > 1:
fail.append(f"第 {tag} 轮 target {target} 被多个桥应答 {sorted(brs)} ⇒ R-7 违反")
maps.append(per)
# 缺陷K(workbuddy-mac13 报): 原判定是**条件式**的 —— 两侧都没有某 target 时两支都不进,
# 静默跳过 ⇒ 零交叉也判通过(阶段门放水)。现在改为**无条件**:
sa, sb = set(maps[0]), set(maps[1])
if sa != sb:
fail.append(f"两侧 target 覆盖不一致: A={sorted(sa)} B={sorted(sb)} ⇒ "
f"零交叉/单侧覆盖都**没验过**'各自被 @ 时命中'")
elif len(sa) < 2:
fail.append(f"每侧只覆盖 {len(sa)} 个 target({sorted(sa)}) ⇒ 判据④ 要求两个 target 各自被 @ 命中")
else:
ok.append(f"两侧覆盖一致且 ≥2 个 target: {sorted(sa)}")
for tg in sorted(sa & sb):
a_br, b_br = sorted(maps[0][tg]), sorted(maps[1][tg])
if a_br != b_br:
fail.append(f"跨轮不一致: target {tg} 在 A 轮由 {a_br} 应答、B 轮由 {b_br} ⇒ 归属不稳定(R-7)")
else:
ok.append(f"跨轮一致: {tg} → {a_br[0]}")
bridges = {h[2] for h in (ha + hb)}
if len(bridges) < 2:
fail.append(f"两次运行只见 1 个桥身份({sorted(bridges)}) ⇒ 无法证明两个节点各自独立应答")
else:
ok.append(f"两个桥身份在场: {'/'.join(sorted(bridges))}")
# 绿勾只在**没有 fail** 时打(原写法 `not fail or all(...)` 会在 fail 非空时照打绿勾)
if not fail:
print(f" ✅ 每一轮内每个 target 唯一应答; 探针全部通过")
for x in ok:
print(f" ✅ {x}")
for x in fail:
print(f" ❌ {x}")
print()
if fail:
print(f"❌ 判据④(两台物理机)未通过: {len(fail)} 项"); sys.exit(1)
print("✅ 判据④(两台物理机)通过: 两节点 · 两桥身份 · 同房间 · 每 target 归属唯一且跨轮一致")
PY
}
run_live() {
local rc=0
# 每次进来重取环境: 这样 --selftest 里 export 的替身配置才生效(脚本顶部的初值只在直接调用时有用)。
BASE="${MATRIX_BASE_URL:-}"; TOKEN="${MATRIX_ACCESS_TOKEN:-}"; ROOM="${BRIDGE_ROOM:-}"
TA="${TARGET_A:-}"; TB="${TARGET_B:-}"; BA="${BRIDGE_A:-}"; BB="${BRIDGE_B:-}"
NODE="${NODE_NAME:-$(hostname -s 2>/dev/null || echo unknown)}"
WAIT="${P4_WAIT:-45}"; NEG_WAIT="${P4_NEG_WAIT:-8}"
ROOTS=""
preflight || return 2
sec "探针(真实发消息到白名单房间)"
probe "判据④-A @ ${TA%%:*}" "$TA" "$BA" "${BB:-}" || rc=1
probe "判据④-B @ ${TB%%:*}" "$TB" "$BB" "${BA:-}" || rc=1
sec "阴性对照"
negative_control || rc=1
idempotency_control "$TA" "${BA:-$TA}" || rc=1
sec "节点指纹(两台机各跑一次, 两行对比即为判据④ 的「两台物理机」证据)"
printf ' P4-FINGERPRINT node=%s user=%s room=%s roots=%s\n' "$NODE" "${ME:-?}" "$ROOM" "${ROOTS# }"
printf '\n\033[1m═══ 判据④: %d 通过 / %d 失败 / %d 跳过 ═══\033[0m\n' "$pass" "$fail" "$skip"
if [ "$FETCH_FAIL" = "1" ]; then
echo "🔴 判据④ **拒绝判读**: 有取数失败(见上) —— 不要把'取不到'当成'没发生'"; return 2
fi
[ "$rc" -eq 0 ] && { echo "✅ 判据④ 在本节点通过"; return 0; } || { echo "❌ 判据④ 未通过"; return 1; }
}
# ── 幂等复验(R-1/A4 的活环境形态)──────────────────────────────────
# ⚠️ 「重放」的正确定义(我上一版在这里想错了,留字免得后人照抄):
# 契约 F2 是 idem_key = sha256(room|sender|client_ts|prompt_digest) —— **含时间戳**;
# §6 判据 2 说的是「同一消息重放 3 次(同 idem_key)→ 只执行一次」。
# ⇒ 连发三条正文相同的消息**不是重放**:各自 origin_server_ts 不同 ⇒ 本就该各执行一次
# (实测 rig 里就是 3 个 task_id,全 done)。真正的重放是**同一条事件被重复投递**
# (Matrix 重投、或桥重启后 initial sync 把旧事件再带回一次)。
# 所以这里:发一条 → 等它到终态 → 用 REPLAY_CMD 让同一条事件被重新投递 →
# 要求房间里**仍只有一个 task_id**,且重投确实带来了新回执(否则不许算通过)。
idempotency_control() { # idempotency_control <target> <期望应答者>
local target="$1" want="$2"
[ -n "$target" ] || { skip "幂等复验: 未给 TARGET_A ⇒ 跳过"; return 0; }
if [ -z "${REPLAY_CMD:-}" ]; then
skip "幂等复验: 未提供 REPLAY_CMD(重投同一条事件的手段) ⇒ 跳过,不假装通过"
return 0
fi
sec "幂等复验(R-1/A4:同一条事件被重投)"
local body eid roots="" all n_recv n_tasks n_recv2 wait_left t0
wait_left="${IDEM_WAIT:-150}"
body="$target ${P4_LABEL_PREFIX:-判据④ 探针} replay$(date +%H%M%S)${P4_PROBE_SUFFIX- [$NODE]}"
eid="$(send "$body")"
[ -n "$eid" ] || { bad "幂等复验发送失败"; return 1; }
roots="$eid"
printf ' 原始事件: %s\n' "$eid"
t0=$(date +%s)
while :; do
sleep 5
all="$(probe_receipts "$roots")" || { bad "取数失败 ⇒ 拒绝判读(承缺陷L)"; FETCH_FAIL=1; return 2; }
[[ "$all" == __FETCH_FAILED__* ]] && { bad "取数失败 ⇒ 拒绝判读(承缺陷L)"; FETCH_FAIL=1; return 2; }
printf '%s\n' "$all" | grep -qE " (succeeded|failed|timeout)$" && break
[ $(( $(date +%s) - t0 )) -ge "$wait_left" ] && break
done
n_recv="$(printf '%s\n' "$all" | grep -c .)"
if [ "${n_recv:-0}" -eq 0 ]; then
bad "第一轮就没收到任何回执 ⇒ 无法判定(不许把'没测到'当'通过')"; return 1
fi
ok "第一轮有回执 ${n_recv} 条,进入重投"
say " 执行重投: $REPLAY_CMD"
sh -c "$REPLAY_CMD" >/dev/null 2>&1 || { bad "重投命令失败 ⇒ 无法判定"; return 1; }
t0=$(date +%s)
while :; do
sleep 5
all="$(probe_receipts "$roots")" || { bad "取数失败 ⇒ 拒绝判读"; FETCH_FAIL=1; return 2; }
[[ "$all" == __FETCH_FAILED__* ]] && { bad "取数失败 ⇒ 拒绝判读"; FETCH_FAIL=1; return 2; }
n_recv2="$(printf '%s\n' "$all" | grep -c .)"
[ "${n_recv2:-0}" -gt "${n_recv:-0}" ] && break
[ $(( $(date +%s) - t0 )) -ge "$wait_left" ] && break
done
n_recv2="$(printf '%s\n' "$all" | grep -c .)"
n_tasks="$(printf '%s\n' "$all" | awk 'NF>=2{print $1}' | sort -u | grep -c .)"
printf ' 重投后 ⇒ 回执 %s 条(原 %s),涉及 task_id %s 个\n' "${n_recv2:-0}" "${n_recv:-0}" "${n_tasks:-0}"
if [ "${n_recv2:-0}" -le "${n_recv:-0}" ]; then
bad "重投后没有任何新回执 ⇒ 这条断言其实没测到东西(不许当成通过)"; return 1
fi
if [ "${n_tasks:-0}" -eq 1 ]; then
ok "重投同一条事件仍只有**一个** task_id(R-1 幂等成立:不重复执行)"
else
bad "出现 ${n_tasks} 个 task_id ⇒ 幂等在真链路上失效"; printf '%s\n' "$all" | sed 's/^/ /'; return 1
fi
return 0
}
# 从 /messages 取指定 thread root 下的回执:每行 "<task_id> <status>";取数失败给哨兵
probe_receipts() { # probe_receipts "<root event ids...>"
local f="$TMP/pr.json"
api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/messages?dir=b&limit=100" > "$f" || { echo __FETCH_FAILED__; return 1; }
python3 -c '
import json, sys
path = sys.argv[1]; roots = set(sys.argv[2].split())
try:
d = json.load(open(path, encoding="utf-8"))
except Exception:
print("__FETCH_FAILED__"); sys.exit(0)
if not isinstance(d, dict) or "chunk" not in d:
print("__FETCH_FAILED__"); sys.exit(0)
rows = []
for ev in (d.get("chunk") or []):
c = ev.get("content") or {}
rel = c.get("m.relates_to") or {}
root = rel.get("event_id") if rel.get("rel_type") == "m.thread" else None
# 真回执把 task_id/status 放在 content **顶层**(事件类型才是 com.apescale.…result.v1);
# 离线替身里也出现过嵌套形态 ⇒ 两种都认,别只按一种写死(我就因此漏抓过一次)。
r = c.get("com.apescale.tutti.result.v1") or c
tid, st = r.get("task_id"), r.get("status")
if tid and st and root in roots:
rows.append((int(ev.get("origin_server_ts") or 0), tid, st))
for _, tid, st in sorted(rows):
print(tid, st)
' "$f" "$1"
}
selftest() {
local out port pid rc
out="$(mktemp)"
echo "── 0. 起 mock matrix 替身"
MOCK_TOKEN=mock-token python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
trap 'kill "${pid:-}" 2>/dev/null; rm -rf "${TMP:-}" "${out:-}"' EXIT
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
[ -z "${port:-}" ] && { echo "mock 未启动"; cat "$out"; exit 1; }
echo " ✅ 监听 127.0.0.1:$port"
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
export BRIDGE_ROOM='!p4:matrix.test' NODE_NAME=selftest P4_WAIT=6 P4_NEG_WAIT=1
export TARGET_A='@tutti-hermes:matrix.test' TARGET_B='@tutti-pi:matrix.test'
export BRIDGE_A='@bridge3:matrix.test' BRIDGE_B='@bridge2:matrix.test'
echo "── 1. 正例:应全绿(exit 0)"
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 0 ] && echo " ✅ 正例通过" || { echo " ❌ 正例失败 exit=$rc"; run_live | sed 's/^/ /'; exit 1; }
echo "── 2. 对照:缺 running(R-3 违例) 必须判红(exit 1)"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_SKIP_RUNNING=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port"
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 1 ] && echo " ✅ 缺 running 时门正确报红(exit 1)" || { echo " ❌ 缺 running 未报红 exit=$rc"; exit 1; }
echo "── 3. 对照:房间缺第二个 target 必须判前置不足(exit 2)"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_NO_TARGET_B=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port"
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 2 ] && echo " ✅ 缺 target 时拒绝判读(exit 2)" || { echo " ❌ 缺 target 未判前置不足 exit=$rc"; exit 1; }
echo "── 4. 对照:另一节点的桥抢答(串台, R-7 违反) 必须判红(exit 1)"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_LEAK=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 1 ] && echo " ✅ 串台时门正确报红(exit 1)" || { echo " ❌ 串台未报红 exit=$rc"; exit 1; }
echo "── 5. 对照:错 token 必须拒绝判读(exit 2)"
export MATRIX_ACCESS_TOKEN=wrong-token
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 2 ] && echo " ✅ 令牌失效时拒绝判读(exit 2)" || { echo " ❌ 错 token 未拒绝 exit=$rc"; exit 1; }
kill $pid 2>/dev/null
echo "── 6. --pair:两台机各跑一次的原始输出应被**机械**判定为通过"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
run_live > "$TMP/pa.log" 2>&1
run_live > "$TMP/pb.log" 2>&1
sed -i.bak 's/node=selftest/node=mac13/' "$TMP/pa.log" # 只改"我在哪台机"
sed -i.bak 's/node=selftest/node=mac15/; s/user=@bridge3:matrix.test/user=@bridge2:matrix.test/' "$TMP/pb.log"
if pair_check "$TMP/pa.log" "$TMP/pb.log"; then
echo " ✅ 两节点/两桥/同房间/归属唯一且跨轮一致 ⇒ 机械判定通过"
else
echo " ❌ --pair 正例失败"; exit 1
fi
echo "── 7. 对照:两侧 node 相同(同一台机跑两次) 必须判红"
cp "$TMP/pa.log" "$TMP/pc.log"
if pair_check "$TMP/pa.log" "$TMP/pc.log" >/dev/null 2>&1; then
echo " ❌ 同一节点跑两次竟被判通过"; exit 1
else
echo " ✅ 同一节点被判红(确实不是两台物理机)"
fi
echo "── 8. 对照:跨轮归属不一致(同一 target 换桥应答) 必须判红 —— R-7"
sed 's/target=@tutti-hermes:matrix.test bridge=@bridge3:matrix.test/target=@tutti-hermes:matrix.test bridge=@bridge9:matrix.test/' \
"$TMP/pb.log" > "$TMP/pd.log"
if pair_check "$TMP/pa.log" "$TMP/pd.log" >/dev/null 2>&1; then
echo " ❌ 跨轮归属不一致竟被判通过"; exit 1
else
echo " ✅ 归属不稳定被判红(R-7)"
fi
echo "── 9. 对照:两侧 target 零交叉(各只测一个) 必须判红 —— 缺陷K"
grep -v "tutti-pi" "$TMP/pa.log" > "$TMP/pe.log" # A 侧只留 hermes
grep -v "tutti-hermes" "$TMP/pb.log" > "$TMP/pf.log" # B 侧只留 pi
if pair_check "$TMP/pe.log" "$TMP/pf.log" >/dev/null 2>&1; then
echo " ❌ 零交叉竟被判通过(阶段门放水)"; exit 1
else
echo " ✅ 零交叉被判红(缺陷K 已修)"
fi
echo "── 10. 对照:/messages 取数 403 ⇒ 阴性对照必须报取数失败 —— 缺陷L"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_MESSAGES_403=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 2 ] && echo " ✅ 取数失败时拒绝判读(exit 2), 不再 fail-open" \
|| { echo " ❌ 取数失败未拒绝判读 exit=$rc"; exit 1; }
echo
echo "═══ 15-criterion4-live selftest: 全部通过(正例 2 + 对照 8)═══"
}
case "${1:-}" in
--selftest) selftest;;
--pair) pair_check "${2:-}" "${3:-}";;
""|--live) run_live;;
--help|-h) sed -n '2,28p' "$0";;
*) echo "未知参数: $1(用 --selftest 或 --help)" >&2; exit 2;;
esac