@coclaw/openclaw-coclaw 0.20.1 → 0.21.0

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.1",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -204,6 +204,21 @@ export function shouldSkipAutoUpgrade(pluginId) {
204
204
  return loadInstallRecord(pluginId)?.source !== 'npm';
205
205
  }
206
206
 
207
+ /**
208
+ * 判断 host 是否运行在 Nix mode。
209
+ *
210
+ * OpenClaw ≥ 2026.5 在 `openclaw plugins {update,install,uninstall,...}` 入口加了
211
+ * `assertConfigWriteAllowedInCurrentMode()` 守门:当 `OPENCLAW_NIX_MODE=1` 时直接抛
212
+ * `NixModeConfigMutationError`(code `OPENCLAW_NIX_MODE_CONFIG_IMMUTABLE`),因为
213
+ * 这类用户的 openclaw.json 由 Nix 当 immutable 资产管,运行时改了下次 Nix 重建会被刷回。
214
+ * 自动升级在这种环境下毫无意义且会污染日志,scheduler 启动前直接退出即可。
215
+ *
216
+ * @returns {boolean}
217
+ */
218
+ export function isNixMode() {
219
+ return process.env.OPENCLAW_NIX_MODE === '1';
220
+ }
221
+
207
222
  /**
208
223
  * 获取插件安装路径
209
224
  * @param {string} pluginId
@@ -239,6 +254,7 @@ export class AutoUpgradeScheduler {
239
254
  * @param {Function} [params.opts.execFileFn]
240
255
  * @param {Function} [params.opts.spawnFn]
241
256
  * @param {Function} [params.opts.shouldSkipFn]
257
+ * @param {Function} [params.opts.isNixModeFn]
242
258
  * @param {Function} [params.opts.getPluginInstallPathFn]
243
259
  */
244
260
  constructor(params) {
@@ -260,6 +276,17 @@ export class AutoUpgradeScheduler {
260
276
  return;
261
277
  }
262
278
 
279
+ const isNix = this.__opts.isNixModeFn ?? isNixMode;
280
+ if (isNix()) {
281
+ // 上推到 server:用户向我们反馈"自动升级没动"时,可凭 server 端 remote log
282
+ // 直接定位到 Nix mode 跳过路径,不必再回滚问"你是不是 nix-openclaw 装的"。
283
+ // scheduler.start() 每次 gateway 启动只调一次,量级低、不会刷屏。
284
+ remoteLog('upgrade.nix-mode-skip');
285
+ this.__logger.info?.('[auto-upgrade] Skipping: host is in Nix mode (config is immutable)');
286
+ this.__running = false;
287
+ return;
288
+ }
289
+
263
290
  const shouldSkip = this.__opts.shouldSkipFn ?? shouldSkipAutoUpgrade;
264
291
  if (shouldSkip(this.__pluginId)) {
265
292
  this.__logger.info?.('[auto-upgrade] Skipping: not an npm-installed plugin');
@@ -43,6 +43,13 @@ const HEALTH_POLL_INTERVAL_MS = 3_000;
43
43
  // 及插件 bootstrap,合计 30~60s 常见;5 分钟给足余量
44
44
  const HEALTH_TOTAL_TIMEOUT_MS = 5 * 60 * 1000;
45
45
 
46
+ // 单调时钟(毫秒,整数)。轮询超时只关心"流逝",必须避开 Date.now() 的墙钟
47
+ // 跳变(NTP 同步、host suspend/resume、WSL2 vmtime sync)—— 这些跳变会让
48
+ // loop 误以为已经超时而提前退出
49
+ function monoNowMs() {
50
+ return Number(process.hrtime.bigint() / 1_000_000n);
51
+ }
52
+
46
53
  /**
47
54
  * 执行命令并返回 stdout;错误对象附带 stderr 以便诊断
48
55
  * @param {string} cmd
@@ -149,12 +156,12 @@ export async function pollUpgradeHealth(toVersion, opts) {
149
156
  const totalTimeout = opts?.totalTimeoutMs ?? HEALTH_TOTAL_TIMEOUT_MS;
150
157
  /* c8 ignore next -- ?? fallback */
151
158
  const pollInterval = opts?.pollIntervalMs ?? HEALTH_POLL_INTERVAL_MS;
152
- const start = Date.now();
159
+ const start = monoNowMs();
153
160
  let attempts = 0;
154
161
  let lastReason = '';
155
162
  let lastVersion = '';
156
163
 
157
- while (Date.now() - start < totalTimeout) {
164
+ while (monoNowMs() - start < totalTimeout) {
158
165
  attempts += 1;
159
166
  const result = await callUpgradeHealthOnce(opts);
160
167
  if (result.ok) {
@@ -164,7 +171,7 @@ export async function pollUpgradeHealth(toVersion, opts) {
164
171
  ok: true,
165
172
  version: result.version,
166
173
  attempts,
167
- elapsedMs: Date.now() - start,
174
+ elapsedMs: monoNowMs() - start,
168
175
  };
169
176
  }
170
177
  lastVersion = result.version;
@@ -174,14 +181,14 @@ export async function pollUpgradeHealth(toVersion, opts) {
174
181
  lastReason = result.reason;
175
182
  }
176
183
  // 剩余时间不足以再等一个 interval 就直接退出,避免最后一次毫无意义的 sleep
177
- if (Date.now() - start + pollInterval >= totalTimeout) break;
184
+ if (monoNowMs() - start + pollInterval >= totalTimeout) break;
178
185
  await sleep(pollInterval);
179
186
  }
180
187
 
181
188
  return {
182
189
  ok: false,
183
190
  attempts,
184
- elapsedMs: Date.now() - start,
191
+ elapsedMs: monoNowMs() - start,
185
192
  lastReason,
186
193
  lastVersion,
187
194
  };
@@ -279,7 +279,8 @@ export class RealtimeBridge {
279
279
  return;
280
280
  }
281
281
  try {
282
- this.gatewayWs.close(1000, 'server-disconnect');
282
+ this.gatewayWs.__closedByPlugin = true;
283
+ this.gatewayWs.close(1000, 'local-close');
283
284
  }
284
285
  /* c8 ignore next */
285
286
  catch {}
@@ -814,7 +815,10 @@ export class RealtimeBridge {
814
815
  this.__gatewayLastReason = reason;
815
816
  remoteLog(`ws.connect-failed peer=gateway msg=${reason}`);
816
817
  this.logger.warn?.(`[coclaw] gateway connect failed: ${reason}`);
817
- try { ws.close(1008, 'gateway_connect_failed'); }
818
+ try {
819
+ ws.__closedByPlugin = true;
820
+ ws.close(1008, 'gateway_connect_failed');
821
+ }
818
822
  /* c8 ignore next */
819
823
  catch {}
820
824
  }
@@ -917,13 +921,27 @@ export class RealtimeBridge {
917
921
  this.__logDebug('gateway ws open, waiting for connect.challenge');
918
922
  });
919
923
  ws.addEventListener('close', (ev) => {
920
- // 握手失败路径已经打过 ws.connect-failed,这里抑制重复的 disconnected 日志;
921
- // 成功后的意外断开、握手途中的异常断开仍按原样上报。per-WS log 用闭包局部
922
- // connectFailReported,无需身份校验
924
+ // 区分本端 plugin 主动关闭与对端 OpenClaw gateway 关闭:日志/远程上报用不同事件名,
925
+ // 避免读 log 时把"plugin 自己关"误读成"对端断开"(reason 字段全在我们自定义);
926
+ // server 仍可从 remoteLog 频次发现 local-close 循环异常。
927
+ const closedByPlugin = ws.__closedByPlugin === true;
928
+ // 握手失败路径已经打过 ws.connect-failed,这里抑制重复的远程上报;
929
+ // 其它分支(成功后掉线 / 握手中异常断开 / plugin 主动关闭)按原样上报。per-WS log 用
930
+ // 闭包局部 connectFailReported,无需身份校验
923
931
  if (!connectFailReported) {
924
- remoteLog(`ws.disconnected peer=gateway code=${ev?.code ?? '?'}`);
932
+ if (closedByPlugin) {
933
+ remoteLog(`ws.local-close peer=gateway reason=${ev?.reason ?? 'n/a'}`);
934
+ }
935
+ else {
936
+ remoteLog(`ws.disconnected peer=gateway code=${ev?.code ?? '?'} reason=${ev?.reason ?? 'n/a'}`);
937
+ }
938
+ }
939
+ if (closedByPlugin) {
940
+ this.logger.info?.(`[coclaw] gateway ws closed by plugin (reason=${ev?.reason ?? 'n/a'})`);
941
+ }
942
+ else {
943
+ this.logger.info?.(`[coclaw] gateway ws closed by peer (code=${ev?.code ?? '?'} reason=${ev?.reason ?? 'n/a'})`);
925
944
  }
926
- this.logger.info?.(`[coclaw] gateway ws closed (code=${ev?.code ?? '?'} reason=${ev?.reason ?? 'n/a'})`);
927
945
  // stale guard:旧 ws 的迟到 close 不应清新 ws 的 lag probes / pending requests / DC 路由 /
928
946
  // 也不应触发新一轮重试调度。非当前 ws → 直接早返,仅留 per-WS 日志。
929
947
  if (this.gatewayWs !== ws) {
@@ -965,7 +983,10 @@ export class RealtimeBridge {
965
983
  this.logger.warn?.(`[coclaw] gateway ws error: ${String(err?.message ?? err)}`);
966
984
  // 防御 ws 库在某些错误下只 emit error 不跟随 close 的情况:主动关闭让 close handler
967
985
  // 接管清理和重试调度,避免 gatewayWs 引用卡在僵尸状态阻塞后续 __ensureGatewayConnection。
968
- try { ws.close(1011, 'ws_error'); }
986
+ try {
987
+ ws.__closedByPlugin = true;
988
+ ws.close(1011, 'ws_error');
989
+ }
969
990
  /* c8 ignore next */
970
991
  catch {}
971
992
  });
@@ -1395,13 +1416,33 @@ export class RealtimeBridge {
1395
1416
  // 整块包 try/catch:模块自身不抛,但仍可能进入 catch 的路径——pluginDir() 同步抛
1396
1417
  // (runtime 未注入 / nodePath.join 参数异常 / 测试注入的 stub 抛错)。任何路径都不能把
1397
1418
  // bridge.start 卡死。
1419
+ // 10s timeout 兜底:cleanup/measure 内部已永不抛(仅 warn + fallback),但 fs hang
1420
+ // 场景(NFS / 网络挂载 / 磁盘卡)下 fs.promises 调用不返回,整段会卡死;超时让 catch
1421
+ // 兜底降级到 MemoryQueue,bridge 至少能起来。
1422
+ // race 闭合:prep 改成纯返回值,timeout 赢时 catch 显式赋 null;不在 IIFE 内部 mutate
1423
+ // `this.__diskCap`,避免后台 prep 晚到把降级状态又覆盖回非 null。
1398
1424
  try {
1399
1425
  const queueDir = nodePath.join(pluginDir(), 'rpc-queues');
1400
- await this.__cleanupRpcQueueResiduals(queueDir, { logger: this.logger });
1401
- this.__diskCap = await this.__measureRpcQueueDiskCap(queueDir, { logger: this.logger });
1402
- // 只有 cleanupResiduals 成功 + measureDiskCap 完成后才暴露 queueDir 给 webrtc 装配;
1403
- // 任一抛错都让 __queueDir null,下游自动降级到 MemoryQueue
1404
- this.__queueDir = queueDir;
1426
+ const prepPromise = (async () => {
1427
+ await this.__cleanupRpcQueueResiduals(queueDir, { logger: this.logger });
1428
+ const diskCap = await this.__measureRpcQueueDiskCap(queueDir, { logger: this.logger });
1429
+ return { queueDir, diskCap };
1430
+ })();
1431
+ let timeoutId;
1432
+ const timeoutPromise = new Promise((_, reject) => {
1433
+ timeoutId = setTimeout(() => reject(new Error('rpc-queues startup prep timeout (10s)')), 10000);
1434
+ timeoutId.unref?.();
1435
+ });
1436
+ let result;
1437
+ try {
1438
+ result = await Promise.race([prepPromise, timeoutPromise]);
1439
+ }
1440
+ finally {
1441
+ clearTimeout(timeoutId);
1442
+ }
1443
+ // race 赢后才赋字段:保证 __diskCap / __queueDir 同时一致
1444
+ this.__diskCap = result.diskCap;
1445
+ this.__queueDir = result.queueDir;
1405
1446
  }
1406
1447
  catch (err) {
1407
1448
  /* c8 ignore next -- ?./?? fallback:err 总是 Error,logger.warn 总存在 */
@@ -6,7 +6,8 @@
6
6
  * - FIFO、单一生产者/消费者;多消费者时每条只交付给其中一个。
7
7
  * - 构造纯字段初始化,不碰 FS;使用前需 `await q.init()`。
8
8
  * - 消费侧:`for await (const item of queue) { ... }`;`destroy()` 让迭代结束。
9
- * - FS 异常下进入 `fsBroken` 粘性降级:mem 路径继续工作,溢出消息 drop
9
+ * - FS 异常下进入 `fsBroken` 粘性降级:mem 路径继续工作,溢出消息 drop
10
+ * 命中 bypassAdmission 的白名单消息允许 mem 桶 overshoot(与 MemoryQueue 镜像,保白名单不被误报 fs-error)。
10
11
  */
11
12
 
12
13
  import fs from 'node:fs/promises';
@@ -19,9 +20,6 @@ import { createMutex } from './mutex.js';
19
20
  const DEFAULT_MEM_BUDGET = 8 * 1024 * 1024;
20
21
  const DEFAULT_DISK_CAP = 1024 * 1024 * 1024;
21
22
 
22
- // JS 对象开销估算(string header + array slot 等),仅用于 admission 决策不影响 memBytes 报告
23
- const ENTRY_OVERHEAD = 64;
24
-
25
23
  // id 字符集:UUID / 字母数字 / 点 / 下划线 / 减号,且不能是 "." 或 ".."
26
24
  const ID_RE = /^[A-Za-z0-9._-]+$/;
27
25
 
@@ -33,10 +31,12 @@ class FileBackedQueue {
33
31
  * @param {object} opts
34
32
  * @param {string} opts.dir - 队列文件根目录
35
33
  * @param {string} opts.id - 队列标识,字符集受限,防路径穿越
36
- * @param {number} [opts.memBudget=8MB] - 内存持有字节数上限
37
- * @param {number} [opts.diskCap=1GB] - 磁盘+内存总字节数硬上限(含 `\n`)
34
+ * @param {number} [opts.memBudget=8MB] - mem 桶阈值(按 current ≥ threshold 判定,允许 single overshoot)
35
+ * @param {number} [opts.diskCap=1GB] - mem + 已写文件累计字节总占用阈值(含 `\n`);按 current ≥ threshold + single overshoot;非文件 size 硬上限
38
36
  * @param {number} [opts.maxMessageBytes=Infinity] - 单条硬上限;超过即 drop(bypass 也不豁免)
39
- * @param {(reason: string, size: number, err?: Error) => void} [opts.onDrop] - 拒入队时的回调;'fs-error' 传底层 err,其它 reason 第三参为 undefined
37
+ * @param {(reason: string, size: number, err?: Error|object) => void} [opts.onDrop] - 拒入队时的回调;'fs-error' 第三参传底层 err;'disk-cap' 第三参传 { memBytes, writtenBytes, diskCap } 分量;其它 reason 第三参为 undefined
38
+ * @param {() => void} [opts.onSpillStart] - 文件创建(spilled false→true)回调,边沿触发
39
+ * @param {(drainedBytes: number) => void} [opts.onSpillEnd] - 文件 drain 删除(spilled true→false)回调,边沿触发;故障删档不触发
40
40
  * @param {{ warn?: Function, info?: Function, error?: Function }} [opts.logger=console]
41
41
  * @param {(jsonStr: string) => boolean} [opts.bypassAdmission] - 白名单谓词,命中则容量层 admission 豁免(与 MemoryQueue 同义)
42
42
  */
@@ -48,6 +48,8 @@ class FileBackedQueue {
48
48
  diskCap = DEFAULT_DISK_CAP,
49
49
  maxMessageBytes = Infinity,
50
50
  onDrop,
51
+ onSpillStart,
52
+ onSpillEnd,
51
53
  logger = console,
52
54
  bypassAdmission,
53
55
  } = opts ?? {};
@@ -75,6 +77,8 @@ class FileBackedQueue {
75
77
  this.diskCap = diskCap;
76
78
  this.maxMessageBytes = maxMessageBytes;
77
79
  this.onDrop = onDrop;
80
+ this.onSpillStart = onSpillStart;
81
+ this.onSpillEnd = onSpillEnd;
78
82
  this.logger = logger;
79
83
  // 非函数(含 undefined / null / 字符串等)一律收编为 null,保持向后兼容
80
84
  this.bypassAdmission = typeof bypassAdmission === 'function' ? bypassAdmission : null;
@@ -151,21 +155,44 @@ class FileBackedQueue {
151
155
  return false;
152
156
  }
153
157
 
154
- // admission:按物理占用(mem + 已写文件总字节,含 \n)判定,保证 diskCap 是真正的硬上限。
155
- // writtenBytes(不减 readOffset)的含义:文件前缀已读但未被 __dropFile 回收前仍算占用。
156
- // 代价:持续背压下消费者还没追到写端时新消息可能被 drop,直到完全 drain 触发 __dropFile 重置。
157
- // bypassAdmission 命中时容量层豁免(与 MemoryQueue 一致):白名单消息可越过 diskCap 入队,
158
- // 实际占用可能短暂超 diskCap——这是红线 3 的明确预期。物理 IO 失败仍会按 fs-error drop。
159
- if (this.memBytes + this.writtenBytes + size + 1 > this.diskCap && !this.__isBypass(jsonStr)) {
160
- this.__dispatchDrop('disk-cap', size);
158
+ // bypass 谓词懒求值缓存:仅 admission / fsBroken overshoot 路径需要时才调用,整次 enqueue 内最多一次。
159
+ // 容量充裕路径(不超 diskCap mem 装得下)完全不调谓词——保留原短路语义、避免每条消息都解析 JSON。
160
+ let isBypass; // undefined = 未求值;?= 后变 true / false 即缓存命中
161
+ const getIsBypass = () => isBypass ??= this.__isBypass(jsonStr);
162
+
163
+ // admission:与 MemoryQueue 一致——按 current threshold 判定,允许 single overshoot。
164
+ // diskCap 语义:mem + 已写文件累计字节(writtenBytes 不减 readOffset,文件前缀已读但未
165
+ // __dropFile 回收前仍计入)的总占用阈值;不是单纯文件 size 上限,所以文件实际峰值约为
166
+ // diskCap - memBudget。允许 single overshoot:当前总占用 < diskCap 时再大的一条都收,
167
+ // 下一条才会 drop——与 MemoryQueue 单条 overshoot 行为对齐,简化两实现的语义分叉。
168
+ // bypass 命中时容量层豁免(红线 3):白名单消息越过 diskCap 入队,物理 IO 失败仍按 fs-error drop。
169
+ // fsBroken 守卫:粘性降级后 spill 永远不可用、writtenBytes 已重置为 0;mem 桶可能因
170
+ // 持续 bypass overshoot 推过 diskCap,但此时再来的非 bypass 消息根因是 fs 已坏(而非"容量"),
171
+ // 必须让下面的 fsBroken 短路赢、报 fs-error 带 lastFsErr,运维才能看到正确的诊断信号。
172
+ if (!this.fsBroken && this.memBytes + this.writtenBytes >= this.diskCap && !getIsBypass()) {
173
+ // 第三参传分量,让监视器在 disk-cap-start log 里把 mem / written / cap 都展开
174
+ this.__dispatchDrop('disk-cap', size, {
175
+ memBytes: this.memBytes,
176
+ writtenBytes: this.writtenBytes,
177
+ diskCap: this.diskCap,
178
+ });
161
179
  return false;
162
180
  }
163
181
 
164
- // 内存路径:未溢出且 admission 通过(考虑 overhead;首条无论多大都收)
182
+ // 内存路径:与 MemoryQueue 一致——memBytes < memBudget 即接受(含 single overshoot)
165
183
  if (!this.spilled) {
166
- const pendingCount = this.memQueue.length - this.head;
167
- const cost = this.memBytes + pendingCount * ENTRY_OVERHEAD + size + ENTRY_OVERHEAD;
168
- if (pendingCount === 0 || cost <= this.memBudget) {
184
+ const memFits = this.memBytes < this.memBudget;
185
+ // fsBroken 降级模式:spill 不可用 mem 桶就是事实上的容量层。
186
+ // 此时 bypass 命中允许 overshoot(与 MemoryQueue 镜像),保白名单消息不被误报 fs-error。
187
+ // 健康路径下 mem 满仍走 spill(不在此处豁免),避免 mem 无界增长违背 spill 设计目标。
188
+ //
189
+ // 设计取舍:fsBroken + bypass 路径下 mem 桶**不再**有显式硬上限——bypass 已豁免 diskCap admission,
190
+ // 这里也豁免 memBudget。理论上同时持续 fsBroken + 高密度 bypass 命中可堆到 OOM,仅靠 maxMessageBytes
191
+ // 单条 50 MB 兜底。**有意识的接受**:业务侧 agent 响应流量由用户活动自然封顶(与"不做 OOM 防御性
192
+ // 检测"项目原则一致);后续诊断完整性由 monitor.summarize 在 close 时透传 residualStats.fsBroken
193
+ // 让运维侧拿到降级信号即可。请勿在 review 中把"无硬上限"当 P1 误报。
194
+ const bypassOvershoot = !memFits && this.fsBroken && getIsBypass();
195
+ if (memFits || bypassOvershoot) {
169
196
  this.memQueue.push(jsonStr);
170
197
  this.memBytes += size;
171
198
  this.__wakeOne();
@@ -191,6 +218,8 @@ class FileBackedQueue {
191
218
  return false;
192
219
  }
193
220
  this.spilled = true;
221
+ // 文件层翻转 false→true:边沿信号,让监视器记录"开始用磁盘"
222
+ this.__dispatchSpillStart();
194
223
  }
195
224
 
196
225
  try {
@@ -232,6 +261,10 @@ class FileBackedQueue {
232
261
  async clear() {
233
262
  return await this.mutex.withLock(async () => {
234
263
  if (this.destroyed) return;
264
+ // 与 __dropFile 对称:在重置 spilled 前抓快照,wasSpilled 时配对调 onSpillEnd,
265
+ // 让监视器 spillActive 复位;否则下一轮真实 spill-start 会被监视器幂等吞掉
266
+ const drainedBytes = this.writtenBytes;
267
+ const wasSpilled = this.spilled;
235
268
  await this.__closeWriteStream();
236
269
  try {
237
270
  await fs.rm(this.filePath, { force: true });
@@ -248,6 +281,7 @@ class FileBackedQueue {
248
281
  this.fsBroken = false;
249
282
  this.writeErr = null;
250
283
  this.lastFsErr = null;
284
+ if (wasSpilled) this.__dispatchSpillEnd(drainedBytes);
251
285
  });
252
286
  }
253
287
 
@@ -348,6 +382,7 @@ class FileBackedQueue {
348
382
  for (const w of toWake) w.resolve();
349
383
  }
350
384
 
385
+ // 与 MemoryQueue 一致:诊断职责完全交给注入的 onDrop(监视器做边沿去抖 + 状态翻转 log)
351
386
  __dispatchDrop(reason, size, err) {
352
387
  try {
353
388
  this.onDrop?.(reason, size, err);
@@ -355,7 +390,22 @@ class FileBackedQueue {
355
390
  /* c8 ignore next 2 -- onDrop throwing is caller's bug */
356
391
  this.logger?.warn?.('fbq.onDrop threw', cbErr);
357
392
  }
358
- this.logger?.warn?.('fbq.drop', { reason, size, err: err?.message });
393
+ }
394
+
395
+ __dispatchSpillStart() {
396
+ try { this.onSpillStart?.(); }
397
+ catch (cbErr) {
398
+ /* c8 ignore next 2 -- onSpillStart throwing is caller's bug */
399
+ this.logger?.warn?.('fbq.onSpillStart threw', cbErr);
400
+ }
401
+ }
402
+
403
+ __dispatchSpillEnd(drainedBytes) {
404
+ try { this.onSpillEnd?.(drainedBytes); }
405
+ catch (cbErr) {
406
+ /* c8 ignore next 2 -- onSpillEnd throwing is caller's bug */
407
+ this.logger?.warn?.('fbq.onSpillEnd threw', cbErr);
408
+ }
359
409
  }
360
410
 
361
411
  __isBypass(jsonStr) {
@@ -470,8 +520,7 @@ class FileBackedQueue {
470
520
  let cumPayload = 0; // 仅 payload
471
521
  let stoppedAtEof = true;
472
522
 
473
- const pendingCount = this.memQueue.length - this.head;
474
- const baseCost = this.memBytes + pendingCount * ENTRY_OVERHEAD;
523
+ const baseBytes = this.memBytes;
475
524
 
476
525
  const stream = createReadStream(this.filePath, {
477
526
  start: this.readOffset,
@@ -482,9 +531,9 @@ class FileBackedQueue {
482
531
  try {
483
532
  for await (const line of rl) {
484
533
  const sz = Buffer.byteLength(line, 'utf8');
485
- // overhead 一致性:admission 侧已用 overhead,refill 侧同步考虑
486
- const newLinesCost = newLines.length * ENTRY_OVERHEAD;
487
- if (newLines.length > 0 && baseCost + cumPayload + newLinesCost + sz + ENTRY_OVERHEAD > this.memBudget) {
534
+ // admission 一致(current threshold):当前 mem 总字节 ≥ memBudget 时停止,
535
+ // 允许首条 single overshoot;后续若仍超阈值再停。
536
+ if (newLines.length > 0 && baseBytes + cumPayload >= this.memBudget) {
488
537
  stoppedAtEof = false;
489
538
  break;
490
539
  }
@@ -531,6 +580,9 @@ class FileBackedQueue {
531
580
  }
532
581
 
533
582
  async __dropFile() {
583
+ // 抓 drainedBytes 在重置前——让监视器拿到"删除时这文件累计写入了多少字节"
584
+ const drainedBytes = this.writtenBytes;
585
+ const wasSpilled = this.spilled;
534
586
  await this.__closeWriteStream();
535
587
  try {
536
588
  await fs.rm(this.filePath, { force: true });
@@ -542,6 +594,9 @@ class FileBackedQueue {
542
594
  this.writtenBytes = 0;
543
595
  this.readOffset = 0;
544
596
  this.writeErr = null;
597
+ // 文件层翻转 true→false:仅 drain 路径调 spill-end;故障删档(__handleFsError)/
598
+ // 清理离场(destroy)不调,由 fs-broken / close 信号各自承载,避免语义混淆
599
+ if (wasSpilled) this.__dispatchSpillEnd(drainedBytes);
545
600
  }
546
601
  }
547
602
 
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * 设计要点:
9
9
  * - 工厂函数 + 闭包,不是 class(与项目其它工具模块一致)
10
- * - 5 个内部标量 + 1 idempotent flag 跟踪状态
10
+ * - 6 个内部状态标量(dropCount / dropBytes / overflowActive / spillActive / fsBroken / lastReason)
11
+ * + 1 个 idempotent flag(summarized)跟踪状态
11
12
  * - logger / remoteLog 调用一律 try/catch 包裹,自身抛不传染调用方
12
13
  * - maybeEmitOverflowEnd 反抖动(候选 A):仅当 memCount===0 && writtenBytes===0 才翻转
13
14
  * B-stage1 阶段 writtenBytes 恒 0(MemoryQueue),等价 memCount===0
@@ -17,18 +18,27 @@
17
18
  * overflow-start 的 queueBytes token:现行取 memBytes,本模块取被拒消息 size,
18
19
  * 因为监视器不持队列深度——属可接受漂移):
19
20
  *
20
- * warn / remoteLog: rpc-queue.overflow-start conn=X queueBytes=N (queue-full)
21
- * warn / remoteLog: rpc-queue.disk-cap-start conn=X size=N (disk-cap)
22
- * warn / remoteLog: rpc-queue.fs-broken conn=X errno=X msg= (fs-error,sticky)
23
- * warn: [rpc-queue conn=X] oversize size=N (每条独立)
21
+ * warn / remoteLog: rpc-queue.overflow-start conn=X queueBytes=N (queue-full)
22
+ * warn / remoteLog: rpc-queue.disk-cap-start conn=X size=N memBytes=M
23
+ * writtenBytes=W diskCap=D (disk-cap)
24
+ * warn / remoteLog: rpc-queue.fs-broken conn=X errno=X msg= (fs-error,sticky)
25
+ * warn: [rpc-queue conn=X] oversize size=N (每条独立)
24
26
  * info / remoteLog: rpc-queue.overflow-end conn=X dropped=N droppedBytes=M
25
- * remoteLog: rpc-queue.close conn=X dropped=N droppedBytes=M
27
+ * info / remoteLog: rpc-queue.spill-start conn=X (FBQ 文件创建)
28
+ * info / remoteLog: rpc-queue.spill-end conn=X drainedBytes=N (FBQ 文件 drain 删除)
29
+ * warn / remoteLog: rpc-queue.close conn=X dropped=N droppedBytes=M
26
30
  * residualChunks=K residualBytes=L
27
31
  * residualDiskBytes=X residualWrittenBytes=Y
28
- * fsBroken=bool lastReason=str
32
+ * fsBroken=bool spillActive=bool lastReason=str
33
+ * (anomaly-only;本地 log 与 remoteLog 同字段)
34
+ *
35
+ * spill-start / spill-end 是文件级状态翻转信号:边沿触发,FBQ 在 spilled false→true / true→false
36
+ * 时通过 onSpillStart / onSpillEnd 钩子回调。与 disk-cap-start 是不同维度的事件——
37
+ * disk-cap-start 表示 admission 拒收(队列总占用顶到阈值),spill-start 表示物理文件被创建。
38
+ * MemoryQueue 不调这两个钩子,纯内存路径下不会有 spill 信号。
29
39
  *
30
40
  * close 日志含两组 disk token(residualDiskBytes/residualWrittenBytes)是为 FBQ 阶段
31
- * 诊断完整性预留——B-stage1 阶段它们恒 0;B-stage2 切到 FileBackedQueue 时承载磁盘残留信息。
41
+ * 诊断完整性预留——MemoryQueue 路径下它们恒 0;FBQ 路径下承载磁盘残留信息。
32
42
  */
33
43
 
34
44
  import { remoteLog } from '../remote-log.js';
@@ -38,16 +48,19 @@ import { remoteLog } from '../remote-log.js';
38
48
  * @param {string} opts.connId - 连接 ID,用于日志 conn=${connId} token
39
49
  * @param {{ warn?: Function, info?: Function, error?: Function }} opts.logger
40
50
  * @returns {{
41
- * onDrop: (reason: string, size: number, err?: { code?: string, message?: string }) => void,
51
+ * onDrop: (reason: string, size: number, err?: { code?: string, message?: string, memBytes?: number, writtenBytes?: number, diskCap?: number }) => void,
52
+ * onSpillStart: () => void,
53
+ * onSpillEnd: (drainedBytes: number) => void,
42
54
  * maybeEmitOverflowEnd: (stats: { memCount: number, writtenBytes: number }) => void,
43
- * summarize: (residualStats?: { memCount?: number, memBytes?: number, diskBytes?: number, writtenBytes?: number }) => void,
44
- * getStats: () => { dropCount: number, dropBytes: number, overflowActive: boolean, fsBroken: boolean, lastReason: string|null },
55
+ * summarize: (residualStats?: { memCount?: number, memBytes?: number, diskBytes?: number, writtenBytes?: number, fsBroken?: boolean }) => void,
56
+ * getStats: () => { dropCount: number, dropBytes: number, overflowActive: boolean, fsBroken: boolean, lastReason: string|null, spillActive: boolean },
45
57
  * }}
46
58
  */
47
59
  export function createRpcDropMonitor({ connId, logger }) {
48
60
  let dropCount = 0;
49
61
  let dropBytes = 0;
50
62
  let overflowActive = false;
63
+ let spillActive = false; // 文件层翻转标志:FBQ 物理文件存在时 true,drain 删除时 false
51
64
  let fsBroken = false; // sticky:一旦 true 不复位
52
65
  let lastReason = null;
53
66
  let summarized = false; // summarize 幂等 flag
@@ -86,8 +99,13 @@ export function createRpcDropMonitor({ connId, logger }) {
86
99
  if (reason === 'disk-cap') {
87
100
  if (!overflowActive) {
88
101
  overflowActive = true;
89
- safeWarn(`disk-cap-start size=${size}`);
90
- safeRemoteLog(`rpc-queue.disk-cap-start conn=${connId} size=${size}`);
102
+ // 把 mem/written/cap 三个分量都带上:disk-cap 不是"文件满了"语义,
103
+ // mem+writtenBytes 总占用顶到 diskCap 阈值,分量让运维能直接看到谁顶到 cap。
104
+ const memBytes = err?.memBytes ?? 0;
105
+ const writtenBytes = err?.writtenBytes ?? 0;
106
+ const diskCap = err?.diskCap ?? 0;
107
+ safeWarn(`disk-cap-start size=${size} memBytes=${memBytes} writtenBytes=${writtenBytes} diskCap=${diskCap}`);
108
+ safeRemoteLog(`rpc-queue.disk-cap-start conn=${connId} size=${size} memBytes=${memBytes} writtenBytes=${writtenBytes} diskCap=${diskCap}`);
91
109
  }
92
110
  return;
93
111
  }
@@ -109,6 +127,23 @@ export function createRpcDropMonitor({ connId, logger }) {
109
127
  // 未知 reason:仅累加,无 log(防御性)
110
128
  }
111
129
 
130
+ // 文件创建:FBQ spilled 翻转 false→true 时调一次。边沿触发,幂等(重复 active 不重 emit)。
131
+ function onSpillStart() {
132
+ if (spillActive) return;
133
+ spillActive = true;
134
+ safeInfo('spill-start');
135
+ safeRemoteLog(`rpc-queue.spill-start conn=${connId}`);
136
+ }
137
+
138
+ // 文件删除(drain 完成):FBQ spilled 翻转 true→false 时调一次。drainedBytes = __dropFile 前的 writtenBytes。
139
+ // 故障删档(__handleFsError 内的 fs.rm)由 fs-broken 信号承载,不复用此钩子。
140
+ function onSpillEnd(drainedBytes) {
141
+ if (!spillActive) return;
142
+ spillActive = false;
143
+ safeInfo(`spill-end drainedBytes=${drainedBytes}`);
144
+ safeRemoteLog(`rpc-queue.spill-end conn=${connId} drainedBytes=${drainedBytes}`);
145
+ }
146
+
112
147
  function maybeEmitOverflowEnd(stats) {
113
148
  if (!overflowActive) return;
114
149
  if (!stats) return; // 防御:调用方应传 queue.stats(),但 stats 为 null/undefined 时安全跳过
@@ -127,15 +162,26 @@ export function createRpcDropMonitor({ connId, logger }) {
127
162
  const residualBytes = residualStats?.memBytes ?? 0;
128
163
  const residualDiskBytes = residualStats?.diskBytes ?? 0;
129
164
  const residualWrittenBytes = residualStats?.writtenBytes ?? 0;
130
- const hasAnomaly = overflowActive || fsBroken || dropCount > 0
165
+ // 队列实际 fsBroken 状态:FBQ 经异步路径(writeStream emit error / refill stat err / bypass overshoot 全程仅 mem)可让 fsBroken=true monitor 从未收到 onDrop('fs-error')。
166
+ // 这里用 residualStats.fsBroken 兜住,让 close 日志反映队列真实降级状态,避免运维侧只能从内部 fsBroken 标量看到"没坏"的假象。
167
+ const residualFsBroken = residualStats?.fsBroken === true;
168
+ const effectiveFsBroken = fsBroken || residualFsBroken;
169
+ // spillActive 也作为 anomaly 信号:destroy 时 spill 文件没 drain 完(onSpillEnd 没触发)
170
+ // 通常会让 residualWrittenBytes/residualDiskBytes>0 间接触发 close 日志,但显式纳入
171
+ // spillActive 避免日后 stats 路径漂移(如延迟清空)让"以 spill 状态结束"静默
172
+ const hasAnomaly = overflowActive || spillActive || effectiveFsBroken || dropCount > 0
131
173
  || residualChunks > 0 || residualDiskBytes > 0 || residualWrittenBytes > 0;
132
174
  if (hasAnomaly) {
133
- safeRemoteLog(
134
- `rpc-queue.close conn=${connId} dropped=${dropCount} droppedBytes=${dropBytes}`
175
+ // 字段表(顺序与 remoteLog 完全一致,便于 server / 本地 log 对齐 grep)
176
+ const fields = `dropped=${dropCount} droppedBytes=${dropBytes}`
135
177
  + ` residualChunks=${residualChunks} residualBytes=${residualBytes}`
136
178
  + ` residualDiskBytes=${residualDiskBytes} residualWrittenBytes=${residualWrittenBytes}`
137
- + ` fsBroken=${fsBroken} lastReason=${lastReason ?? 'none'}`,
138
- );
179
+ + ` fsBroken=${effectiveFsBroken} spillActive=${spillActive}`
180
+ + ` lastReason=${lastReason ?? 'none'}`;
181
+ // 本地 log 镜像:close 是 session 收尾的异常汇总,开发者主要看本地 log 排查。
182
+ // 与 overflow-start/disk-cap-start/fs-broken 同级别用 warn——只有 anomaly 时才发。
183
+ safeWarn(`close ${fields}`);
184
+ safeRemoteLog(`rpc-queue.close conn=${connId} ${fields}`);
139
185
  }
140
186
  overflowActive = false;
141
187
  }
@@ -145,10 +191,11 @@ export function createRpcDropMonitor({ connId, logger }) {
145
191
  dropCount,
146
192
  dropBytes,
147
193
  overflowActive,
194
+ spillActive,
148
195
  fsBroken,
149
196
  lastReason,
150
197
  };
151
198
  }
152
199
 
153
- return { onDrop, maybeEmitOverflowEnd, summarize, getStats };
200
+ return { onDrop, onSpillStart, onSpillEnd, maybeEmitOverflowEnd, summarize, getStats };
154
201
  }
@@ -9,11 +9,11 @@ import { isAgentRunResponse } from './agent-run-response.js';
9
9
  import { remoteLog } from '../remote-log.js';
10
10
 
11
11
  // rpc DC 发送队列实现选择(B-stage2 B9b)。
12
- // - 'mem':MemoryQueue(当前生产默认)—— 不碰 fs,溢出即 drop;FBQ 未充分本地验证前先用此模式发布
13
- // - 'fbq':FileBackedQueue —— 长时间后台 / ICE 恢复等慢消化场景溢出到磁盘
12
+ // - 'fbq':FileBackedQueue(当前生产默认)—— 长时间后台 / ICE 恢复等慢消化场景溢出到磁盘
13
+ // - 'mem':MemoryQueue —— 不碰 fs,溢出即 drop(紧急回退用,0.20.1~0.20.2 期间临时启用)
14
14
  // 当 'fbq' 但 queueDir 不可用(bridge 启动期 plan-2 prep 失败)时自动降级到 'mem',避免阻塞 webrtc 装配。
15
- // 单点常量;构造时可通过 `rpcQueueImpl` opt 覆盖(测试用)。生产侧改回 'fbq' 只需翻这一行。
16
- const RPC_QUEUE_IMPL = 'mem';
15
+ // 单点常量;构造时可通过 `rpcQueueImpl` opt 覆盖(测试用)。紧急回退到 'mem' 只需翻这一行。
16
+ const RPC_QUEUE_IMPL = 'fbq';
17
17
 
18
18
  // FBQ 装配兜底:bridge 启动期 measureDiskCap 失败 → __diskCap=null → 这里兜底 1GB
19
19
  const ONE_GB = 1024 * 1024 * 1024;
@@ -152,7 +152,7 @@ export class WebRtcPeer {
152
152
  await Promise.all(closing);
153
153
  }
154
154
 
155
- /** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 MemoryQueue + RpcDcSender 流控) */
155
+ /** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 FBQ/MemoryQueue + RpcDcSender 流控) */
156
156
  broadcast(payload) {
157
157
  let jsonStr;
158
158
  try {
@@ -664,7 +664,10 @@ export class WebRtcPeer {
664
664
  // 但 destroy 是 fire-and-forget,回调在 mutex 异步内才 fire 时字段已 null。
665
665
  sess.rpcDcSender?.close();
666
666
  const monRef = sess.rpcDropMonitor;
667
- sess.rpcQueue?.destroy((residual) => { monRef?.summarize(residual); }).catch(() => {});
667
+ sess.rpcQueue?.destroy((residual) => { monRef?.summarize(residual); }).catch((err) => {
668
+ /* c8 ignore next 2 -- destroy 在生产路径稳定(mutex 内异常极冷),仅诊断兜底 */
669
+ this.logger.warn?.(`${this.__rtcTag} [${connId}] rpc queue destroy failed in dc.onclose: ${err?.message ?? err}`);
670
+ });
668
671
  sess.rpcDcSender = null;
669
672
  sess.rpcQueue = null;
670
673
  sess.rpcConsumeLoop = null;
@@ -691,18 +694,30 @@ export class WebRtcPeer {
691
694
  // 罕见:session 已有旧三件套(UI 重建 rpc DC 等)。先 await close + destroy 旧实例
692
695
  // 再造新实例,避免新旧 queue/sender 在同一 session 上并存。summarize 走 destroy 的
693
696
  // onBeforeClear 钩子,确保拿到原子残留快照(in-flight enqueue 已落地)。
697
+ //
698
+ // race 闭合:先**同步**捕获旧引用 + 把四个字段置 null,再 await 旧实例 destroy。
699
+ // 否则 sync ondatachannel 已把 rpcChannel 切到新 dc(readyState='open'),但 rpcQueue
700
+ // 等字段保留到 await destroy 完成;这窗口里 broadcast / sendTo 看到 "rpcQueue=旧 +
701
+ // rpcChannel=新 dc open" 会把消息塞进即将销毁的旧 queue。sync nullify 后窗口里 broadcast
702
+ // 看到 rpcQueue=null 跳过(与"通道未就绪"等价)。
694
703
  if (session.rpcDcSender || session.rpcQueue) {
695
- session.rpcDcSender?.close();
704
+ const oldSender = session.rpcDcSender;
705
+ const oldQueue = session.rpcQueue;
706
+ const oldLoop = session.rpcConsumeLoop;
696
707
  const oldMonitor = session.rpcDropMonitor;
697
- if (session.rpcQueue) await session.rpcQueue.destroy((residual) => { oldMonitor?.summarize(residual); });
698
- if (session.rpcConsumeLoop) await session.rpcConsumeLoop.catch(() => { /* c8 ignore next -- 极冷防御 */ });
708
+ session.rpcDcSender = null;
709
+ session.rpcQueue = null;
710
+ session.rpcConsumeLoop = null;
699
711
  session.rpcDropMonitor = null;
712
+ oldSender?.close();
713
+ if (oldQueue) await oldQueue.destroy((residual) => { oldMonitor?.summarize(residual); });
714
+ if (oldLoop) await oldLoop.catch(() => { /* c8 ignore next -- 极冷防御 */ });
700
715
  }
701
716
  // 创建 monitor。必须在 new Queue 之前——Queue 的 onDrop 接 monitor.onDrop。
702
717
  // monitor 是局部变量,stale 路径下函数返回后自然 GC,不挂 session 字段(无 drop 可汇总)。
703
718
  const monitor = createRpcDropMonitor({ connId, logger: this.logger });
704
719
 
705
- // queue 实例选择(B-stage2 B9b):默认取模块级 RPC_QUEUE_IMPL(当前 'mem');
720
+ // queue 实例选择(B-stage2 B9b):默认取模块级 RPC_QUEUE_IMPL(当前 'fbq');
706
721
  // 'fbq' 模式下若 queueDir 不可用则降级到 mem,避免阻塞装配。
707
722
  // 同 connId race 隔离(决策 4):FBQ id 加唯一后缀 ${connId}-${ts}-${nonce},
708
723
  // 让新旧实例文件名物理不同,destroy/init 期间互不踩踏。MemoryQueue 不碰 fs,无此需求。
@@ -716,10 +731,14 @@ export class WebRtcPeer {
716
731
  id: `${connId}-${Date.now()}-${randomUUID().slice(0, 8)}`,
717
732
  dir: this.__queueDir,
718
733
  memBudget: RPC_QUEUE_MEM_BUDGET,
734
+ // diskCap 是 mem + 已写文件累计字节(writtenBytes)的总占用阈值,不是单纯文件 size
735
+ // 上限——因此文件实际峰值约为 diskCap - memBudget;详见 docs/rpc-dc-file-queue.md
719
736
  diskCap: this.__getDiskCap?.() ?? ONE_GB,
720
737
  maxMessageBytes: MAX_SINGLE_MSG_BYTES,
721
738
  bypassAdmission: isAgentRunResponse,
722
739
  onDrop: monitor.onDrop,
740
+ onSpillStart: monitor.onSpillStart,
741
+ onSpillEnd: monitor.onSpillEnd,
723
742
  logger: this.logger,
724
743
  })
725
744
  : new MemoryQueue({
@@ -822,7 +841,7 @@ export class WebRtcPeer {
822
841
  ? (() => {
823
842
  const s = q.stats();
824
843
  // memCount 沿用历史 token 名 queueLen(不改名);queue.stats() 6 个字段(含 4 个
825
- // 磁盘字段,MemoryQueue 阶段恒 0/false)+ monitor.getStats() 提供 dropCount/dropBytes。
844
+ // 磁盘字段,MemoryQueue 路径下恒 0/false)+ monitor.getStats() 提供 dropCount/dropBytes。
826
845
  // 输出文本 8 token 字节级保持与现行格式一致。
827
846
  // monitor 与 queue 在 setupDataChannel 末尾同 tick 装载,q 真值时 monitor 必定也存在;
828
847
  // `?? { ... }` 是防御性兜底,结构上不可达。