fix(bridges): SSE 跨分片保帧 + Last-Event-ID;pi worker 有界重投;systemd 故障上报;清理误提交二进制
三个平台桥原本各自手写 SSE 解析,有两个共同的静默丢事件缺陷:
1. evt/data 是每次 read() 的局部变量 —— TCP 把一帧
'event: x\ndata: {...}\n\n' 切在换行处时,前半段的 event 名被丢掉、
后半段只剩 data,整帧静默丢弃。表现为「新邮件偶尔收不到」
「权限决策点了没反应」,日志里一个字都没有。
2. 重连不带 Last-Event-ID —— 断线期间的事件留在服务端 per-agent 环形
缓冲里永远回放不出来(pi 与 homeagent 已正确使用,DSH/opencode 没有)。
修法:抽出共用 lib/sse-client.js(三桥逐字节同源,check-shared-libs 校验),
把「跨 chunk 保帧状态」与「Last-Event-ID 断点续传」写对一次。pi 桥的
gateway.mjs 也改为复用同一实现(保留 reconfigure 时清断点的语义)。
pi worker 丢任务:worker 未回报 done 就退出(SIGKILL/OOM/崩溃)时,
主进程原来只记一行日志就 pump() —— 那封邮件永远没有回音。改为按
1s/2s 退避有界重投(默认 3 次),到上限记「放弃」并可观测。
systemd 故障上报:四个宿主服务接入 service-failure-notify.mjs 的
ExecStopPost/--report 与 ExecStartPost/--flush。进程内 uncaughtException
捕获不了 SIGKILL/OOM,只能由 systemd 统一覆盖。正常 stop/restart 不发信。
仓库卫生:server/server(24MB 构建产物,f9d757b 误提交)移出版本库。
测试:opencode 302 / dsh 335 / pi 391 全绿(新增 12 例 SSE 帧解析 +
2 例 worker 重投);Go 全量通过;四平台重启后在线且无错误。
This commit is contained in:
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* AgentMail Gateway 客户端 —— HTTP + SSE。
|
||||
*
|
||||
* 与另两个插件同构(同样的认证头、同样的手写 SSE 解析),区别只在这里是
|
||||
* 独立守护进程,所以密钥解析与 Last-Event-ID 的状态都归它自己管。
|
||||
* SSE 部分委托给共用模块 lib/sse-client.js(三桥逐字节同源),
|
||||
* 本文件只管认证头、密钥解析与坐标变更。
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
@ -10,6 +10,8 @@ import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createSSEClient } from '../lib/sse-client.js';
|
||||
|
||||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||||
export const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||
@ -84,10 +86,7 @@ export class GatewayClient {
|
||||
this.agentName = agentName;
|
||||
this.agentKey = agentKey || '';
|
||||
this.agentSecret = agentSecret || '';
|
||||
this.sseAbort = null;
|
||||
// SSE 重连时带上,首次连接**不带**(B-1.4 / N-11):
|
||||
// 带上会收到一批已处理过的旧事件,插件重启一次就把历史邮件重投一遍。
|
||||
this.lastEventID = '';
|
||||
this.sseClient = null;
|
||||
}
|
||||
|
||||
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
||||
@ -165,85 +164,23 @@ export class GatewayClient {
|
||||
/**
|
||||
* 建立 SSE 长连并自动重连。
|
||||
*
|
||||
* 手写解析而不用 EventSource:Node 内建的那个不支持自定义请求头,
|
||||
* 而认证头是必须的。协议这一小块(`id:` / `event:` / `data:` + 空行分隔)
|
||||
* 比引一个依赖划算。
|
||||
* 实现委托给共用模块 `lib/sse-client.js`(三桥逐字节同源,由
|
||||
* deploy/check-shared-libs.sh 校验)—— 那里把「跨 TCP 分片保帧状态」与
|
||||
* 「Last-Event-ID 断点续传」两件事写对了一次,不必每个平台各抄一遍。
|
||||
*
|
||||
* 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲,
|
||||
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
|
||||
* 首次连接**不带**(N-11):那会让服务端把缓冲区里的旧事件全回放一遍。
|
||||
*/
|
||||
startSSE(onEvent, log = console.error) {
|
||||
this.sseAbort?.abort();
|
||||
this.sseAbort = new AbortController();
|
||||
const signal = this.sseAbort.signal;
|
||||
|
||||
const reconnect = (delay) => {
|
||||
if (signal.aborted) return;
|
||||
setTimeout(() => this.#connect(onEvent, reconnect, log), delay);
|
||||
};
|
||||
this.#connect(onEvent, reconnect, log);
|
||||
}
|
||||
|
||||
#connect(onEvent, reconnect, log) {
|
||||
const signal = this.sseAbort?.signal;
|
||||
if (!signal || signal.aborted) return;
|
||||
|
||||
const headers = { ...this.authHeaders(), Accept: 'text/event-stream' };
|
||||
// 重连时带上断点(D-7.2)。**首次连接必须不带**(N-11):那会让服务端
|
||||
// 把缓冲区里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||
// 只有 lastEventID 非空(= 已经收过事件)时才是重连。
|
||||
if (this.lastEventID) {
|
||||
headers['Last-Event-ID'] = this.lastEventID;
|
||||
log(`SSE 重连,从事件 ${this.lastEventID} 之后续传`);
|
||||
}
|
||||
|
||||
fetch(`${this.baseURL}/api/v1/events/stream`, { headers, signal })
|
||||
.then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
log(`SSE 建连失败: HTTP ${res.status}`);
|
||||
return reconnect(5000);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let id = '';
|
||||
let evt = '';
|
||||
let data = '';
|
||||
|
||||
const read = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) return reconnect(3000);
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('id: ')) id = line.slice(4).trim();
|
||||
else if (line.startsWith('event: ')) evt = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) data = line.slice(6);
|
||||
else if (line === '' && evt) {
|
||||
// 事件 id 要在**分发之前**记下:分发里抛异常也不该让它丢,
|
||||
// 否则重连会从更早的位置回放,已处理的邮件再来一遍。
|
||||
if (id) this.lastEventID = id;
|
||||
try { onEvent(evt, JSON.parse(data)); } catch (e) {
|
||||
log(`SSE 事件处理失败: ${e?.message || e}`);
|
||||
}
|
||||
id = ''; evt = ''; data = '';
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch((e) => {
|
||||
if (signal.aborted) return;
|
||||
log(`SSE 读取中断: ${e?.message || e}`);
|
||||
reconnect(5000);
|
||||
});
|
||||
};
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal.aborted) return;
|
||||
log(`SSE 连接错误: ${e?.message || e}`);
|
||||
reconnect(5000);
|
||||
});
|
||||
this.sseClient?.stop?.();
|
||||
this.sseClient = createSSEClient({
|
||||
authHeaders: () => this.authHeaders(),
|
||||
baseURL: this.baseURL,
|
||||
path: '/api/v1/events/stream',
|
||||
onEvent,
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -262,14 +199,14 @@ export class GatewayClient {
|
||||
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
|
||||
if (!changed) return false;
|
||||
|
||||
if (nextURL !== this.baseURL) this.lastEventID = '';
|
||||
if (nextURL !== this.baseURL) this.sseClient?.reset?.();
|
||||
this.baseURL = nextURL;
|
||||
this.agentKey = nextKey;
|
||||
return true;
|
||||
}
|
||||
|
||||
stopSSE() {
|
||||
this.sseAbort?.abort();
|
||||
this.sseAbort = null;
|
||||
this.sseClient?.stop?.();
|
||||
this.sseClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user