@vrs-soft/wecom-aibot-mcp 3.4.2 → 3.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-server.js +110 -0
- package/package.json +1 -1
package/dist/channel-server.js
CHANGED
|
@@ -208,6 +208,9 @@ let sseConnected = false;
|
|
|
208
208
|
let sseAbortController = null;
|
|
209
209
|
let mcpServer = null;
|
|
210
210
|
let sseCurrentCcId = undefined;
|
|
211
|
+
// v3.4.3: 暴露给 verify_sse_health 工具,判断"是否假在线"
|
|
212
|
+
let sseLastChunkAt = 0;
|
|
213
|
+
let sseWatchdogActive = false;
|
|
211
214
|
// 保存首次 enter_headless_mode 的参数,重连时原样复用
|
|
212
215
|
let sseRobotId = undefined;
|
|
213
216
|
let sseProjectDir = undefined;
|
|
@@ -361,6 +364,7 @@ function connectSSE(ccId) {
|
|
|
361
364
|
clearInterval(watchdogTimer);
|
|
362
365
|
watchdogTimer = null;
|
|
363
366
|
}
|
|
367
|
+
sseWatchdogActive = false; // v3.4.3
|
|
364
368
|
};
|
|
365
369
|
// SSE fetch 配置:添加 keep-alive headers 确保连接稳定
|
|
366
370
|
fetch(sseUrl, {
|
|
@@ -407,6 +411,7 @@ function connectSSE(ccId) {
|
|
|
407
411
|
let buffer = '';
|
|
408
412
|
let messageCount = 0;
|
|
409
413
|
let lastChunkAt = Date.now();
|
|
414
|
+
sseLastChunkAt = lastChunkAt; // v3.4.3: 暴露到模块作用域
|
|
410
415
|
let currentEvent = 'message'; // SSE event type,由 `event: xxx` 行设置;空行复位
|
|
411
416
|
// Watchdog:>45s 没收到任何 chunk(含 daemon 端的 `: heartbeat` 注释)
|
|
412
417
|
// 视为单向 TCP 死链,主动 abort 让 catch 分支触发 reconnect。
|
|
@@ -421,11 +426,13 @@ function connectSSE(ccId) {
|
|
|
421
426
|
catch { /* ignore */ }
|
|
422
427
|
}
|
|
423
428
|
}, 15000);
|
|
429
|
+
sseWatchdogActive = true; // v3.4.3
|
|
424
430
|
while (true) {
|
|
425
431
|
const { done, value } = await reader.read();
|
|
426
432
|
if (done) {
|
|
427
433
|
logChannel('SSE stream ended');
|
|
428
434
|
clearWatchdog();
|
|
435
|
+
sseWatchdogActive = false; // v3.4.3
|
|
429
436
|
sseConnected = false;
|
|
430
437
|
// 非主动断开时自动重连
|
|
431
438
|
if (!sseAbortController?.signal.aborted) {
|
|
@@ -435,6 +442,7 @@ function connectSSE(ccId) {
|
|
|
435
442
|
break;
|
|
436
443
|
}
|
|
437
444
|
lastChunkAt = Date.now();
|
|
445
|
+
sseLastChunkAt = lastChunkAt; // v3.4.3
|
|
438
446
|
const chunk = decoder.decode(value, { stream: true });
|
|
439
447
|
logChannel('SSE chunk received', { bytes: chunk.length, preview: chunk.slice(0, 100) });
|
|
440
448
|
buffer += chunk;
|
|
@@ -609,6 +617,108 @@ function registerChannelTools(server) {
|
|
|
609
617
|
return forwardToHttpMcp('check_connection', {});
|
|
610
618
|
});
|
|
611
619
|
// ============================================
|
|
620
|
+
// 工具 4b: verify_sse_health(v3.4.3)—— 比对本地 SSE 状态 vs daemon 注册表
|
|
621
|
+
// 用于 "CC 自己以为还连着但 daemon 已经看不到" 的 split-brain 诊断
|
|
622
|
+
// ============================================
|
|
623
|
+
server.tool('verify_sse_health', '比对本地 channel-server 的 SSE 状态和远端 daemon 注册表,诊断"假在线/假离线"问题。auto_fix=true 时检测到 split-brain 自动 abort 触发重连。', {
|
|
624
|
+
cc_id: z.string().describe('要检查的 CC 标识(必填,多 CC 部署下唯一确认目标)'),
|
|
625
|
+
auto_fix: z.boolean().optional().default(false).describe('为 true 时若发现 stale_local 自动 abort SSE 触发重连'),
|
|
626
|
+
}, async ({ cc_id, auto_fix }) => {
|
|
627
|
+
const now = Date.now();
|
|
628
|
+
const localIdleMs = sseLastChunkAt ? now - sseLastChunkAt : -1;
|
|
629
|
+
const localView = {
|
|
630
|
+
sseConnected,
|
|
631
|
+
idleMs: localIdleMs,
|
|
632
|
+
watchdogActive: sseWatchdogActive,
|
|
633
|
+
ccId: sseCurrentCcId || null,
|
|
634
|
+
};
|
|
635
|
+
// 调 daemon list_active_ccs 看注册表里有没有 ccId(/state 没暴露 ccIdRegistry,
|
|
636
|
+
// 但 list_active_ccs 走 MCP 转发,返回结构含 lastOnline)
|
|
637
|
+
let remoteView = { reachable: false, error: 'unknown' };
|
|
638
|
+
try {
|
|
639
|
+
const listRes = await forwardToHttpMcp('list_active_ccs', { cc_id });
|
|
640
|
+
const text = listRes?.content?.[0]?.text;
|
|
641
|
+
const data = text ? JSON.parse(text) : {};
|
|
642
|
+
// list_active_ccs.self 是 caller 自己,data.ccs 是其他在线 CC
|
|
643
|
+
// 检查 cc_id 是不是 self(caller 在 registry 自然没问题,复用本地心跳新鲜度)
|
|
644
|
+
// 或者在 ccs 列表里
|
|
645
|
+
const isSelf = data.self === cc_id;
|
|
646
|
+
const otherEntry = (data.ccs || []).find((e) => e?.ccId === cc_id);
|
|
647
|
+
if (isSelf) {
|
|
648
|
+
remoteView = {
|
|
649
|
+
reachable: true,
|
|
650
|
+
inRegistry: true,
|
|
651
|
+
note: 'cc_id is self — daemon 注册表里一定有,参考 local.idleMs 判断新鲜度',
|
|
652
|
+
lastOnlineMs: localIdleMs,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
else if (otherEntry) {
|
|
656
|
+
remoteView = {
|
|
657
|
+
reachable: true,
|
|
658
|
+
inRegistry: true,
|
|
659
|
+
lastOnlineMs: otherEntry.lastOnline ? now - otherEntry.lastOnline : -1,
|
|
660
|
+
robotName: otherEntry.robotName,
|
|
661
|
+
mode: otherEntry.mode,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
remoteView = {
|
|
666
|
+
reachable: true,
|
|
667
|
+
inRegistry: false,
|
|
668
|
+
knownCcs: [data.self, ...(data.ccs || []).map((e) => e?.ccId)].filter(Boolean),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
remoteView = { reachable: false, error: e?.message || String(e) };
|
|
674
|
+
}
|
|
675
|
+
// verdict
|
|
676
|
+
let verdict;
|
|
677
|
+
let message;
|
|
678
|
+
if (!remoteView.reachable) {
|
|
679
|
+
verdict = 'remote_unreachable';
|
|
680
|
+
message = `daemon 不可达:${remoteView.error}。channel-server 本地 sseConnected=${sseConnected},但无法验证 daemon 视角。`;
|
|
681
|
+
}
|
|
682
|
+
else if (!localView.ccId) {
|
|
683
|
+
verdict = 'never_entered';
|
|
684
|
+
message = 'channel-server 这个进程从未调过 enter_headless_mode,没建 SSE。让 LLM 跑一次 enter_headless_mode 即可。';
|
|
685
|
+
}
|
|
686
|
+
else if (!remoteView.inRegistry) {
|
|
687
|
+
verdict = 'stale_remote';
|
|
688
|
+
message = `daemon 注册表里看不到 ${cc_id}(被 stale-clean 掉了,或从未注册)。需要重新调 enter_headless_mode 让 daemon 重新认你。`;
|
|
689
|
+
}
|
|
690
|
+
else if (!sseConnected || (localIdleMs > 0 && localIdleMs > 60000)) {
|
|
691
|
+
verdict = 'stale_local';
|
|
692
|
+
message = `channel-server 本地 SSE 死了(connected=${sseConnected}, idleMs=${localIdleMs})但 daemon 还以为你在线。watchdog 应该 45s 触发但显然没。需要重连。`;
|
|
693
|
+
}
|
|
694
|
+
else if (remoteView.lastOnlineMs > 60000) {
|
|
695
|
+
verdict = 'split_brain';
|
|
696
|
+
message = `本地心跳活(idleMs=${localIdleMs}ms)但 daemon 上 lastOnline 是 ${Math.round(remoteView.lastOnlineMs / 1000)}s 前——路由可能有 bug,但你的 SSE 应该还在工作。`;
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
verdict = 'ok';
|
|
700
|
+
message = `健康:本地 idleMs=${localIdleMs}ms,daemon 上 lastOnline=${Math.round(remoteView.lastOnlineMs / 1000)}s 前。`;
|
|
701
|
+
}
|
|
702
|
+
let autoFixed = false;
|
|
703
|
+
if (auto_fix && (verdict === 'stale_local')) {
|
|
704
|
+
try {
|
|
705
|
+
logger.info('verify_sse_health auto_fix: aborting SSE to trigger reconnect', { ccId: cc_id });
|
|
706
|
+
sseAbortController?.abort();
|
|
707
|
+
autoFixed = true;
|
|
708
|
+
message += ' [auto_fix] 已触发 SSE abort,约 3 秒后 channel-server 自动重连。';
|
|
709
|
+
}
|
|
710
|
+
catch (e) {
|
|
711
|
+
message += ` [auto_fix 失败:${String(e)}]`;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
content: [{
|
|
716
|
+
type: 'text',
|
|
717
|
+
text: JSON.stringify({ local: localView, remote: remoteView, verdict, message, autoFixed }, null, 2),
|
|
718
|
+
}],
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
// ============================================
|
|
612
722
|
// 工具 4a: CC 间通信 — send_to_cc / list_active_ccs(v2.6.0+)
|
|
613
723
|
// ============================================
|
|
614
724
|
server.tool('send_to_cc', '向同一 daemon 上的另一个 CC 发送消息。目标 CC 收到时会作为 <channel source="cc:..."> 推送唤醒。仅支持同 daemon 间互通。支持 attachments 内联小文档(每个 < 16 KB);大文档请改用 upload_document。', {
|