@coclaw/openclaw-coclaw 0.20.0 → 0.20.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -1,13 +1,18 @@
1
1
  import fs from 'node:fs/promises';
2
+ import nodeFs from 'node:fs';
2
3
  import nodePath from 'node:path';
3
4
 
4
5
  import { checkForUpdate } from './updater-check.js';
5
6
  import { spawnUpgradeWorker } from './updater-spawn.js';
6
7
  import { readState, resolveStateDir, writeState } from './state.js';
7
- import { getRuntime } from '../runtime.js';
8
+ import { getClawConfig } from '../claw-config.js';
8
9
  import { remoteLog } from '../remote-log.js';
9
10
  import { atomicWriteFile } from '../utils/atomic-write.js';
10
11
 
12
+ // OpenClaw ≥ 2026.4.25 起把插件安装记录从 openclaw.json 的 plugins.installs
13
+ // 迁移到独立账本文件,并在 loadConfig() 返回前剥掉 plugins.installs。
14
+ const INSTALLS_LEDGER_RELATIVE_PATH = nodePath.join('plugins', 'installs.json');
15
+
11
16
  // 首次检查延迟较长:失败时由 worker 触发 gateway restart,scheduler 重启后会重新计时;
12
17
  // 60 分钟基线(实际随机 60-120 分钟)能把"失败→重启→再次检查"的循环周期拉长,
13
18
  // 避免连续升级失败时 gateway 在短时间内反复被打扰。
@@ -109,6 +114,80 @@ export async function writeUpgradeLock(pid) {
109
114
  );
110
115
  }
111
116
 
117
+ /**
118
+ * 读取本插件的安装记录(兼容新旧 OpenClaw 契约)
119
+ *
120
+ * - 新版(OpenClaw ≥ 2026.4.25):账本文件 `<state-dir>/plugins/installs.json`
121
+ * 下的 `installRecords[pluginId]` 是来源真相;`loadConfig()` 返回的对象里
122
+ * `plugins.installs` 已被剥离。
123
+ * - 旧版(OpenClaw ≤ 2026.4.24):账本文件不存在,
124
+ * `loadConfig().plugins.installs[pluginId]` 是来源真相。
125
+ *
126
+ * 兼容策略:先尝试账本文件;ENOENT(文件不存在)→ 回落到旧字段;
127
+ * 其它失败(权限/JSON 损坏/缺记录)→ 视为账本不可用,按"无来源信息"处理,不回落。
128
+ * 这两条互斥(新 gateway 必有账本、旧 gateway 必无)能让两个分支天然分流。
129
+ *
130
+ * 失败路径会通过 `remoteLog` 外推诊断信号(`upgrade.state-dir-failed` /
131
+ * `upgrade.ledger-read-failed` / `upgrade.ledger-parse-failed`),避免运维只
132
+ * 看到 start() 那条 "Skipping: not an npm-installed plugin" 时误判方向。
133
+ *
134
+ * @param {string} pluginId
135
+ * @returns {object|null}
136
+ */
137
+ function loadInstallRecord(pluginId) {
138
+ let ledgerPath;
139
+ try {
140
+ ledgerPath = nodePath.join(resolveStateDir(), INSTALLS_LEDGER_RELATIVE_PATH);
141
+ }
142
+ catch (err) {
143
+ // 极少触发:host runtime 的 state resolver 自身异常
144
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
145
+ remoteLog(`upgrade.state-dir-failed msg=${err?.message ?? String(err)}`);
146
+ return null;
147
+ }
148
+ let raw;
149
+ try {
150
+ raw = nodeFs.readFileSync(ledgerPath, 'utf8');
151
+ }
152
+ catch (err) {
153
+ if (err?.code === 'ENOENT') {
154
+ return loadInstallRecordFromLegacyConfig(pluginId);
155
+ }
156
+ // 账本应该可读但读不到(权限/EISDIR/IO 错误):不回落到旧字段,避免误判老路径
157
+ // 静默返回 null 会让 start() 打 "Skipping: not an npm-installed plugin",对运维毫无指向;
158
+ // 把诊断信号外推到 server,便于定位
159
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
160
+ remoteLog(`upgrade.ledger-read-failed code=${err?.code ?? 'unknown'} msg=${err?.message ?? String(err)}`);
161
+ return null;
162
+ }
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(raw);
166
+ }
167
+ catch (err) {
168
+ // 账本损坏:同样不回落,并外推诊断信号
169
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
170
+ remoteLog(`upgrade.ledger-parse-failed msg=${err?.message ?? String(err)}`);
171
+ return null;
172
+ }
173
+ return parsed?.installRecords?.[pluginId] ?? null;
174
+ }
175
+
176
+ /**
177
+ * 旧版 OpenClaw(≤ 2026.4.24)账本路径:openclaw.json 的 plugins.installs。
178
+ * @param {string} pluginId
179
+ * @returns {object|null}
180
+ */
181
+ function loadInstallRecordFromLegacyConfig(pluginId) {
182
+ try {
183
+ const config = getClawConfig();
184
+ return config?.plugins?.installs?.[pluginId] ?? null;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+
112
191
  /**
113
192
  * 判断是否应跳过自动升级
114
193
  *
@@ -122,16 +201,7 @@ export async function writeUpgradeLock(pid) {
122
201
  * @returns {boolean} true 表示应跳过自动升级
123
202
  */
124
203
  export function shouldSkipAutoUpgrade(pluginId) {
125
- const rt = getRuntime();
126
- if (!rt?.config?.loadConfig) return true;
127
- try {
128
- const config = rt.config.loadConfig();
129
- const installInfo = config?.plugins?.installs?.[pluginId];
130
- return installInfo?.source !== 'npm';
131
- }
132
- catch {
133
- return true;
134
- }
204
+ return loadInstallRecord(pluginId)?.source !== 'npm';
135
205
  }
136
206
 
137
207
  /**
@@ -140,15 +210,7 @@ export function shouldSkipAutoUpgrade(pluginId) {
140
210
  * @returns {string|null}
141
211
  */
142
212
  export function getPluginInstallPath(pluginId) {
143
- const rt = getRuntime();
144
- if (!rt?.config?.loadConfig) return null;
145
- try {
146
- const config = rt.config.loadConfig();
147
- return config?.plugins?.installs?.[pluginId]?.installPath ?? null;
148
- }
149
- catch {
150
- return null;
151
- }
213
+ return loadInstallRecord(pluginId)?.installPath ?? null;
152
214
  }
153
215
 
154
216
  /**
@@ -0,0 +1,27 @@
1
+ /**
2
+ * claw-config.js — OpenClaw runtime config 的统一访问入口
3
+ *
4
+ * 设计原则:
5
+ * - 业务代码只调 getClawConfig(),不直接摸 rt.config,新旧 API 切换全在此处兜底
6
+ * - OpenClaw v2026.4.27+ 新 API `config.current()`;老 API `config.loadConfig()` 仍可用但触发 deprecation 警告
7
+ * - 两个 API 内部都返回同一个 getRuntimeConfig() 快照,字段语义一致
8
+ * - 异常不在此处吞,让调用方按需处理(取 token 与读账本兜底策略不同)
9
+ *
10
+ * 拆分触发:本文件超约 200 行,或某一类 host 适配独立成块且 ≥ 100 行时再拆出去;
11
+ * 否则 path 之外的 host 适配优先往本文件加(必要时改名 claw-host.js)
12
+ */
13
+ import { getRuntime } from './runtime.js';
14
+
15
+ /**
16
+ * 读取当前 OpenClaw 运行时配置快照
17
+ *
18
+ * 优先 `config.current()`(v2026.4.27+),缺失时回落到 `config.loadConfig()`。
19
+ *
20
+ * @returns {object|null} runtime 未注入或缺 config 访问 API 时返回 null
21
+ */
22
+ export function getClawConfig() {
23
+ const rt = getRuntime();
24
+ const reader = rt?.config?.current ?? rt?.config?.loadConfig;
25
+ if (!reader) return null;
26
+ return reader();
27
+ }
@@ -1,6 +1,7 @@
1
1
  import nodePath from 'node:path';
2
2
  import { WebSocket as WsWebSocket } from 'ws';
3
3
 
4
+ import { getClawConfig } from './claw-config.js';
4
5
  import { pluginDir } from './claw-paths.js';
5
6
  import { clearConfig, getBindingsPath, readConfig } from './config.js';
6
7
  import { cleanupResiduals as defaultCleanupResiduals, measureDiskCap as defaultMeasureDiskCap } from './rpc-queue-startup.js';
@@ -95,11 +96,7 @@ export function defaultResolveGatewayAuthToken() {
95
96
  return envToken;
96
97
  }
97
98
  try {
98
- const rt = getRuntime();
99
- if (!rt?.config?.loadConfig) {
100
- return '';
101
- }
102
- const cfg = rt.config.loadConfig();
99
+ const cfg = getClawConfig();
103
100
  const token = cfg?.gateway?.auth?.token;
104
101
  return typeof token === 'string' && token.trim() ? token.trim() : '';
105
102
  }
@@ -183,8 +180,11 @@ export class RealtimeBridge {
183
180
  // runId → connId 路由表:用于 event:agent 帧按发起方单播。
184
181
  // 实例延迟到 start() 真 logger 到位时再 new;stop() destroy 后置 null。
185
182
  this.__runEventRoutes = null;
186
- // rpc DC 文件回退队列的磁盘容量(B-stage1 plan-2 探测,B-stage2 才消费)
183
+ // rpc DC 文件回退队列的磁盘容量(B-stage1 plan-2 探测,B9b 在装配 FBQ 时取)
187
184
  this.__diskCap = null;
185
+ // rpc DC 文件回退队列根目录(B-stage1 plan-2 已 cleanupResiduals 过;B9b 装配 FBQ 时取)。
186
+ // prep 失败时 null → webrtc-peer 装配点自动降级到 MemoryQueue
187
+ this.__queueDir = null;
188
188
  }
189
189
 
190
190
  __resolveWebSocket() {
@@ -329,6 +329,10 @@ export class RealtimeBridge {
329
329
  PeerConnection,
330
330
  impl: this.__ndcPreloadResult?.impl,
331
331
  logger: this.logger,
332
+ // B9b:webrtc-peer 装配 FBQ 时取 disk 容量 + 队列根目录;
333
+ // __queueDir 为 null 时(plan-2 prep 失败)自动降级到 MemoryQueue
334
+ getDiskCap: () => this.__diskCap,
335
+ queueDir: this.__queueDir,
332
336
  });
333
337
  }
334
338
  /* c8 ignore stop */
@@ -1395,11 +1399,15 @@ export class RealtimeBridge {
1395
1399
  const queueDir = nodePath.join(pluginDir(), 'rpc-queues');
1396
1400
  await this.__cleanupRpcQueueResiduals(queueDir, { logger: this.logger });
1397
1401
  this.__diskCap = await this.__measureRpcQueueDiskCap(queueDir, { logger: this.logger });
1402
+ // 只有 cleanupResiduals 成功 + measureDiskCap 完成后才暴露 queueDir 给 webrtc 装配;
1403
+ // 任一抛错都让 __queueDir 留 null,下游自动降级到 MemoryQueue
1404
+ this.__queueDir = queueDir;
1398
1405
  }
1399
1406
  catch (err) {
1400
1407
  /* c8 ignore next -- ?./?? fallback:err 总是 Error,logger.warn 总存在 */
1401
1408
  this.logger.warn?.(`[coclaw] rpc-queues startup prep failed (skipped): ${err?.message ?? err}`);
1402
1409
  this.__diskCap = null;
1410
+ this.__queueDir = null;
1403
1411
  }
1404
1412
  // race 守卫:cleanup/measure 期间若 stop() 已执行,不应再启动 native WebRTC 进程。
1405
1413
  // preload 后还有一道 started 检查兜底(含 pion cleanup),这里先挡住一次无意义的 preload。
@@ -35,8 +35,10 @@ class FileBackedQueue {
35
35
  * @param {string} opts.id - 队列标识,字符集受限,防路径穿越
36
36
  * @param {number} [opts.memBudget=8MB] - 内存持有字节数上限
37
37
  * @param {number} [opts.diskCap=1GB] - 磁盘+内存总字节数硬上限(含 `\n`)
38
- * @param {(reason: string, size: number) => void} [opts.onDrop] - 拒入队时的回调
38
+ * @param {number} [opts.maxMessageBytes=Infinity] - 单条硬上限;超过即 drop(bypass 也不豁免)
39
+ * @param {(reason: string, size: number, err?: Error) => void} [opts.onDrop] - 拒入队时的回调;'fs-error' 传底层 err,其它 reason 第三参为 undefined
39
40
  * @param {{ warn?: Function, info?: Function, error?: Function }} [opts.logger=console]
41
+ * @param {(jsonStr: string) => boolean} [opts.bypassAdmission] - 白名单谓词,命中则容量层 admission 豁免(与 MemoryQueue 同义)
40
42
  */
41
43
  constructor(opts) {
42
44
  const {
@@ -44,8 +46,10 @@ class FileBackedQueue {
44
46
  id,
45
47
  memBudget = DEFAULT_MEM_BUDGET,
46
48
  diskCap = DEFAULT_DISK_CAP,
49
+ maxMessageBytes = Infinity,
47
50
  onDrop,
48
51
  logger = console,
52
+ bypassAdmission,
49
53
  } = opts ?? {};
50
54
 
51
55
  if (!dir || typeof dir !== 'string') throw new TypeError('dir is required');
@@ -61,13 +65,19 @@ class FileBackedQueue {
61
65
  if (!Number.isFinite(diskCap) || diskCap <= 0) {
62
66
  throw new TypeError('diskCap must be a finite positive number');
63
67
  }
68
+ if (maxMessageBytes !== Infinity && (!Number.isFinite(maxMessageBytes) || maxMessageBytes <= 0)) {
69
+ throw new TypeError('maxMessageBytes must be Infinity or a finite positive number');
70
+ }
64
71
 
65
72
  this.dir = dir;
66
73
  this.id = id;
67
74
  this.memBudget = memBudget;
68
75
  this.diskCap = diskCap;
76
+ this.maxMessageBytes = maxMessageBytes;
69
77
  this.onDrop = onDrop;
70
78
  this.logger = logger;
79
+ // 非函数(含 undefined / null / 字符串等)一律收编为 null,保持向后兼容
80
+ this.bypassAdmission = typeof bypassAdmission === 'function' ? bypassAdmission : null;
71
81
 
72
82
  this.filePath = nodePath.join(dir, `${id}.jsonl`);
73
83
 
@@ -83,6 +93,9 @@ class FileBackedQueue {
83
93
  this.fsBroken = false; // 粘性:一旦 FS 出错,不再尝试 reopen
84
94
  this.writeStream = null;
85
95
  this.writeErr = null;
96
+ // 粘性最新 fs 错误:__handleFsError 在 mutex 内缓存;后续走 fsBroken 短路的 enqueue 通过
97
+ // __dispatchDrop 第三参把 err 透传给 onDrop,让 monitor / 运维拿到具体 errno
98
+ this.lastFsErr = null;
86
99
  this.waiters = [];
87
100
  this.mutex = createMutex();
88
101
  }
@@ -130,10 +143,20 @@ class FileBackedQueue {
130
143
 
131
144
  const size = Buffer.byteLength(jsonStr, 'utf8');
132
145
 
146
+ // per-message 硬上限:bypass 也不豁免(红线 3:bypass 仅豁免容量层 admission)。
147
+ // 与 sender 端 MAX_SINGLE_MSG_BYTES 检查对齐——避免大帧先入队再被 sender 拒,
148
+ // 导致 backlog 异常膨胀且 oversize 不进 monitor 账(loud-on-loss)。
149
+ if (size > this.maxMessageBytes) {
150
+ this.__dispatchDrop('oversize', size);
151
+ return false;
152
+ }
153
+
133
154
  // admission:按物理占用(mem + 已写文件总字节,含 \n)判定,保证 diskCap 是真正的硬上限。
134
155
  // 用 writtenBytes(不减 readOffset)的含义:文件前缀已读但未被 __dropFile 回收前仍算占用。
135
156
  // 代价:持续背压下消费者还没追到写端时新消息可能被 drop,直到完全 drain 触发 __dropFile 重置。
136
- if (this.memBytes + this.writtenBytes + size + 1 > this.diskCap) {
157
+ // bypassAdmission 命中时容量层豁免(与 MemoryQueue 一致):白名单消息可越过 diskCap 入队,
158
+ // 实际占用可能短暂超 diskCap——这是红线 3 的明确预期。物理 IO 失败仍会按 fs-error drop。
159
+ if (this.memBytes + this.writtenBytes + size + 1 > this.diskCap && !this.__isBypass(jsonStr)) {
137
160
  this.__dispatchDrop('disk-cap', size);
138
161
  return false;
139
162
  }
@@ -150,9 +173,10 @@ class FileBackedQueue {
150
173
  }
151
174
  }
152
175
 
153
- // 溢出路径:FS 已破直接 drop,不再尝试 reopen
176
+ // 溢出路径:FS 已破直接 drop,不再尝试 reopen。lastFsErr 由 __handleFsError 已粘性置好,
177
+ // 把它透传给 onDrop 第三参——红线 2 "丢失/延迟必须 loud 可观测" 要求把 errno 抬出去
154
178
  if (this.fsBroken) {
155
- this.__dispatchDrop('fs-error', size);
179
+ this.__dispatchDrop('fs-error', size, this.lastFsErr);
156
180
  return false;
157
181
  }
158
182
 
@@ -160,7 +184,7 @@ class FileBackedQueue {
160
184
  await this.__openWriteStream();
161
185
  if (this.writeErr) {
162
186
  const err = this.writeErr;
163
- this.__dispatchDrop('fs-error', size);
187
+ this.__dispatchDrop('fs-error', size, err);
164
188
  // 前置 mkdir/rm 失败也进入粘性降级:与 stream 'error' 路径语义一致,
165
189
  // 避免后续每次 overflow 都重试同一个持续性 FS 故障。
166
190
  await this.__handleFsError(err);
@@ -176,7 +200,7 @@ class FileBackedQueue {
176
200
  return true;
177
201
  } catch (err) {
178
202
  this.logger?.warn?.('fbq.enqueue fs-error', err);
179
- this.__dispatchDrop('fs-error', size);
203
+ this.__dispatchDrop('fs-error', size, err);
180
204
  // 直接在当前锁内触发粘性降级:真实 Node stream 下 cb err 通常也会 emit 'error'
181
205
  // (监听器会另外排一次 handleFsError,但 fsBroken 已置 → no-op);测试里的 monkey-patch
182
206
  // 只触发 cb、不发 'error',这里主动降级保证行为一致。
@@ -223,17 +247,33 @@ class FileBackedQueue {
223
247
  this.spilled = false;
224
248
  this.fsBroken = false;
225
249
  this.writeErr = null;
250
+ this.lastFsErr = null;
226
251
  });
227
252
  }
228
253
 
229
254
  /**
230
255
  * 停写、关 FD、删文件、结束所有迭代器。幂等。
256
+ *
257
+ * @param {(residual: { memCount: number, memBytes: number, diskBytes: number, writtenBytes: number, spilled: boolean, fsBroken: boolean }) => void} [onBeforeClear]
258
+ * 可选回调(**必须为同步函数**):在 mutex 内、清空字段 / 关流 / 删文件**之前**触发,
259
+ * 参数是销毁时刻的 6 字段残留快照。存在意义:mutex 保证 in-flight enqueue 已落地;调用方
260
+ * sync 调 `queue.stats()` 看不到 in-flight 入队的消息,改用此回调可拿原子准确的残留快照。
261
+ * 回调同步抛错被 swallow,不影响 destroy 完成。
262
+ * **注意**:返回 Promise 的异步回调其 rejection 不会被捕获——仅设计为 sync 钩子(与 MemoryQueue 一致)。
231
263
  */
232
- async destroy() {
264
+ async destroy(onBeforeClear) {
233
265
  return await this.mutex.withLock(async () => {
234
266
  if (this.destroyed) return;
235
267
  this.destroyed = true;
236
268
 
269
+ // 在 mutex 内、清空 / 关流 / 删文件之前快照 6 字段残留:mutex 保证看到所有已入队的消息(含 in-flight);
270
+ // 异步 IO 清理(__closeWriteStream / fs.rm)会改 spilled/writtenBytes,所以快照必须先抓
271
+ if (typeof onBeforeClear === 'function') {
272
+ const residual = this.stats();
273
+ try { onBeforeClear(residual); }
274
+ catch { /* 回调自身抛是调用方的 bug;不能传染给 destroy 契约(与 MemoryQueue silent gotcha 镜像)*/ }
275
+ }
276
+
237
277
  // 唤醒所有等待者,让它们在下一轮循环中看到 destroyed 并返回 done
238
278
  const toWake = this.waiters.splice(0);
239
279
  for (const w of toWake) w.resolve();
@@ -252,6 +292,7 @@ class FileBackedQueue {
252
292
  this.writtenBytes = 0;
253
293
  this.readOffset = 0;
254
294
  this.spilled = false;
295
+ this.lastFsErr = null;
255
296
  });
256
297
  }
257
298
 
@@ -307,14 +348,24 @@ class FileBackedQueue {
307
348
  for (const w of toWake) w.resolve();
308
349
  }
309
350
 
310
- __dispatchDrop(reason, size) {
351
+ __dispatchDrop(reason, size, err) {
311
352
  try {
312
- this.onDrop?.(reason, size);
313
- } catch (err) {
353
+ this.onDrop?.(reason, size, err);
354
+ } catch (cbErr) {
314
355
  /* c8 ignore next 2 -- onDrop throwing is caller's bug */
315
- this.logger?.warn?.('fbq.onDrop threw', err);
356
+ this.logger?.warn?.('fbq.onDrop threw', cbErr);
357
+ }
358
+ this.logger?.warn?.('fbq.drop', { reason, size, err: err?.message });
359
+ }
360
+
361
+ __isBypass(jsonStr) {
362
+ if (!this.bypassAdmission) return false;
363
+ try {
364
+ return Boolean(this.bypassAdmission(jsonStr));
365
+ } catch {
366
+ // 谓词自身抛 → 视为非白名单(最安全的回退;保守 drop 而非误入队)
367
+ return false;
316
368
  }
317
- this.logger?.warn?.('fbq.drop', { reason, size });
318
369
  }
319
370
 
320
371
  async __openWriteStream() {
@@ -373,16 +424,17 @@ class FileBackedQueue {
373
424
  });
374
425
  }
375
426
 
376
- // mutex 内调用:FS 错误粘性降级
377
- async __handleFsError(_err) {
427
+ // mutex 内调用:FS 错误粘性降级。err 缓存到 lastFsErr 供后续 fsBroken 短路 enqueue 透传给 onDrop
428
+ async __handleFsError(err) {
378
429
  if (this.destroyed || this.fsBroken) return;
379
430
  this.fsBroken = true;
431
+ this.lastFsErr = err;
380
432
  await this.__closeWriteStream();
381
433
  try {
382
434
  await fs.rm(this.filePath, { force: true });
383
- } catch (err) {
435
+ } catch (rmErr) {
384
436
  /* c8 ignore next 2 -- rm with force rarely fails */
385
- this.logger?.warn?.('fbq.handleFsError rm error', err);
437
+ this.logger?.warn?.('fbq.handleFsError rm error', rmErr);
386
438
  }
387
439
  this.spilled = false;
388
440
  this.writtenBytes = 0;
@@ -1,10 +1,24 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import { createReassembler } from './dc-chunking.js';
2
4
  import { MemoryQueue } from '../utils/memory-queue.js';
5
+ import { FileBackedQueue } from '../utils/file-backed-queue.js';
3
6
  import { RpcDcSender, DC_LOW_WATER_MARK, MAX_SINGLE_MSG_BYTES } from './rpc-dc-sender.js';
4
7
  import { createRpcDropMonitor } from './rpc-drop-monitor.js';
5
8
  import { isAgentRunResponse } from './agent-run-response.js';
6
9
  import { remoteLog } from '../remote-log.js';
7
10
 
11
+ // rpc DC 发送队列实现选择(B-stage2 B9b)。
12
+ // - 'mem':MemoryQueue(当前生产默认)—— 不碰 fs,溢出即 drop;FBQ 未充分本地验证前先用此模式发布
13
+ // - 'fbq':FileBackedQueue —— 长时间后台 / ICE 恢复等慢消化场景溢出到磁盘
14
+ // 当 'fbq' 但 queueDir 不可用(bridge 启动期 plan-2 prep 失败)时自动降级到 'mem',避免阻塞 webrtc 装配。
15
+ // 单点常量;构造时可通过 `rpcQueueImpl` opt 覆盖(测试用)。生产侧改回 'fbq' 只需翻这一行。
16
+ const RPC_QUEUE_IMPL = 'mem';
17
+
18
+ // FBQ 装配兜底:bridge 启动期 measureDiskCap 失败 → __diskCap=null → 这里兜底 1GB
19
+ const ONE_GB = 1024 * 1024 * 1024;
20
+ const RPC_QUEUE_MEM_BUDGET = 10 * 1024 * 1024;
21
+
8
22
  // 单个 session 内 file DC 历史快照的容量上限(满后按 FIFO 淘汰最老条目)。
9
23
  // 用于诊断 dump:过大会撑爆 remoteLog 单帧,20 足以覆盖典型多文件传输会话。
10
24
  const FILE_CHANNEL_HISTORY_LIMIT = 20;
@@ -31,8 +45,13 @@ export class WebRtcPeer {
31
45
  * @param {object} [opts.logger] - pino 风格 logger
32
46
  * @param {function} opts.PeerConnection - RTCPeerConnection 构造函数(由 ndc-preloader 提供)
33
47
  * @param {string} [opts.impl] - WebRTC 实现标识(pion / ndc / werift)
48
+ * @param {() => (number|null)} [opts.getDiskCap] - 启动期测得的 diskCap 字节数;prep 失败返 null。
49
+ * FBQ 装配时取(兜底 1GB);MemoryQueue 装配不消费。
50
+ * @param {string} [opts.queueDir] - rpc DC 队列文件目录(FBQ 模式所需);空 / 非字符串时降级到 MemoryQueue
51
+ * @param {'fbq'|'mem'} [opts.rpcQueueImpl] - rpc 队列实现选择,默认取模块级 RPC_QUEUE_IMPL;
52
+ * 测试用——生产路径走默认即可
34
53
  */
35
- constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection, impl }) {
54
+ constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection, impl, getDiskCap, queueDir, rpcQueueImpl }) {
36
55
  if (!PeerConnection) {
37
56
  throw new Error('PeerConnection constructor is required');
38
57
  }
@@ -43,6 +62,12 @@ export class WebRtcPeer {
43
62
  this.logger = logger ?? console;
44
63
  this.__PeerConnection = PeerConnection;
45
64
  this.__impl = impl ?? null;
65
+ // 非函数(含 undefined / null / 字符串等)一律收编为 null,调用时再做 null 兜底
66
+ this.__getDiskCap = typeof getDiskCap === 'function' ? getDiskCap : null;
67
+ // FBQ 文件根目录;非字符串 / 空字符串 → null → 装配时自动降级到 MemoryQueue
68
+ this.__queueDir = typeof queueDir === 'string' && queueDir.length > 0 ? queueDir : null;
69
+ // 队列实现选择:测试可显式覆盖;非 'fbq'/'mem' 一律收编为模块默认,避免误用
70
+ this.__rpcQueueImpl = (rpcQueueImpl === 'fbq' || rpcQueueImpl === 'mem') ? rpcQueueImpl : RPC_QUEUE_IMPL;
46
71
  this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
47
72
  /** @type {Map<string, { pc: object, rpcChannel: object|null, rpcQueue: MemoryQueue|null, rpcDcSender: RpcDcSender|null, rpcConsumeLoop: Promise<void>|null, rpcDropMonitor: object|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
48
73
  this.__sessions = new Map();
@@ -551,8 +576,8 @@ export class WebRtcPeer {
551
576
  }
552
577
 
553
578
  async __setupDataChannel(connId, dc) {
554
- // rpc DC 发送流控:MemoryQueueadmission + bypass 白名单)+ rpc-drop-monitor
555
- // (边沿日志 / 累计 / 汇总)+ RpcDcSender(分片 + 背压),通过消费循环串起来。
579
+ // rpc DC 发送流控:Queue(FileBackedQueue 默认 / MemoryQueue 降级;admission + bypass 白名单)
580
+ // + rpc-drop-monitor(边沿日志 / 累计 / 汇总)+ RpcDcSender(分片 + 背压),通过消费循环串起来。
556
581
  // 广播 / sendTo / files sendFn 都向 queue.enqueue,sender 从 queue 拉。
557
582
  const session = this.__sessions.get(connId);
558
583
 
@@ -673,20 +698,40 @@ export class WebRtcPeer {
673
698
  if (session.rpcConsumeLoop) await session.rpcConsumeLoop.catch(() => { /* c8 ignore next -- 极冷防御 */ });
674
699
  session.rpcDropMonitor = null;
675
700
  }
676
- // 创建 monitor。必须在 new MemoryQueue 之前——MemoryQueue 的 onDrop 接 monitor.onDrop。
701
+ // 创建 monitor。必须在 new Queue 之前——Queue 的 onDrop 接 monitor.onDrop。
677
702
  // monitor 是局部变量,stale 路径下函数返回后自然 GC,不挂 session 字段(无 drop 可汇总)。
678
703
  const monitor = createRpcDropMonitor({ connId, logger: this.logger });
679
- // 构造 queue 并 await init。MemoryQueue.init 是 no-op;保留 await 占位,FBQ 阶段
680
- // init 承担 fs 残留清理。await 期间可能发生 closeByConnId / 同 connId 二次 ondatachannel,
681
- // 因此后面必须身份重核才能赋 session 字段。
682
- const queue = new MemoryQueue({
683
- id: connId,
684
- maxMessageBytes: MAX_SINGLE_MSG_BYTES,
685
- bypassAdmission: isAgentRunResponse,
686
- onDrop: monitor.onDrop,
687
- logger: this.logger,
688
- tag: `conn=${connId}`,
689
- });
704
+
705
+ // queue 实例选择(B-stage2 B9b):默认取模块级 RPC_QUEUE_IMPL(当前 'mem');
706
+ // 'fbq' 模式下若 queueDir 不可用则降级到 mem,避免阻塞装配。
707
+ // connId race 隔离(决策 4):FBQ id 加唯一后缀 ${connId}-${ts}-${nonce},
708
+ // 让新旧实例文件名物理不同,destroy/init 期间互不踩踏。MemoryQueue 不碰 fs,无此需求。
709
+ // connId 字符集(PRE-EXISTING 契约):上游 server 分配 connId 形如 `c_<digits>`;
710
+ // FBQ / MemoryQueue 共用 `^[A-Za-z0-9._-]+$` 校验。若 server 将来引入特殊字符,
711
+ // queue 构造会抛 TypeError,由 __setupDataChannel 的 .catch 兜底 warn——非 B9b 引入。
712
+ const useFbq = this.__rpcQueueImpl === 'fbq' && !!this.__queueDir;
713
+ const fbqFallback = !useFbq && this.__rpcQueueImpl === 'fbq';
714
+ const queue = useFbq
715
+ ? new FileBackedQueue({
716
+ id: `${connId}-${Date.now()}-${randomUUID().slice(0, 8)}`,
717
+ dir: this.__queueDir,
718
+ memBudget: RPC_QUEUE_MEM_BUDGET,
719
+ diskCap: this.__getDiskCap?.() ?? ONE_GB,
720
+ maxMessageBytes: MAX_SINGLE_MSG_BYTES,
721
+ bypassAdmission: isAgentRunResponse,
722
+ onDrop: monitor.onDrop,
723
+ logger: this.logger,
724
+ })
725
+ : new MemoryQueue({
726
+ id: connId,
727
+ maxMessageBytes: MAX_SINGLE_MSG_BYTES,
728
+ bypassAdmission: isAgentRunResponse,
729
+ onDrop: monitor.onDrop,
730
+ logger: this.logger,
731
+ tag: `conn=${connId}`,
732
+ });
733
+ // FBQ.init 承担 fs 残留清理(含同 connId 唯一后缀文件,不会撞旧实例);MemoryQueue.init 是 no-op。
734
+ // await 期间可能发生 closeByConnId / 同 connId 二次 ondatachannel,因此后面必须身份重核才能赋字段。
690
735
  await queue.init();
691
736
  // 身份重核:init 期间 session 可能被 closeByConnId 从 Map 删除,或被同 connId 二次
692
737
  // ondatachannel 把 rpcChannel 替换成新 dc。任一不再成立都视为 stale,destroy queue 后
@@ -696,6 +741,12 @@ export class WebRtcPeer {
696
741
  await queue.destroy();
697
742
  return;
698
743
  }
744
+ // 装配成功后日志:让运维侧看到该 session 实际跑哪种 queue(特别是 fbq 降级到 mem 的场景)。
745
+ // 放在 stale 守卫之后——只对真正生效的 session 打 log,避免 stale 装配虚报一次。
746
+ // 单 session 只打一次,频率与连接频率挂钩——符合 remoteLog 红线(不高频)。
747
+ const queueImpl = useFbq ? 'fbq' : 'mem';
748
+ this.logger.info?.(`${this.__rtcTag} [${connId}] rpc queue impl=${queueImpl}${fbqFallback ? ' (fallback: queueDir unavailable)' : ''}`);
749
+ this.__remoteLog(`rtc.queue-impl conn=${connId} impl=${queueImpl}${fbqFallback ? ' fallback=queue-dir-null' : ''}`);
699
750
  if ('bufferedAmountLowThreshold' in dc) {
700
751
  dc.bufferedAmountLowThreshold = DC_LOW_WATER_MARK;
701
752
  }