Files
MailUI4Agents/deploy/service-failure-notify.mjs
JianFeeeee 9204f019a1 fix(deploy): 故障告警按 invocation 分会话 —— 崩溃循环时告警不再被护栏挡下
# 问题(实测)

服务反复崩溃时,故障告警**送不出去**。

链路:告警邮件带 `relay:"summary"`(走免预算通道),而服务端按
`AutoAliasFor(收件人, 主题)` 派生会话别名 —— **主题相同即同一条会话**。于是崩溃
循环下多封告警全落进同一条会话,而网关对会话内**连续**的中继邮件有
`maxRelayHops=5` 的硬上限(防两个 Agent 互相唤醒的正当护栏)→ 第 6 封起返回 403:

    POST /api/v1/mail/send ... - 403 245B        (网关 NRestarts=0,一直在正常服务)

报告只能落 spool,等下次 ExecStartPost `--flush` 才补投。实测 dsh 崩溃循环
(我自己的部署缺陷所致)时的六条告警全部 spooled —— 而**服务反复崩溃正是最需要
告警送达的时刻**。

# 修法

主题带上本次启动的 `INVOCATION_ID` 短号:`[dsh] 桥服务异常终止 #5ce6a845`。
主题变了,别名与会话随之分开,每次崩溃各自可投递。

**护栏不削弱**:它针对的是 agent↔agent 互相唤醒,不是同一个人收多条故障通知。
副作用是崩溃循环产生多条会话而非一条线程 —— 对"服务在崩"这件事,分开计数比合并成
一条更容易发现问题。

`INVOCATION_ID` 缺失时退回 `Date.now()+randomUUID()` 的哈希:宁可每次不同,
也不能因为拿不到标识而退回"主题相同 → 告警又被挡下"。

# 验证(三条,含两个反向对照)

  不同 invocation      → 主题各不相同(每次崩溃各自成会话)
  无 invocation(兜底) → 每次仍各不相同(不会退回同主题)
  同一 invocation 重报 → 主题相同(一次崩溃仍是一条会话,不刷屏)

注:第二次验证第一次跑时"失败",是因为**我的测试假设错了** —— 父环境里本来就继承了
`INVOCATION_ID`,两次跑用的是同一个 id。用 `env -u INVOCATION_ID` 才真正走到兜底分支。
2026-09-12 11:56:48 +08:00

316 lines
12 KiB
JavaScript
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 node
/**
* systemd 服务异常退出邮件上报器。
*
* 两种调用:
* service-failure-notify.mjs --report --service pi-mail-bridge.service
* service-failure-notify.mjs --flush --service pi-mail-bridge.service
*
* `--report` 供 ExecStopPost 使用。systemd 会把 SERVICE_RESULT / EXIT_CODE /
* EXIT_STATUS / INVOCATION_ID 注入命令环境;正常 stop/restart 不上报,只有
* exit-code、signal、oom-kill、timeout、watchdog 等异常结果才发信。
*
* Gateway 暂时不可达时,报告只把**不含密钥**的邮件 payload 落到本地 spool
* 下次服务启动由 `--flush` 补发。relay_key 取 systemd invocation id立即发送与
* 补发即使竞态也只会入库一封。
*/
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const DEFAULT_GATEWAY = 'http://127.0.0.1:8180';
const DEFAULT_RECIPIENT = 'jianf@';
const DEFAULT_SPOOL = '/var/lib/agentmail/failure-spool';
const MAX_SPOOL_FILES = 100;
const REQUEST_TIMEOUT_MS = 5_000;
function argValue(argv, name) {
const i = argv.indexOf(name);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
}
export function isAbnormalExit(env = process.env) {
const result = String(env.SERVICE_RESULT || '').trim();
if (result) return result !== 'success';
const code = String(env.EXIT_CODE || '').trim();
const status = String(env.EXIT_STATUS || '').trim();
if (!code && !status) return false;
return !(code === 'exited' && (status === '' || status === '0'));
}
function cleanServiceName(value, agentName) {
const raw = String(value || '').trim();
if (raw) return basename(raw).replace(/[^A-Za-z0-9_.@-]/g, '-').slice(0, 96);
return `${String(agentName || 'agent').replace(/[^A-Za-z0-9_.@-]/g, '-')}-bridge`;
}
function redact(value, env) {
let out = String(value || '');
for (const secret of [env.AGENTMAIL_AGENT_KEY, env.AGENTMAIL_AGENT_SECRET]) {
if (secret) out = out.split(String(secret)).join('[REDACTED]');
}
return out
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]')
.replace(/\b(?:ak_[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED]')
.slice(0, 1_500);
}
/**
* 本次服务启动的短标识,用于让每封故障通知各自成会话(见 subject 处的注释)。
*
* 优先用 systemd 的 `INVOCATION_ID`(每次启动唯一)。它缺失时退回随机值 ——
* 宁可每次不同,也不要因为拿不到标识而退回"主题相同 → 告警被护栏挡下"。
*/
function invocationTag(env) {
const invocation = String(env.INVOCATION_ID || '').trim();
if (invocation) return invocation.replace(/[^A-Za-z0-9]/g, '').slice(0, 8);
return createHash('sha256')
.update(`${Date.now()}:${randomUUID()}`)
.digest('hex')
.slice(0, 8);
}
function relayKeyFor(env, serviceName) {
const invocation = String(env.INVOCATION_ID || '').trim();
if (invocation) return `service-failure:${invocation}`;
const seed = `${serviceName}:${Date.now()}:${randomUUID()}`;
return `service-failure:${createHash('sha256').update(seed).digest('hex')}`;
}
export function buildFailurePayload({
env = process.env,
serviceName = '',
reason = '',
error = null,
now = new Date(),
} = {}) {
const agentName = String(env.AGENTMAIL_AGENT_NAME || 'unknown-agent').trim();
const service = cleanServiceName(serviceName || env.AGENTMAIL_SERVICE_NAME, agentName);
const result = String(env.SERVICE_RESULT || reason || 'unexpected-exit');
const exitCode = String(env.EXIT_CODE || 'unknown');
const exitStatus = String(env.EXIT_STATUS || 'unknown');
const detail = redact(error?.stack || error?.message || error || reason, env);
const lines = [
`**${agentName}** 的宿主服务 **${service}** 异常终止。`,
'',
`- 时间:${now.toISOString()}`,
`- systemd 结果:${result}`,
`- 退出类型:${exitCode}`,
`- 退出状态:${exitStatus}`,
`- invocation${String(env.INVOCATION_ID || 'unknown')}`,
];
if (detail) lines.push('', '**异常详情(已脱敏)**', '```text', detail, '```');
lines.push(
'',
'systemd 已按指数退避策略自动重启该服务;如果邮件连续出现,请检查:',
`\`journalctl -u ${service} -n 100 --no-pager\``,
);
return {
to: String(env.AGENTMAIL_FAILURE_RECIPIENT || DEFAULT_RECIPIENT),
/*
* 主题带上本次启动的 invocation 短号。
*
* 服务端按 `AutoAliasFor(收件人, 主题)` 派生会话别名,所以**主题相同 = 同一条会话**。
* 崩溃循环下这一点会咬人:多封告警全落进同一条会话,而网关对会话内**连续**的中继
* 邮件有 `maxRelayHops=5` 的硬上限(防两个 Agent 互相唤醒的正当护栏)→ 第 6 封起
* 返回 403报告只能落 spool等下次 ExecStartPost `--flush` 才补投。
*
* 实测就是这样dsh 崩溃循环时的六条告警全部 spooled而**服务反复崩溃正是最需要
* 告警送达的时刻**。
*
* 带 invocation 让每次崩溃各自成会话,因而每次都能投出去 —— 护栏不受影响
* (它针对的是 agent↔agent 的互相唤醒,不是同一个人收多条故障通知)。
* 副作用是崩溃循环会产生多条会话而不是一条线程;对"服务在崩"这件事,
* 分开计数比合并成一条更容易发现问题。
*/
subject: `[${agentName}] 桥服务异常终止 #${invocationTag(env)}`,
body: lines.join('\n'),
relay: 'summary',
relay_key: relayKeyFor(env, service),
};
}
async function postPayload(payload, env = process.env) {
const key = String(env.AGENTMAIL_AGENT_KEY || '').trim();
if (!key) throw new Error('AGENTMAIL_AGENT_KEY 未配置');
const base = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_GATEWAY).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${base}/api/v1/mail/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'X-Agent-Name': String(env.AGENTMAIL_AGENT_NAME || ''),
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal: controller.signal,
});
const responseBody = await res.text();
if (!res.ok) throw new Error(`Gateway HTTP ${res.status}: ${responseBody.slice(0, 300)}`);
return responseBody;
} finally {
clearTimeout(timer);
}
}
function safeAgentName(env) {
return String(env.AGENTMAIL_AGENT_NAME || 'unknown-agent')
.replace(/[^A-Za-z0-9_.@-]/g, '-')
.slice(0, 96);
}
function spoolDirFor(env) {
return join(String(env.AGENTMAIL_FAILURE_SPOOL || DEFAULT_SPOOL), safeAgentName(env));
}
function safeSpoolName(payload) {
return `${createHash('sha256').update(String(payload.relay_key)).digest('hex')}.json`;
}
async function pruneSpool(spoolDir) {
const names = (await readdir(spoolDir).catch(() => []))
.filter((name) => name.endsWith('.json'));
if (names.length <= MAX_SPOOL_FILES) return;
const entries = await Promise.all(names.map(async (name) => ({
name,
mtime: (await stat(join(spoolDir, name))).mtimeMs,
})));
entries.sort((a, b) => a.mtime - b.mtime);
for (const entry of entries.slice(0, entries.length - MAX_SPOOL_FILES)) {
await rm(join(spoolDir, entry.name), { force: true });
}
}
export async function spoolPayload(payload, env = process.env) {
// 每个 Agent 独立目录:否则 pi 启动时可能拿自己的密钥去补发 dsh 的报告,
// 发件身份与正文主体会错位relay_key 的幂等范围也变了。
const spoolDir = spoolDirFor(env);
await mkdir(spoolDir, { recursive: true, mode: 0o700 });
const target = join(spoolDir, safeSpoolName(payload));
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temp, `${JSON.stringify({ payload }, null, 2)}\n`, { mode: 0o600 });
await rename(temp, target);
await pruneSpool(spoolDir);
return target;
}
export async function notifyCrash(reason, error, options = {}) {
const env = options.env || process.env;
const payload = buildFailurePayload({
env,
serviceName: options.serviceName,
reason,
error,
now: options.now,
});
try {
await postPayload(payload, env);
return { sent: true, spooled: false, payload };
} catch (sendError) {
if (options.spool === false) throw sendError;
const path = await spoolPayload(payload, env);
return { sent: false, spooled: true, path, payload, error: sendError };
}
}
export async function flushSpool(env = process.env) {
const spoolDir = spoolDirFor(env);
const names = (await readdir(spoolDir).catch(() => []))
.filter((name) => name.endsWith('.json'))
.sort();
let sent = 0;
let failed = 0;
for (const name of names) {
const path = join(spoolDir, name);
try {
const record = JSON.parse(await readFile(path, 'utf8'));
await postPayload(record.payload, env);
await rm(path, { force: true });
sent++;
} catch {
failed++;
}
}
return { total: names.length, sent, failed };
}
/**
* 把发送失败翻译成一句能定位原因的话。
*
* `postPayload` 对非 2xx 抛的是 `Gateway HTTP <status>: <body>` —— 里面已经带着
* 状态码与响应体,直接打出来就够定位了。只有 fetch 本身抛DNS/连接/超时)才真的是
* 「不可达」,此时按名字区分开。
*/
function describeSendError(error) {
const message = String(error?.message || error || '');
const httpMatch = message.match(/^Gateway HTTP (\d{3}): ([\s\S]*)$/);
if (httpMatch) {
return `网关可达,但返回 HTTP ${httpMatch[1]}${httpMatch[2].slice(0, 300)}`;
}
if (error?.name === 'AbortError') return '请求超时(网关未在限定时间内响应)';
if (/fetch failed|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH/i.test(message)) {
return `网关不可达:${message}`;
}
return message || '未知错误';
}
async function main(argv = process.argv.slice(2), env = process.env) {
const serviceName = argValue(argv, '--service');
if (argv.includes('--flush')) {
const result = await flushSpool(env);
if (result.sent || result.failed) {
console.error(`[agentmail-failure-notify] spool: sent=${result.sent} failed=${result.failed}`);
}
return;
}
if (!argv.includes('--report')) throw new Error('需要 --report 或 --flush');
if (!isAbnormalExit(env)) return;
if (argv.includes('--dry-run')) {
console.log(JSON.stringify(buildFailurePayload({ env, serviceName }), null, 2));
return;
}
const result = await notifyCrash(env.SERVICE_RESULT || 'unexpected-exit', null, {
env,
serviceName,
});
if (result.sent) {
console.error(`[agentmail-failure-notify] ${serviceName} 异常已上报`);
return;
}
/*
* 区分「连不上」与「连上了但被拒」。
*
* 原先两种情况都打印「Gateway 不可达」。实测代价:切换插件时 dsh 进了崩溃循环,
* 通知脚本连打六条「Gateway 不可达」并写进 spool —— 而网关**一直在正常服务**
* NRestarts=0真实响应是 **HTTP 403**(同一会话连续中继邮件撞上防互相唤醒
* 的跳数上限,`maxRelayHops=5`)。
*
* 那句话会把人送去查网络,而问题在策略层。故障通知本身给出误导性诊断,
* 是「静默失败」的另一种形态。
*/
console.error(
`[agentmail-failure-notify] ${serviceName} 异常上报失败,报告已暂存 ${result.path}`
);
console.error(`[agentmail-failure-notify] 原因:${describeSendError(result.error)}`);
}
const isCLI = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCLI) {
main().catch((error) => {
console.error(`[agentmail-failure-notify] ${error?.message || error}`);
process.exitCode = 1;
});
}