Files
MailUI4Agents/scripts/sse-delivery-audit.sh
JianFeeeee be9bfd3cd1 chore(deploy)★★: 固定端口监听扩展的失效防护 + SSE 投递审计脚本
## 一次真实事故(2026-10-03)

pi-web 会话里**所有**子 agent 都启动失败,报错

    Error: listen EADDRINUSE: address already in use 127.0.0.1:14010

这个扩展把端口写死,于是「端口被占」直接变成**全部启动失败**,
没有任何降级路径。这次提交把那条路径变成可测的。

- `check-listen-portability.mjs`(360 行):主判据
- `mutate-listen-portability.py`(124 行):变异体 —— 确认判据抓得住
- `verify-ext-parent-path.mjs` / `verify-ext-port-taken.mjs`:
  两条独立路径。**"端口已被占用"那一条之前从没真正跑完过** ——
  它原先被塞在一条 `node -e` 里,进程在「await 端口探测」那步被 30s
  超时掐断,而它恰恰是唯一会决定「子进程会不会被打死」的分支。

## scripts/sse-delivery-audit.sh(298 行)

从网关 access log 判定「SSE 那批邮件到底投没投出去」,**只读**。

为什么必须进版本库而不是 `.tmp/`:`.tmp/` 在 .gitignore 里,
写在 `.tmp/stress-test.sh` 的判据下一轮就没了 ——
而 `docs/DEBTS.json` 里 `stress-sse-delivered-but-silent-no-assertion`
那笔债会长期挂着。**判据和账本必须同寿命。**

## .gitignore

补 `/tmp/`:blob 阈值测试会生成 13MB 级 .bin
(`client/electron/test/tmp/att-threshold-test/`),可再生,不该进版本库
(本机实测 25MB)。
2026-10-05 09:26:59 +08:00

299 lines
18 KiB
Bash
Executable File
Raw 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
# 从网关 access log 判定「SSE 那批邮件到底投没投出去」——**只读**,不碰数据、不碰服务。
#
# ## 为什么它必须在版本控制里(而不是 .tmp/)
#
# `.tmp/` 在 .gitignore:12 ⇒ 写在 `.tmp/stress-test.sh` 里的判据**下一轮就没了**,
# 而 `docs/DEBTS.json` 里 `stress-sse-delivered-but-silent-no-assertion` 那笔债会长期裸着。
# 判据和账本必须同寿命,所以它住 scripts/。
#
# ## 它替代的是什么
#
# 原判据只有两个读数:「起了 worker」(桥日志「命名同步」)与「真没投出」。
# 2026-09-28 那轮把两者拆开后,仍漏一档:**worker 起来之后有没有把正文取到手里**。
# 本脚本产出**三个分开的读数**,外加一条「取信是否被归档阻断」的判据。
#
# ## ★ 三个读数必须分开报,不能合成一个「成功率」
#
# ① 投递成立 = 有 mail_reads 行(markDelivered 的指纹,间隔 ~4~12ms)
# ② 起了 worker = 桥日志有「命名同步」
# ③ 取到正文 = access log 里 `GET /agent/mail/{id}` 返回 200 **且响应大小与 body 长度相关**
#
# 「投了但没人回信」是**协议设计**(Agent 来信不自动转发),不是丢信 ——
# 它与「没投出」在库里**同形**(都只有一封、都 status=read、都有 reader_name='pi' 一行)。
# ⇒ 只看库永远分不开,必须靠 ② ③ 这类库外读数。
#
# 用法:
# ALIASES='stress-sse-1-18730,stress-sse-2-24857' bash scripts/sse-delivery-audit.sh
# SINCE='2026-09-28 10:05' UNTIL='2026-09-28 10:40' DB=/opt/agentmail/data/agentmail.db \
# bash scripts/sse-delivery-audit.sh
set -euo pipefail
DB="${DB:-/opt/agentmail/data/agentmail.db}"
UNIT="${UNIT:-agentmail-gateway}"
SINCE="${SINCE:-1 hour ago}"
UNTIL="${UNTIL:-now}"
BRIDGE_LOG="${BRIDGE_LOG:-}" # 可选:桥日志路径,给读数 ②
ALIASES="${ALIASES:-}" # 可选:逗号分隔别名;不给则自动取 stress-sse% 全部
command -v sqlite3 >/dev/null || { echo "需要 sqlite3" >&2; exit 2; }
command -v journalctl >/dev/null || { echo "需要 journalctl" >&2; exit 2; }
[ -r "$DB" ] || { echo "读不到 $DB" >&2; exit 2; }
# ─────────────────────────────────────────────────────────────
# 0b. 落临时文件前先证明「临时目录写得进去」
#
# ★★★ 同一个坑的第二个器官(2026-09-28 实撞):
# /tmp 那个 tmpfs 涨到 100% 满时,mktemp 与重定向**双双失败**,
# 而下面那行 `|| true` 会把「写不进去」和「一条都没投出」**变成同一个 0**。
# 症状:脚本 exit 1、stdout/stderr **各 0 字节**,连 `bash -x` 都没有 trace。
# ⇒ 曾经我据此以为「脚本自己有 bug」,其实是磁盘满。
# ⇒ 所以:先自检可写,失败就 **exit 2**(环境问题),绝不留一个 0 让下游推理。
# ─────────────────────────────────────────────────────────────
tmp_probe=$(mktemp 2>/dev/null) || tmp_probe=""
if [ -z "$tmp_probe" ] || ! printf 'x' > "$tmp_probe" 2>/dev/null; then
{
echo "❌ 临时目录不可写(mktemp/重定向失败)——**这不是「没投出」,是环境问题**。"
echo " 症状会伪装成:第 1 档输出「取信请求 0 条」、且本档**不出读数**。"
echo " TMPDIR=${TMPDIR:-<未设,走 /tmp>}"
echo " 排查: df -h \$(df -d /tmp --output=target 2>/dev/null || echo /tmp)"
echo " 临时绕过: TMPDIR=\$PWD/.tmp-audit bash \$0"
} >&2
[ -n "$tmp_probe" ] && rm -f "$tmp_probe" 2>/dev/null
exit 2
fi
rm -f "$tmp_probe"
# ─────────────────────────────────────────────────────────────
# 0. 先证明「这条判据本身能命中」——否则后面所有 0 都不可信
# ─────────────────────────────────────────────────────────────
echo "=== 0. 判据自检(命中 0 时不许把 0 写进结论)==="
# ⚠ 这条路径的 query 有五种形态,见下。**不要假设参数顺序**。
# ?workspace=…&session_id=… / ?session_id=…(无 workspace) / ?session_id=…&workspace=…
# /thread?workspace=…&session_id=… / /thread?offset=…&session_id=…
# 写成 grep '…?workspace' 会静默漏掉 43 条(2026-09-28 实测)。
#
# ★★ 另一个坑(这个脚本第一版就踩了,且症状与「没投出」**完全一样**):
# access log 的行尾形状是
# "GET <url> HTTP/1.1" from 127.0.0.1:PORT - 200 956B in 9.17ms
# URL 与状态码之间隔着 `" from 127.0.0.1:PORT`。
# ⇒ 写成 `\?[^ ]* - 200` **匹配不到**(`[^ ]*` 撞在空格上就停了),
# 第 1 档会静默输出「取信请求 0 条」。
# ⇒ 必须用 `\?.* - ` 让它跨过那段。
FETCH_RE='GET http://[^ ]*/api/v1/agent/mail/[0-9a-f-]{36}\?.* - ([0-9]{3}) ([0-9]+)B'
# /thread 单独排除:它取的是往来树,不是正文。
THREAD_RE='agent/mail/[0-9a-f-]{36}/thread\?'
log_all() { journalctl -u "$UNIT" --since "$SINCE" --until "$UNTIL" --no-pager 2>/dev/null; }
probe=$(log_all | grep -cE 'api/v1/agent/mail/[0-9a-f-]{36}\?' || true)
if [ "$probe" -eq 0 ]; then
cat >&2 <<'EOF'
❌ 自检失败:该窗口内 `/api/v1/agent/mail/{id}?` 一条都没有。
在断定「没投出」之前,先排除这三种「静默归零」:
1) 路径写错(人类侧 /mail/{id} 本来就没有流量,Agent 走 /agent/mail/{id})
2) 窗口不对(★ DB 存 UTC、journalctl 打印本地时区,本项目差 8 小时;
按本地时间写 SQL 会整段落空 —— 10:09 本地 = 02:09 UTC)
3) unit 名不对(systemd 服务名不是 agentmail-gateway)
三者都排除后,才可以说「0」。
EOF
exit 3
fi
echo " 自检通过:该窗口有 $probe 条 /agent/mail/{id}? 取信请求(判据能命中)"
cat <<'EOF'
口径(2026-09-28 那轮实测,用来对照别把数读歪):
全部 /agent/mail/*(含 /thread) 347
其中非 thread 203 ← 真正的「取全文」
非 thread 且 UUID 合法 202 ← 本脚本报的数
差 1 条 = UUID 畸形的请求(服务端 400)
非 thread 的 query 形态:?workspace=…&session_id=… 159 / ?session_id=…(无 workspace)43
/ ?session_id=…&workspace=… 1
EOF
# ─────────────────────────────────────────────────────────────
# 1. 读数 ③ 取信是否成功(含「是否被归档阻断」这条独立判据)
# ─────────────────────────────────────────────────────────────
echo
echo "=== 1. 读数 ③:取信(GET /agent/mail/{id},不含 /thread)==="
tmp_fetches=$(mktemp) || { echo "❌ mktemp 失败(临时目录不可写)" >&2; exit 2; }
trap 'rm -f "$tmp_fetches"' EXIT
log_all | grep -E "$FETCH_RE" | grep -vE "$THREAD_RE" \
| sed -E 's/.* ([0-9]{2}:[0-9]{2}:[0-9]{2}).*agent\/mail\/([0-9a-f-]{36})\?.* - ([0-9]{3}) ([0-9]+)B.*/\1 \2 \3 \4/' \
> "$tmp_fetches" || { echo "❌ 写入 $tmp_fetches 失败(磁盘满?)——**不是「没投出」**" >&2; exit 2; }
# ⚠ 不要加 `|| true`:那会把「写失败」和「窗口内一条都没有」压成同一个 0。
# grep 无匹配本来就会 exit 1,那是**合法**的 0 —— 交给第 0 档自检去判。
total=$(wc -l < "$tmp_fetches" | tr -d ' ')
echo " 取信请求 $total 条,状态码分布:"
awk '{print " " $3}' "$tmp_fetches" | sort | uniq -c | sort -rn
ok200=$(awk '$3==200' "$tmp_fetches" | wc -l | tr -d ' ')
not200=$(awk '$3!=200' "$tmp_fetches" | wc -l | tr -d ' ')
echo " 200 = $ok200,非 200 = $not200"
if [ "$not200" -gt 0 ]; then
echo " ⚠ 非 200 的 mail_id 是否还在库里?**不在 ⇒ 是发信失败回滚(DeleteMailByID),与归档无关**"
echo " (2026-09-28 实测 4 条即如此:3×404 + 1×400,那 4 个 mail_id 库里已不存在)"
fi
# ─────────────────────────────────────────────────────────────
# 2. 读数 ①:投递成立(mail_reads = markDelivered 指纹)
# ─────────────────────────────────────────────────────────────
echo
echo "=== 2. 读数 ①:投递成立(mail_reads 行)==="
if [ -n "$ALIASES" ]; then
inlist=$(printf ",%s," "$ALIASES" | sed 's/^,//; s/,$//' | sed "s/'/''/g; s/[^,']*/'&'/g")
scope="s.session_alias IN ($inlist)"
else
# ⚠ 别名有「带字面引号」和「不带」两种形态(2026-09-28:33 条含 ",其中 26 条是 stress-sse)。
# 只查其中一种会得到一个**真实但无关**的数字。LIKE '%…%' 两种都覆盖。
scope="s.session_alias LIKE '%stress-sse%'"
fi
sqlite3 -header -column "$DB" "
SELECT
COUNT(*) AS 会话数,
SUM(EXISTS(SELECT 1 FROM mails m WHERE m.session_id=s.session_id)) AS 有信,
SUM(EXISTS(SELECT 1 FROM mails m WHERE m.session_id=s.session_id
AND EXISTS(SELECT 1 FROM mail_reads r WHERE r.mail_id=m.mail_id))) AS 有投递标记
FROM sessions s WHERE $scope;"
echo " ⇒ 有投递标记 = 投递成立(markDelivered 紧跟投递打,间隔 ~4~12ms)"
# ─────────────────────────────────────────────────────────────
# 3. 读数 ③的强度:响应大小 vs body 长度
# ★ 别把「~950B」当逐封正文到手的证明 —— 那是**路径的通用信封大小**。
# 真判据是**相关性**:若 body 不在响应里,r 应 ≈ 0。
# ─────────────────────────────────────────────────────────────
echo
echo "=== 3. 读数 ③的强度:pearson(body 长度, 响应字节数) ==="
echo " (2026-09-28 实测 n=164 时 r=0.9968 ⇒ 响应确实带 body)"
if command -v python3 >/dev/null; then
python3 - "$DB" "$tmp_fetches" <<'PY'
import sqlite3, sys, math
db = sqlite3.connect(sys.argv[1]); pairs = []
for line in open(sys.argv[2]):
p = line.split()
if len(p) != 4 or p[2] != '200':
continue
r = db.execute("SELECT length(body) FROM mails WHERE mail_id=?", (p[1],)).fetchone()
if r and r[0] is not None:
pairs.append((r[0], int(p[3])))
if len(pairs) < 8:
print(f" 样本不足(n={len(pairs)},需 ≥8)⇒ 本档**不出读数**,别硬凑")
sys.exit(0)
n = len(pairs); mx = sum(a for a, _ in pairs)/n; my = sum(b for _, b in pairs)/n
cov = sum((a-mx)*(b-my) for a, b in pairs)
sx = math.sqrt(sum((a-mx)**2 for a, _ in pairs)); sy = math.sqrt(sum((b-my)**2 for _, b in pairs))
r = cov/(sx*sy) if sx and sy else 0.0
print(f" n={n} r={r:.4f} body {min(a for a,_ in pairs)}..{max(a for a,_ in pairs)}B"
f" 响应 {min(b for _,b in pairs)}..{max(b for _,b in pairs)}B")
print(" ⇒ r 高 ⇒ 响应带 body(非空壳)。**这证明「服务端发出了 body」,"
"不证明「worker 交给了模型」** —— 后者只有桥侧证据。")
PY
else
echo " (无 python3,跳过本档)"
fi
# ─────────────────────────────────────────────────────────────
# 4. 读数 ②:起了 worker(桥日志「命名同步」)
# ─────────────────────────────────────────────────────────────
echo
echo "=== 4. 读数 ②:起了 worker(桥日志「命名同步」)==="
if [ -n "$BRIDGE_LOG" ] && [ -r "$BRIDGE_LOG" ]; then
grep -c "命名同步" "$BRIDGE_LOG" | sed 's/^/ 命名同步条数:/'
echo " ⚠ 这只证明 worker 起来了,**不证明它取到了正文**(那是上面第 3 档)"
else
echo " 未提供 BRIDGE_LOG,跳过。传 BRIDGE_LOG=<桥日志路径> 启用。"
echo " ⚠ 没有这一档时,② 与 ③ 不可互相替代 —— 两者是不同的失败模式。"
fi
# ─────────────────────────────────────────────────────────────
# 5. 归档这条独立判据(不与上面任何一档混)
# ─────────────────────────────────────────────────────────────
echo
echo "=== 5. 归档:别名寻址是否已死 ==="
sqlite3 -header -column "$DB" "
SELECT s.status AS 会话状态, COUNT(*) AS 条数,
SUM(NOT EXISTS(SELECT 1 FROM mails m WHERE m.session_id=s.session_id
AND m.status <> 'archived')) AS 会案内仍有非归档邮件
FROM sessions s WHERE $scope GROUP BY 1;"
cat <<'EOF'
⇒ 取信(AgentGetMail → AgentMayReadSession)**不**看 sessions.status ⇒ 归档不阻断取信
(判据在源码:repo/session_workspace.go 的 AgentMayReadSession 只比 scope == target)。
⇒ 但**收件箱**(ListInboxScoped)与**别名寻址**(FindNamedSessionFor)都带 status <> 'archived'
⇒ 归档后原地址是**死信地址,回信投不进去**。后果更重,别与「取不到信」混为一谈。
EOF
# ─────────────────────────────────────────────────────────────
# 6. 半活会话:归档是否被「复活」
# 签名唯一 —— 旧 TouchSession 只盖 sessions.status,不碰 mails;
# ArchiveSession 两条都盖。⇒ sessions=active 而 mails=archived 只可能来自
# 「先归档、后 touch」。这比读 updated_at 可靠:updated_at 会被任何写入改,
# 这张两表状态分叉不会。
# ─────────────────────────────────────────────────────────────
echo
echo "=== 6. 半活会话(sess=active 但 mail=archived ⇒ 归档后被复活)==="
sqlite3 -header -column "$DB" "
SELECT s.session_alias, s.updated_at, COUNT(*) AS 归档邮件数
FROM sessions s JOIN mails m ON m.session_id = s.session_id
WHERE $scope AND s.status = 'active' AND m.status = 'archived'
GROUP BY 1,2 ORDER BY s.updated_at;"
half=$(sqlite3 "$DB" "
SELECT COUNT(*) FROM (SELECT 1 FROM sessions s JOIN mails m ON m.session_id=s.session_id
WHERE $scope AND s.status='active' AND m.status='archived' GROUP BY s.session_id);")
echo " 半活会话数:$half"
if [ "$half" = "0" ]; then
echo " ⇒ 该范围内无半活会话。"
else
cat <<'EOF'
⚠ **非 0 就说明归档被绕过了**:这批会话的 mails 已是 archived(归档确实发生过),
但 sessions.status 又是 active。后果 = 别名寻址/收件箱里它又「活着」,
而它的信已被归档 —— **「已归档」与「还活着」并存**。
⇒ 别用 `sessions.status` 单表判断归档是否生效,要连 mails.status 一起看。
EOF
fi
# ─────────────────────────────────────────────────────────────
# 6b. ★ 反向分叉:sess=archived 而 mail<>'archived'
# 这是 6 的**镜像**,而且**成因至今是活的**:
# deploy/archive-stale-sessions.sh:105 归档时只 UPDATE sessions,
# 同一个事务里没有 UPDATE mails ⇒ 每跑一次 sweeper 就多一批。
# 它的回滚脚本(:147)同样只写 sessions ⇒ 回滚会把这些邮件一次性放回收件箱。
# ⚠ session_status_invariant_test.go:70 查的正是这个方向,但只在 t.TempDir()
# 的空库上跑 ⇒ 存量分叉它永远看不见(实测生产已有 28 会话 / 431 封)。
# 两个方向要一起查:s.status 与 mails.status 的四种组合里,
# 只有「全 active」和「全 archived」自洽,另两种都是分叉。
# ─────────────────────────────────────────────────────────────
echo
echo "=== 6b. 反向分叉(sess=archived 但有非 archived 邮件 ⇒ 归档没盖到 mails)==="
sqlite3 -header -column "$DB" "
SELECT s.session_alias,
COUNT(*) AS 总邮件,
SUM(m.status <> 'archived') AS 非归档邮件,
SUM(m.status = 'unread') AS 其中未读
FROM sessions s JOIN mails m ON m.session_id = s.session_id
WHERE $scope AND s.status = 'archived' AND m.status <> 'archived'
GROUP BY 1 ORDER BY 非归档邮件 DESC;"
rev=$(sqlite3 "$DB" "
SELECT COUNT(*) FROM (SELECT 1 FROM sessions s JOIN mails m ON m.session_id=s.session_id
WHERE $scope AND s.status='archived' AND m.status<>'archived' GROUP BY s.session_id);")
revmail=$(sqlite3 "$DB" "
SELECT COUNT(*) FROM sessions s JOIN mails m ON m.session_id=s.session_id
WHERE $scope AND s.status='archived' AND m.status<>'archived';")
echo " 反向分叉会话数:$rev 涉及非归档邮件:$revmail 封"
if [ "$rev" = "0" ]; then
echo " ⇒ 该范围内无反向分叉。"
else
cat <<'EOF'
⚠ **非 0 说明归档只盖了 sessions、没盖 mails**。后果比半活更直接:
· 收件箱 ListInboxScoped 带 `s.status <> 'archived'` ⇒ 这些信**完全不可见**;
· 别名寻址 FindNamedSessionFor 同样带该谓词 ⇒ **回信投不进去**(死信地址);
· 但 sweeper 的回滚脚本只把 sessions 改回 active ⇒ 回滚会**一次性把它们全放回来**,
「回滚」在这里是放大器而不是修复。
⇒ 归档语义必须两表一起判:`ArchiveSession` 已经是对的(两条 UPDATE),
缺的是 `deploy/archive-stale-sessions.sh`(归档与回滚都只写 sessions)。
EOF
fi