@coclaw/openclaw-coclaw 0.22.3 → 0.22.4

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/index.js CHANGED
@@ -19,6 +19,7 @@ import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
19
19
  import { remoteLog } from './src/remote-log.js';
20
20
  import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
21
21
  import { registerModelDefaultHandlers } from './src/model-default/index.js';
22
+ import { getClawConfig } from './src/claw-config.js';
22
23
 
23
24
  import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
24
25
  export { getPluginVersion, __resetPluginVersion };
@@ -32,6 +33,15 @@ export function awaitPluginInit() {
32
33
  return __pluginInitDone;
33
34
  }
34
35
 
36
+ // abort-threw 远端日志节流:gateway 进程活动期间最多上报一次,避免上游 handle.abort
37
+ // 持续抛错时 500ms tick 重试把 remote-log 刷爆。logger.info 已对 abort-threw 静默
38
+ // (见 coclaw.agent.abort handler 注释),但完全失去诊断信号会让运维瞎子,故保留首条。
39
+ let __abortThrewReported = false;
40
+
41
+ export function __resetAbortThrewReported() {
42
+ __abortThrewReported = false;
43
+ }
44
+
35
45
  // 侧门注册表观测:patch OpenClaw embeddedRunState.activeRuns 的 set/delete,
36
46
  // 用于跟踪 sessionId 何时注册/注销(agent 取消流程实际读取的就是这张表)。
37
47
  // OpenClaw 侧门形状变化时(缺失 / 抛异常),通过 remoteLog 上报为升级契约变更的早期信号。
@@ -70,8 +80,9 @@ function patchMapLogging(map, label, logger) {
70
80
  }
71
81
  const origSet = map.set.bind(map);
72
82
  const origDel = map.delete.bind(map);
73
- // log 行包 try/catch 兜底:上游若把 Map 换成有 throwing getter(如 Proxy)的对象,
74
- // 不能让本插件的诊断 log 把 OpenClaw 内部 set/delete 流程带崩
83
+ // AGENTS.md §"日志器"一般规则是"调用点不要再包 try/catch",此处特例:safeLog/safeSize
84
+ // 是绑到 OpenClaw 内部 Map.set/delete 路径上的诊断仪器,上游若把 Map 换成 throwing-getter
85
+ // Proxy 或 logger 自身异常都不能把宿主的 set/delete 流程带崩,故保留 swallow 兜底
75
86
  const safeLog = (msg) => {
76
87
  try { logger?.info?.(msg); } catch { /* swallow — diag log 不得影响主流程 */ }
77
88
  };
@@ -692,8 +703,8 @@ const plugin = {
692
703
  const runDuration = typeof params?.runDuration === 'number' ? params.runDuration : undefined;
693
704
  const abortDuration = typeof params?.abortDuration === 'number' ? params.abortDuration : undefined;
694
705
  const result = decideCancelResponse(abortResult, { runDuration, abortDuration });
695
- // not-found UI 重试期常态(注册空窗),不打日志避免噪音;其余分支保留 info
696
- if (result.reason !== 'not-found') {
706
+ // not-found(注册空窗)与 abort-threw(上游 handle.abort 持续抛)都是 UI 500ms tick 重试期常态,跳过 info 日志避免洪水;其余分支保留 info
707
+ if (result.reason !== 'not-found' && result.reason !== 'abort-threw') {
697
708
  logger.info?.(`[coclaw.agent.abort] result sessionId=${sessionId} ok=${result.ok}${result.reason ? ` reason=${result.reason}` : ''}${result.error ? ` error=${result.error}` : ''}`);
698
709
  }
699
710
  if (result.ok) {
@@ -707,6 +718,12 @@ const plugin = {
707
718
  // 启发升格:双闸均达阈值,把 not-found 升格为 gone,让 UI 主动 settleByCancel
708
719
  remoteLog(`abort.gone sid=${sessionId} runDur=${runDuration} abortDur=${abortDuration}`);
709
720
  }
721
+ else if (result.reason === 'abort-threw' && !__abortThrewReported) {
722
+ // 一次性:上游 handle.abort 首次抛错时上报,让运维知道这个 gateway 实例发生过;
723
+ // 后续 tick 重试不再上报,避免被 UI 500ms 拍频刷爆远端日志
724
+ __abortThrewReported = true;
725
+ remoteLog(`abort.threw sid=${sessionId} error=${result.error || ''}`);
726
+ }
710
727
  respond(true, result);
711
728
  }
712
729
  catch (err) {
@@ -729,7 +746,7 @@ const plugin = {
729
746
 
730
747
  const fileHandler = createFileHandler({
731
748
  resolveWorkspace: (agentId) => {
732
- const cfg = api.runtime?.config?.loadConfig();
749
+ const cfg = getClawConfig();
733
750
  const dir = api.runtime?.agent?.resolveAgentWorkspaceDir(cfg, agentId);
734
751
  if (!dir) {
735
752
  const err = new Error('Cannot resolve workspace: runtime not available');
@@ -811,8 +828,13 @@ const plugin = {
811
828
 
812
829
  try {
813
830
  if (action === 'bind') {
831
+ // 先校验非空 code,避免无效输入触发 doBind 内的 cancelActiveEnroll 副作用
832
+ const code = positionals[0];
833
+ if (typeof code !== 'string' || code.length === 0) {
834
+ return { text: 'Error: binding code is required' };
835
+ }
814
836
  const result = await doBind({
815
- code: positionals[0],
837
+ code,
816
838
  serverUrl: options.server,
817
839
  });
818
840
  return { text: bindOk(result) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.22.3",
3
+ "version": "0.22.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
@@ -50,7 +50,7 @@
50
50
  "typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
51
51
  "check": "pnpm lint && pnpm typecheck",
52
52
  "test:plugin": "node --test src/plugin-mode.test.js",
53
- "test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'for f in src/**/*.test.js src/*.test.js index.test.js; do node --test \"$f\" || exit 1; done'",
53
+ "test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'node --test src/**/*.test.js src/*.test.js index.test.js'",
54
54
  "e2e": "node --test --test-concurrency=1 e2e/*.e2e.spec.js",
55
55
  "verify": "pnpm check && pnpm test",
56
56
  "link": "bash ./scripts/link.sh",
@@ -191,7 +191,11 @@ function loadInstallRecordFromLegacyConfig(pluginId) {
191
191
  const config = getClawConfig();
192
192
  return config?.plugins?.installs?.[pluginId] ?? null;
193
193
  }
194
- catch {
194
+ catch (err) {
195
+ // 与同函数其他 catch 风格对齐:旧版账本读取异常也外推诊断信号,
196
+ // 否则下游只能看到笼统的 "Skipping: not an npm-installed plugin",无定位线索
197
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
198
+ remoteLog(`upgrade.legacy-config-read-failed msg=${err?.message ?? String(err)}`);
195
199
  return null;
196
200
  }
197
201
  }
@@ -39,6 +39,8 @@ export function classifyChatHistorySessionKey(sessionKey) {
39
39
  // 由 cron 守卫挡住即可,避免与 IM per-account DM accountId="cron"/"subagent" 形态冲突
40
40
  // (accountId 仅按 [a-z0-9_-]{1,64} 校验,"cron"/"subagent" 都是合法账户名)。
41
41
  if (parts[2] === 'explicit') return { ok: false, reason: 'explicit' };
42
+ // agent:<id>:cron:<jobId>:subagent:<uuid> 形态命中 cron 守卫返回 reason='cron',
43
+ // 丢一层嵌套语义;上报信号目前无下游按 reason 细分计数/告警,留作识别到才说
42
44
  if (parts[2] === 'cron') return { ok: false, reason: 'cron' };
43
45
  if (parts[2] === 'subagent') return { ok: false, reason: 'subagent' };
44
46
  return { ok: true, reason: null };
@@ -193,6 +195,7 @@ export class ChatHistoryManager {
193
195
  for (let i = 1; i < list.length; i++) {
194
196
  const item = list[i];
195
197
  if (!item || typeof item !== 'object' || item.archivedAt) continue;
198
+ // 复用 archivedAt 写补登时间;事后与正常归档无法区分,线下分析靠下方 chat-history.sanitize-coerce 上报信号
196
199
  item.archivedAt = now;
197
200
  this.__logger.warn?.(
198
201
  `[coclaw] chat-history sanitize: non-head unarchived entry coerced sessionKey=${sessionKey} sid=${item.sessionId}`,
@@ -352,6 +355,8 @@ export class ChatHistoryManager {
352
355
  } catch (err) {
353
356
  this.__reportLoadError(filePath, err, '__reloadFromDisk');
354
357
  }
358
+ // 读盘失败 + cache 已热身 → 保留旧 cache(best-effort 容忍)。下次写盘会把旧数据回灌磁盘自愈;
359
+ // 但若长期无新写入,磁盘上的损坏内容不会被主动修复
355
360
  if (!this.__cache.has(agentId)) {
356
361
  this.__cache.set(agentId, emptyStore());
357
362
  }
@@ -643,6 +643,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
643
643
  let wsBackpressureCount = 0;
644
644
  let wsError = false;
645
645
  let finishing = false;
646
+ // 一次性 cleanup attach 守卫:dc.onerror 后 pion 通常紧跟 dc.onclose,
647
+ // 两条 cleanup 路径若都向 ws 挂 'close' 监听会重复 safeUnlink。
648
+ // 用 flag 而不是 wsError 守卫,是为了不破坏 SIZE_EXCEEDED 等"先同步 unlink、
649
+ // 再触发 dc.close → dc.onclose"路径:那条路径同样设 wsError=true 但**没**
650
+ // attach 过,仍需 dc.onclose 兜底 attach(fopen 完成后才删到真文件)
651
+ let cleanupAttached = false;
652
+ function attachTmpCleanupOnce() {
653
+ if (cleanupAttached) return;
654
+ cleanupAttached = true;
655
+ ws.on('close', () => safeUnlink(tmpPath));
656
+ }
646
657
 
647
658
  // --- 受控写入:中间缓冲 + drain 循环 ---
648
659
  const pendingQueue = [];
@@ -672,9 +683,11 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
672
683
  draining = false;
673
684
  pendingQueue.length = 0;
674
685
  log.warn?.(`[coclaw/file] drainLoop write error: ${err.message}`);
686
+ // 走安全网而非同步 safeUnlink:fopen 未完成时 sync unlink 扑空被吞,
687
+ // fopen 后留孤儿。attach 在 destroy 之前确保 'close' emit 时 listener 已就位
688
+ attachTmpCleanupOnce();
675
689
  ws.destroy();
676
690
  if (!dcClosed) sendError(dc, 'WRITE_FAILED', err.message);
677
- safeUnlink(tmpPath);
678
691
  const elapsed = Date.now() - startTime;
679
692
  remoteLog(`file.up.fail ${logTag}id=${transferId} reason=drain-write-error err=${err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
680
693
  return;
@@ -692,8 +705,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
692
705
  function finishUpload() {
693
706
  if (finishing) return;
694
707
  finishing = true;
695
- ws.end(async () => {
708
+ ws.end(async (err) => {
696
709
  const elapsed = Date.now() - startTime;
710
+ // ws 在 'finish' 之前被 destroy 时,Node Writable 会用 err 调 pending end cb
711
+ //(destroy(e) 用 e;destroy() 用 ERR_STREAM_DESTROYED)。任何 ws 中途夭折的
712
+ // 产线路径都已经在 ws.on('error') / dc.onclose / dc.onerror / drainLoop catch
713
+ // 里 attach 过清理 + 打过更具体的 fail log;这里只需兜底 attach 一次(idempotent)
714
+ // 防御未来路径漏挂,并跳过 rename / size 校验 / 成功响应
715
+ if (err) {
716
+ attachTmpCleanupOnce();
717
+ return;
718
+ }
697
719
  if (dcClosed) {
698
720
  safeUnlink(tmpPath);
699
721
  remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
@@ -795,15 +817,20 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
795
817
  if (doneReceived) {
796
818
  // done 已收到但 drain 未完成 — finishUpload 中会检测 dcClosed 并清理 tmp
797
819
  if (!finishing) finishUpload();
798
- } else {
799
- // 等 ws 关闭后再 unlink——fopen 未完成时直接 safeUnlink 会扑空被吞,
800
- // 随后 fopen 完成创建文件却没人清,留下孤儿 tmp
801
- ws.on('close', () => safeUnlink(tmpPath));
802
- ws.destroy();
803
- const elapsed = Date.now() - startTime;
804
- remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
805
- log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
820
+ return;
806
821
  }
822
+ // 等 ws 关闭后再 unlink——fopen 未完成时直接 safeUnlink 会扑空被吞,
823
+ // 随后 fopen 完成创建文件却没人清,留下孤儿 tmp。flag 守卫保证 dc.onerror
824
+ // 已 attach 过时不会重挂第二个 listener
825
+ attachTmpCleanupOnce();
826
+ ws.destroy();
827
+ // dc.onerror 已打过 reason=dc-error fail 日志 → 跳过 reason=dc-closed
828
+ // 避免双触发时 fail 日志重复(SIZE_EXCEEDED 路径打的是 reason=size-exceeded
829
+ // 的 reject 日志,不在此 fail 命名空间,不受影响)
830
+ if (wsError) return;
831
+ const elapsed = Date.now() - startTime;
832
+ remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
833
+ log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
807
834
  };
808
835
 
809
836
  // pion 异步 send 错误经此回调上报;触发已有清理路径
@@ -813,7 +840,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
813
840
  draining = false;
814
841
  pendingQueue.length = 0;
815
842
  // 同上:等 ws 关闭后再 unlink,避开 fopen-vs-unlink race
816
- ws.on('close', () => safeUnlink(tmpPath));
843
+ attachTmpCleanupOnce();
817
844
  ws.destroy();
818
845
  const elapsed = Date.now() - startTime;
819
846
  /* c8 ignore next -- ?? fallback for non-Error throw */
@@ -832,11 +859,16 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
832
859
  draining = false;
833
860
  pendingQueue.length = 0;
834
861
  log.warn?.(`[coclaw/file] write stream error: ${err.message}`);
862
+ // 安全网先装:sendError 会同步 dc.close → dc.onclose,doneReceived=true 且
863
+ // !finishing 时会同步重入 finishUpload → ws.end,整条 sync 链全跑完才回到这里。
864
+ // 与 drainLoop catch 顺序对齐。显式 ws.destroy() 不依赖 Node autoDestroy
865
+ // (注入的非标准 stream 可能不触发)触发 ws 关闭流程;listener 在 'close' emit 时 fire
866
+ attachTmpCleanupOnce();
867
+ ws.destroy();
835
868
  if (!dcClosed) {
836
869
  const code = err.code === 'ENOSPC' ? 'DISK_FULL' : 'WRITE_FAILED';
837
870
  sendError(dc, code, err.message);
838
871
  }
839
- safeUnlink(tmpPath);
840
872
  const elapsed = Date.now() - startTime;
841
873
  remoteLog(`file.up.fail ${logTag}id=${transferId} reason=write-error err=${err.code || err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
842
874
  log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=write-error received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms err=${err.code || err.message}`);
@@ -904,7 +936,13 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
904
936
  }
905
937
 
906
938
  function safeUnlink(filePath) {
907
- _unlink(filePath).catch(() => {});
939
+ // ENOENT 静默:tmp 可能尚未创建(fopen 未完成)或已被另一路径删过,均属预期。
940
+ // 其余错(EACCES / EBUSY / EIO)若完全无声会让孤儿 tmp 悄悄积累——warn 一下,
941
+ // 仍保留 fire-and-forget 语义;scheduleTmpCleanup 仍是最终兜底
942
+ _unlink(filePath).catch((err) => {
943
+ if (err?.code === 'ENOENT') return;
944
+ log.warn?.(`[coclaw/file] safeUnlink failed: ${filePath} ${err?.code || err?.message}`);
945
+ });
908
946
  }
909
947
 
910
948
  return {
@@ -105,11 +105,13 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
105
105
  respondInvalid(respond, 'primary is required');
106
106
  return;
107
107
  }
108
- const primary = params.primary;
109
- if (primary !== null && typeof primary !== 'string') {
108
+ const rawPrimary = params.primary;
109
+ if (rawPrimary !== null && typeof rawPrimary !== 'string') {
110
110
  respondInvalid(respond, 'primary must be a string or null');
111
111
  return;
112
112
  }
113
+ // 复制粘贴常带前后空白;trim 后再做形态/凭据/catalog 校验,并按 trim 后的值落盘
114
+ const primary = rawPrimary === null ? null : rawPrimary.trim();
113
115
  if (primary !== null && primary.length === 0) {
114
116
  respondInvalid(respond, 'primary must be a non-empty string or null');
115
117
  return;
@@ -112,6 +112,7 @@ export function defaultResolveGatewayAuthToken() {
112
112
  }
113
113
  }
114
114
  catch (err) {
115
+ // register 早期 / runtime 未注入完成时也可能被触达,注入 logger 此时未必可用,用 console.warn 兜底
115
116
  console.warn?.(`[coclaw] resolve gateway auth token failed: ${String(err?.message ?? err)}`);
116
117
  }
117
118
  const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
@@ -584,12 +585,11 @@ export class RealtimeBridge {
584
585
  const hostName = getHostName();
585
586
  const pluginVersion = await getPluginVersion();
586
587
  const agentModels = await this.__collectAgentModels();
587
- broadcastPluginEvent('coclaw.info.updated', {
588
- name,
589
- hostName,
590
- pluginVersion,
591
- agentModels,
592
- });
588
+ // 采集失败时漏报 agentModels(而非显式 null):server / UI 走 patch 语义
589
+ // 保留旧值,避免 admin 仪表盘瞬时清空(OpenClaw manifest cache 偶发卡顿时易触发)
590
+ const payload = { name, hostName, pluginVersion };
591
+ if (agentModels !== null) payload.agentModels = agentModels;
592
+ broadcastPluginEvent('coclaw.info.updated', payload);
593
593
  }
594
594
  catch (err) {
595
595
  /* c8 ignore next 2 -- 防御性兜底 */
@@ -603,7 +603,9 @@ export class RealtimeBridge {
603
603
  */
604
604
  async __collectAgentModels() {
605
605
  try {
606
- const result = await this.__gatewayRpc('agents.list', {}, { timeoutMs: 3000 });
606
+ // OpenClaw agents.list manifest cache 加载链路,偶发 cache mismatch 时 ~10s 才回
607
+ // (issue #80697);timeout 给到 30s 让本地 cache miss 有恢复窗口
608
+ const result = await this.__gatewayRpc('agents.list', {}, { timeoutMs: 30000 });
607
609
  if (result?.ok !== true) return null;
608
610
  const agents = result?.response?.payload?.agents;
609
611
  if (!Array.isArray(agents)) return null;
@@ -1128,9 +1130,11 @@ export class RealtimeBridge {
1128
1130
  this.logger.warn?.(
1129
1131
  `[coclaw] dc gateway req invalid: id=${typeof payload?.id} method=${typeof payload?.method}`,
1130
1132
  );
1131
- // 有合法 id 时回 INVALID_REQUEST 让发起方尽快放弃等待;id 不合法时只能 drop
1133
+ // 有合法 id 时回 INVALID_REQUEST 让发起方尽快放弃等待;id 不合法时只能 drop
1134
+ // 单播:connId 来自每条连接的闭包必非空,只发给发出乱码请求的连接,
1135
+ // 不打扰其他客户端(旁边 OFFLINE / SEND_FAILED 分支属系统级公告保留广播语义)
1132
1136
  if (hasValidId) {
1133
- this.webrtcPeer?.broadcast({
1137
+ await this.webrtcPeer?.sendTo(connId, {
1134
1138
  type: 'res',
1135
1139
  id: payload.id,
1136
1140
  ok: false,
@@ -1604,6 +1608,8 @@ export class RealtimeBridge {
1604
1608
  });
1605
1609
  this.__runEventRoutes.init();
1606
1610
  // 外线(plugin↔远端 server)先发起 connectIfNeeded:仅创建 WebSocket 即返回,不阻塞内线。
1611
+ // 不包 try/catch:__connectIfNeeded → getBindingsPath → pluginDir 链路依赖 runtime 已注入,
1612
+ // register full 模式下 setRuntime 必然先于 service.start 完成,pluginDir 不会抛
1607
1613
  await this.__connectIfNeeded();
1608
1614
  // 三条线各自独立启动:内线(plugin↔本机 gateway)由 start() 主动触发,
1609
1615
  // 不再依附于外线 open。即便外线建连失败/未配置 token,内线仍能起来支撑 DC RPC。
@@ -229,6 +229,7 @@ export class TopicManager {
229
229
  const srcPath = nodePath.join(this.__sessionsDir(agentId), `${topicId}.jsonl`);
230
230
  const tempId = randomUUID();
231
231
  const tempPath = nodePath.join(this.__sessionsDir(agentId), `${tempId}.jsonl`);
232
+ // fs.copyFile 非 atomic;崩在半路的临时 *.jsonl 可能被 session-manager 扫到列为幽灵会话,用户重试导出即恢复
232
233
  await this.__copyFile(srcPath, tempPath);
233
234
  return { tempId, tempPath };
234
235
  }
@@ -282,8 +282,11 @@ class MemoryQueue {
282
282
  return this.tag ? ` ${this.tag}` : '';
283
283
  }
284
284
 
285
+ // AGENTS.md §"日志器" 一般规则是"调用点不要再包 try/catch",此处特例:
286
+ // __safeWarn 是 enqueue 同步入口的兜底,enqueue 对外承诺不抛业务无关错误,
287
+ // 哪怕 logger 自身坏了也不能传染到 RPC 数据通路,故保留 swallow
285
288
  __safeWarn(msg) {
286
- try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 enqueue 抛 */ }
289
+ try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* swallow */ }
287
290
  }
288
291
  }
289
292
 
@@ -60,29 +60,6 @@ export function buildChunks(jsonStr, maxMessageSize, getNextMsgId) {
60
60
  return chunks;
61
61
  }
62
62
 
63
- /**
64
- * 按需分片并发送消息(薄包装:buildChunks + dc.send)
65
- * 注意:无应用层流控;生产路径请使用 MemoryQueue + RpcDcSender
66
- * @param {object} dc - DataChannel
67
- * @param {string} jsonStr - 已序列化的 JSON 字符串
68
- * @param {number} maxMessageSize - 对端声明的 maxMessageSize
69
- * @param {() => number} getNextMsgId - 获取下一个 msgId
70
- * @param {object} [logger] - 可选 logger
71
- */
72
- export function chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger) {
73
- const chunks = buildChunks(jsonStr, maxMessageSize, getNextMsgId);
74
- if (!chunks) {
75
- dc.send(jsonStr);
76
- return;
77
- }
78
- const msgId = chunks[0].readUInt32BE(1);
79
- const totalBytes = chunks.reduce((n, c) => n + (c.length - HEADER_SIZE), 0);
80
- logger?.info?.(`[dc-chunking] chunking msgId=${msgId}: ${totalBytes} bytes → ${chunks.length} chunks (maxMsgSize=${maxMessageSize})`);
81
- for (const chunk of chunks) {
82
- dc.send(chunk);
83
- }
84
- }
85
-
86
63
  /**
87
64
  * 创建分片重组器
88
65
  * @param {(jsonStr: string) => void} onComplete - 完整消息回调
@@ -160,8 +160,11 @@ class RpcDcSender {
160
160
  return this.tag ? ` ${this.tag}` : '';
161
161
  }
162
162
 
163
+ // AGENTS.md §"日志器" 一般规则是"调用点不要再包 try/catch",此处特例:
164
+ // __safeWarn 在 send/BAL 热路径里调,logger 异常不能升级为协议错(caller 只
165
+ // 等 RPC 应答,不该收到 logger 自身的 stack),故保留 swallow
163
166
  __safeWarn(msg) {
164
- try { this.logger.warn?.(`[rpc-dc-sender${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了不能让 send 抛非协议错 */ }
167
+ try { this.logger.warn?.(`[rpc-dc-sender${this.__tagSuffix()}] ${msg}`); } catch { /* swallow */ }
165
168
  }
166
169
 
167
170
  __safeRemoteLog(text) {