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:
2026-09-11 10:27:41 +08:00
parent f9d757b5e5
commit c401eb2da2
17 changed files with 1093 additions and 175 deletions

View File

@ -59,6 +59,7 @@ import {
renderThread,
} from '../lib/discovery.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
import { createSSEClient } from '../lib/sse-client.js';
// 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
import { isApproval } from '../lib/permission-grants.js';
@ -1051,49 +1052,25 @@ export function apply(ctx: any, config: PluginConfig): void {
failures.map(f => f.error).join(' | '));
}
// ─── SSE 监听(与 opencode-mail-bridge 相同的 fetch + reader 模式)───
// ─── SSE 监听 ───
//
// 共用客户端lib/sse-client.js跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。
// 之前手写的解析把 evt/data 当 read() 的局部变量,一帧被切在两个 chunk 就整帧
// 静默丢弃new_mail / permission_decision / session_update 都可能丢);也不带
// Last-Event-ID断线期间的事件回放不出来。
let sseAbort: AbortController | null = null;
let sseClient: { stop: () => void } | null = null;
function startSSE(onEvent: (type: string, data: any) => void) {
sseAbort?.abort();
sseAbort = new AbortController();
const reconnect = () => {
if (sseAbort?.signal.aborted) return;
fetch(`${GW}/api/v1/events/stream`, {
headers: client.authHeaders(),
signal: sseAbort?.signal ?? new AbortController().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();
sseClient?.stop?.();
sseClient = createSSEClient({
authHeaders: () => client.authHeaders(),
baseURL: GW.replace(/\/+$/, ''),
path: '/api/v1/events/stream',
onEvent,
log: (msg: string) => console.error(`[dsh-mail-bridge] ${msg}`),
});
return sseClient;
}
// ─── 注册模型工具 ───
@ -1498,7 +1475,11 @@ export function apply(ctx: any, config: PluginConfig): void {
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
'connect_to_server',
]) {
try { ctx.tools.unregister(n); } catch {}
try {
ctx.tools.unregister(n);
} catch {
// 拆插件时重复注销(已经卸载 / 未注册过)不影响 teardown忽略即可
}
}
};
}, 'dsh-mail-bridge.tools');
@ -1813,8 +1794,8 @@ export function apply(ctx: any, config: PluginConfig): void {
}
});
return () => {
sseAbort?.abort();
sseAbort = null;
sseClient?.stop?.();
sseClient = null;
// 拆插件时没人再能回答待决询问,一律 fail closed
// 否则 DSH 侧那些 await 永远不会返回。
for (const [key, pending] of pendingApprovals) {