Files
MailUI4Agents/deploy/service-failure-notify.mjs
JianFeeeee 2922fb711f fix(deploy): 故障通知的三处缺陷 —— 死信目录、隔夜补投、失败无原因
起因:用户邮箱里「[dsh] 桥服务异常终止」反复出现。查下来有两层。

**第一层:崩溃本身(已修,是历史)**
dsh 在 2026-09-12 11:40 起崩溃循环,根因是当时那次插件快照切换后
`/root/.dsh/profiles/web/node_modules/dsh-mail-bridge/cordis.patch.yml` 不存在
(`failed to read overlay … ENOENT`)→ 起不来 → systemd 反复重启。
现在快照里该文件在、dsh `NRestarts=0`、今天 0 次失败。

**第二层:通知管线本身坏了(本次修)**

1. ★ **死信目录**:`zcode.service`(应用单元)既没有 `AGENTMAIL_AGENT_NAME` 也没有
   密钥,报告就落进 `unknown-agent/` —— 而 flush **只读自己那个 Agent 的目录**,
   于是 25 份 zcode 崩溃告警永久投不出去。修法是两件事:
   · `resolveIdentity()`:身份按「单元 env → 单元名推导 → /etc/agentmail/<agent>.env」
     解析;`zcode.service`→zcode、`pi-mail-bridge.service`→pi……
   · 支持 `AGENTMAIL_AGENT_SECRET`:zcode 只配了 secret 没有 key,而脚本原先只认
     Bearer key ⇒ 就算目录对了也发不出去(网关的 AgentAuth 两种都认)。

2. ★ **隔夜补投**:补投原先只在**同一个单元**的下次 `ExecStartPost` 跑,于是
   网关不可达时攒下的报告要等到那个服务自己重启才补投 —— 实测 4 封 Sep-12 的
   告警在 Sep-13 10:35 才到。现在新增 `agentmail-failure-flush.timer`(每 10 分钟
   `--flush-all`),它遍历所有 Agent 目录、按目录名逐个解析身份后补投。
   过时报告还会在主题与正文上标 **「补投:这是 N 分钟前的故障报告,不代表现在仍在
   故障」**(原先正文里只有昨天的时间戳,读起来像刚崩)。

3. **补投失败只报数不报因**:`catch { failed++ }` → 日志只有 `spool: sent=0 failed=4`,
   没人知道卡在哪。现在每条失败都带回原因(HTTP 状态码/网关不可达/身份未配置)。

4. 顺带两处准确性问题:
   · 主题写**单元名**而不是笼统的「桥服务」—— 25 封标题写着"桥服务异常终止",
     实际崩的是 zcode **应用**单元,照标题去查桥方向就错了。
   · `created_at_ms` 是我们的元数据,但 `/mail/send` 是**严格解码**的(实测 400
     不认识的字段)→ 发送前剥离,线格式保持干净。

判据:新增 `deploy/service-failure-notify.test.mjs`(12 条,含假网关做真实投递、
严格解码断言、补投标记的正反两向)。端到端验证:模拟"没有身份的 zcode.service
崩溃"——修复前落 `unknown-agent/` 永久死信;现在**真的投出去了**,且
`from_name=zcode`、主题 `[zcode] zcode.service 异常终止 #…`。

积压清理:27 份(dsh 2 + unknown-agent 25)全部来自 09-12 那两轮崩溃、事件已在
邮箱与 journal 里出现过,按**不再补投**处理(避免把隔夜告警灌进邮箱),
原始文件留档 /root/gotmp/failure-spool-backlog-20260913.tar.gz(600),
spool 目录留 README.md 说明。
2026-09-13 11:06:01 +08:00

497 lines
21 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 { readFileSync } from 'node:fs';
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';
/**
* Agent 身份文件目录。单元自己的 env 里未必有密钥(`zcode.service` 就没有 ——
* 它是应用单元、不是桥),但同一个 Agent 的密钥一定在 `/etc/agentmail/<name>.env`。
*/
const DEFAULT_ENV_DIR = '/etc/agentmail';
/**
* 早于这个时长的报告在补投时会被标成「补投」。
*
* 为什么必须标:实测有 4 封 Sep-12 的崩溃告警因为网关当时不可达而落 spool
* 直到第二天重启服务才补投出去 —— 正文里的时间戳是**昨天**,而人打开邮箱时
* 会以为服务刚刚又崩了。告警的时效性就是它的全部价值。
*/
const STALE_MS = 30 * 60 * 1000;
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(),
} = {}) {
// 身份优先按单元名推导 + 从 /etc/agentmail/<agent>.env 取 —— 单元自己的 env
// 里未必有名字zcode.service 就没有),而正文与目录都要用它。
const agentName = resolveIdentity(env, serviceName).name;
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),
// 机器可读的生成时刻:补投时据此判断这份报告有多旧(正文里的时间是给人看的)
created_at_ms: now.getTime(),
/*
* 主题带上本次启动的 invocation 短号。
*
* 服务端按 `AutoAliasFor(收件人, 主题)` 派生会话别名,所以**主题相同 = 同一条会话**。
* 崩溃循环下这一点会咬人:多封告警全落进同一条会话,而网关对会话内**连续**的中继
* 邮件有 `maxRelayHops=5` 的硬上限(防两个 Agent 互相唤醒的正当护栏)→ 第 6 封起
* 返回 403报告只能落 spool等下次 ExecStartPost `--flush` 才补投。
*
* 实测就是这样dsh 崩溃循环时的六条告警全部 spooled而**服务反复崩溃正是最需要
* 告警送达的时刻**。
*
* 带 invocation 让每次崩溃各自成会话,因而每次都能投出去 —— 护栏不受影响
* (它针对的是 agent↔agent 的互相唤醒,不是同一个人收多条故障通知)。
* 副作用是崩溃循环会产生多条会话而不是一条线程;对"服务在崩"这件事,
* 分开计数比合并成一条更容易发现问题。
*/
// 主题里写**单元名**而不是笼统的"桥服务":同一个 Agent 可能既有应用单元
// zcode.service又有桥单元zcode-mail-bridge.service崩的常常是前者。
// 实测代价25 封标题写着"桥服务异常终止",实际是 zcode **应用**单元在崩 ——
// 照标题去查桥,方向从一开始就错了。
subject: `[${agentName}] ${service} 异常终止 #${invocationTag(env)}`,
body: lines.join('\n'),
relay: 'summary',
relay_key: relayKeyFor(env, service),
};
}
async function postPayload(payload, env = process.env, serviceName = '') {
const identity = resolveIdentity(env, serviceName);
if (!identity.key && !identity.secret) {
throw new Error(
`Agent 身份未配置(单元 env 与 /etc/agentmail/${identity.name}.env 里都没有 ` +
'AGENTMAIL_AGENT_KEY / AGENTMAIL_AGENT_SECRET'
);
}
const base = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_GATEWAY).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
/*
* 剥掉我们自己的元数据再发。
*
* `/mail/send` 是**严格解码**的(不认识的字段直接 400而 `created_at_ms`
* 只是 spool 记录用来判断"这份报告有多旧"的,不属于线上格式。
* 实测:带上它 → `400 不认识的字段 "created_at_ms"`,报告只好又回 spool。
*/
const { created_at_ms: _omit, ...wire } = payload;
const res = await fetch(`${base}/api/v1/mail/send`, {
method: 'POST',
headers: {
// 两种身份都支持:有 key 走 Bearer只有 secret 走 X-Agent-Name/Secret
...(identity.key
? { Authorization: `Bearer ${identity.key}` }
: { 'X-Agent-Name': identity.name, 'X-Agent-Secret': identity.secret }),
'Content-Type': 'application/json',
},
body: JSON.stringify(wire),
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);
}
/** 从 systemd 单元名推导 Agent 名:`zcode.service`→zcode、`pi-mail-bridge.service`→pi。 */
export function agentNameFromService(serviceName) {
const base = basename(String(serviceName || '').trim())
.replace(/\.service$/, '')
.trim();
if (!base) return '';
return base
.replace(/-mail-bridge$/, '')
.replace(/-bridge$/, '')
.replace(/-serve$/, '')
.replace(/-mail$/, '')
.trim();
}
/** 从 `/etc/agentmail/<agent>.env` 里读 `AGENTMAIL_AGENT_KEY`systemd EnvironmentFile 格式)。 */
export function readAgentSecretFromFile(file, varName = 'AGENTMAIL_AGENT_KEY') {
try {
const text = readFileSync(file, 'utf8');
const m = text.match(new RegExp(`^\\s*${varName}\\s*=\\s*(.*)$`, 'm'));
if (!m) return '';
return String(m[1]).trim().replace(/^["']|["']$/g, '');
} catch {
return '';
}
}
/**
* 解析这次上报/补投该用哪个 Agent 身份。
*
* 单元自己的 env 优先;缺失时按单元名推导、再从 `/etc/agentmail/<name>.env` 取密钥。
*
* 为什么需要这一步(实测故障):`zcode.service`(应用单元)崩溃时,它的 env 里
* 既没有 `AGENTMAIL_AGENT_NAME` 也没有 `AGENTMAIL_AGENT_KEY`,报告就落进了
* `unknown-agent/` 目录并一直待在那里 —— 而 **flush 只读自己那个 Agent 的目录**
* 于是 25 份 zcode 崩溃告警永久死信(它们在 2026-09-12 14:40 那轮崩溃循环里产生)。
* 「报不出去」本身还能看见,**报不出去还落进没人读的目录**才是真的丢。
*/
export function resolveIdentity(env = process.env, serviceName = '') {
const explicit = String(env.AGENTMAIL_AGENT_NAME || '').trim();
const derived = agentNameFromService(serviceName || env.AGENTMAIL_SERVICE_NAME || '');
const rawName = explicit || derived || 'unknown-agent';
const name = rawName.replace(/[^A-Za-z0-9_.@-]/g, '-').slice(0, 96);
let key = String(env.AGENTMAIL_AGENT_KEY || '').trim();
let secret = String(env.AGENTMAIL_AGENT_SECRET || '').trim();
let keyFrom = '';
if ((!key || !secret) && name !== 'unknown-agent') {
const file = join(String(env.AGENTMAIL_ENV_DIR || DEFAULT_ENV_DIR), `${name}.env`);
if (!key) key = readAgentSecretFromFile(file, 'AGENTMAIL_AGENT_KEY');
// zcode 这类单元只配了 secret 没有 key而网关的 AgentAuth 两种都认。
// 脚本原先只认 key ⇒ zcode.service 的告警永远发不出去(实测 25 封死信)。
if (!secret) secret = readAgentSecretFromFile(file, 'AGENTMAIL_AGENT_SECRET');
if (key || secret) keyFrom = file;
}
return { name, key, secret, keyFrom, derived: !explicit && !!derived };
}
function spoolDirFor(env, serviceName = '') {
const { name } = resolveIdentity(env, serviceName);
return join(String(env.AGENTMAIL_FAILURE_SPOOL || DEFAULT_SPOOL), name);
}
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, serviceName = '') {
// 每个 Agent 独立目录:否则 pi 启动时可能拿自己的密钥去补发 dsh 的报告,
// 发件身份与正文主体会错位relay_key 的幂等范围也变了。
//
// 目录名走 resolveIdentityenv → 单元名推导 → /etc/agentmail/<name>.env
// 否则没有 AGENTMAIL_AGENT_NAME 的单元zcode.service 就是)会把报告落进
// `unknown-agent/` —— 而 flush 只读自己那个目录,等于永久死信(实测 25 封)。
const spoolDir = spoolDirFor(env, serviceName);
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, options.serviceName || '');
return { sent: true, spooled: false, payload };
} catch (sendError) {
if (options.spool === false) throw sendError;
const path = await spoolPayload(payload, env, options.serviceName || '');
return { sent: false, spooled: true, path, payload, error: sendError };
}
}
/**
* 补投 spool 里的报告。
*
* 两个改动都是踩出来的:
* - **失败要说原因**。原先是 `catch { failed++ }`只报数不报因4 封 dsh 告警
* 在 spool 里躺了一整天,日志只有 `spool: sent=0 failed=N`,没人知道卡在哪。
* - **过时的报告要标注**。Sep-12 的崩溃告警 Sep-13 才补投成功,正文时间是昨天,
* 收信人只会以为服务刚刚又崩了。
*/
export async function flushSpool(env = process.env, serviceName = '') {
const spoolDir = spoolDirFor(env, serviceName);
const names = (await readdir(spoolDir).catch(() => []))
.filter((name) => name.endsWith('.json'))
.sort();
let sent = 0;
let failed = 0;
const errors = [];
for (const name of names) {
const path = join(spoolDir, name);
try {
const record = JSON.parse(await readFile(path, 'utf8'));
const payload = await markStaleIfNeeded(record.payload, path);
await postPayload(payload, env, serviceName);
await rm(path, { force: true });
sent++;
} catch (error) {
failed++;
errors.push({ file: name, reason: describeSendError(error) });
}
}
return { total: names.length, sent, failed, errors, spoolDir };
}
/**
* 补投一份**已经过时**的报告时,把这件事写在正文最前面。
*
* 不标的后果实测Sep-12 的崩溃告警在 Sep-13 才补投成功,正文里的时间是
* 昨天,而收信人打开邮箱只会以为服务刚刚又崩了 —— 误导性的告警比没有更糟。
*/
async function markStaleIfNeeded(payload, filePath) {
const createdMs =
Number(payload?.created_at_ms || 0) ||
(await stat(filePath).catch(() => null))?.mtimeMs ||
0;
if (!createdMs) return payload;
const age = Date.now() - createdMs;
if (age <= STALE_MS) return payload;
const minutes = Math.round(age / 60000);
const head = [
`> **补投**:这是 ${minutes} 分钟前的故障报告(当时网关不可达或上报被拒,已暂存)。`,
`> 它**不代表现在仍在故障**;服务下次启动时已触发补投。`,
'',
].join('\n');
return { ...payload, subject: `[补投] ${payload.subject}`, body: `${head}${payload.body}` };
}
/**
* 把发送失败翻译成一句能定位原因的话。
*
* `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');
/*
* `--flush-all`:把 spool 下**所有** Agent 目录都排空一次。
*
* 为什么要它:`--flush` 只读自己那个 Agent 的目录,于是两条路都堵死 ——
* ① 报告在 A 的目录里、而 A 一直没重启 → 隔一天才补投(实测 4 封);
* ② 报告落进 `unknown-agent/`(单元没给身份)→ **任何** flush 都不会读它
* (实测 25 份 zcode 崩溃告警永久死信)。
* 由定时器每 10 分钟跑一次这一条,上面两种情形都不再发生。
*
* 身份仍按目录逐个解析(目录名 = Agent 名 → /etc/agentmail/<name>.env 取密钥),
* 所以不会出现「pi 拿自己的密钥去发 dsh 的报告」那种错位。
*/
if (argv.includes('--flush-all')) {
const root = String(env.AGENTMAIL_FAILURE_SPOOL || DEFAULT_SPOOL);
const dirs = (await readdir(root).catch(() => [])).filter((n) => !n.startsWith('.'));
let sent = 0;
let failed = 0;
const errors = [];
for (const dir of dirs) {
const named = dir !== 'unknown-agent' && /^[A-Za-z0-9_.@-]+$/.test(dir);
const sub = { ...env, AGENTMAIL_AGENT_NAME: named ? dir : '', AGENTMAIL_AGENT_KEY: '' };
const result = await flushSpool(sub, named ? dir : '');
sent += result.sent;
failed += result.failed;
for (const e of result.errors) errors.push({ dir, ...e });
if (!named && result.total > 0) {
console.error(
`[agentmail-failure-notify] \u2605 ${dir}/ 有 ${result.total} 份报告但无法确定发件身份` +
`(缺 ${DEFAULT_ENV_DIR}/<agent>.env—— 它们永远发不出去,请修单位的 AGENTMAIL_AGENT_NAME`
);
}
}
if (sent || failed) {
console.error(`[agentmail-failure-notify] flush-all: sent=${sent} failed=${failed}`);
}
for (const e of errors.slice(0, 10)) {
console.error(`[agentmail-failure-notify] 补投失败 ${e.dir}/${String(e.file).slice(0, 12)}…:${e.reason}`);
}
return;
}
if (argv.includes('--flush')) {
const result = await flushSpool(env, serviceName);
if (result.sent || result.failed) {
console.error(`[agentmail-failure-notify] spool: sent=${result.sent} failed=${result.failed}`);
}
// 只报数不报因的话,"卡了一天"这件事在日志里就是一句沉默(实测过)
for (const e of result.errors) {
console.error(`[agentmail-failure-notify] 补投失败 ${String(e.file).slice(0, 12)}…:${e.reason}`);
}
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;
});
}