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:
@ -38,6 +38,7 @@ import { autoRelayDecision, replyInstruction, inboundHeadline } from "./lib/rela
|
||||
import { clampRelayKey, isPermanentFailure } from "./lib/relay-key.js";
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from "./lib/bounded.js";
|
||||
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
|
||||
import { createSSEClient } from "./lib/sse-client.js";
|
||||
// opencode 原生支持三态权限,免批由它自己记(response:"always"),
|
||||
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
|
||||
import { isAlwaysDecision, isApproval } from "./lib/permission-grants.js";
|
||||
@ -540,48 +541,22 @@ const connectToServerTool = {
|
||||
};
|
||||
|
||||
// ─── SSE ───
|
||||
|
||||
let sseAbort = null;
|
||||
//
|
||||
// 共用客户端(lib/sse-client.js):跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。
|
||||
// 之前这里手写的解析把 evt/data 当 read() 的局部变量,一帧被切在两个 chunk
|
||||
// 就整帧静默丢弃;也不带 Last-Event-ID,断线期间的事件永远回放不出来。
|
||||
let sseClientRef = null;
|
||||
|
||||
function startSSE(onEvent) {
|
||||
if (sseAbort) sseAbort.abort();
|
||||
sseAbort = new AbortController();
|
||||
|
||||
const reconnect = () => {
|
||||
if (sseAbort?.signal.aborted) return;
|
||||
|
||||
fetch(`${GATEWAY_URL}/api/v1/events/stream`, {
|
||||
headers: authHeaders(),
|
||||
signal: sseAbort.signal,
|
||||
}).then((res) => {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
const read = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) { setTimeout(reconnect, 3000); return; }
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
let evt = "", data = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) evt = line.slice(7).trim();
|
||||
else if (line.startsWith("data: ")) data = line.slice(6);
|
||||
else if (line === "" && evt) {
|
||||
try { onEvent(evt, JSON.parse(data)); } catch {}
|
||||
evt = ""; data = "";
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch(() => setTimeout(reconnect, 5000));
|
||||
};
|
||||
read();
|
||||
}).catch(() => setTimeout(reconnect, 5000));
|
||||
};
|
||||
|
||||
reconnect();
|
||||
sseClientRef?.stop?.();
|
||||
sseClientRef = createSSEClient({
|
||||
authHeaders,
|
||||
baseURL: GATEWAY_URL.replace(/\/+$/, ""),
|
||||
path: "/api/v1/events/stream",
|
||||
onEvent,
|
||||
log: (msg) => console.error("[mail-bridge]", msg),
|
||||
});
|
||||
return sseClientRef;
|
||||
}
|
||||
|
||||
// ─── Plugin ───
|
||||
@ -1243,7 +1218,7 @@ export default async function mailBridge(input) {
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(heartbeat);
|
||||
if (sseAbort) sseAbort.abort();
|
||||
if (sseClientRef) sseClientRef.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user