@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
|
@@ -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 =
|
|
159
|
+
const start = monoNowMs();
|
|
153
160
|
let attempts = 0;
|
|
154
161
|
let lastReason = '';
|
|
155
162
|
let lastVersion = '';
|
|
156
163
|
|
|
157
|
-
while (
|
|
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:
|
|
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 (
|
|
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:
|
|
191
|
+
elapsedMs: monoNowMs() - start,
|
|
185
192
|
lastReason,
|
|
186
193
|
lastVersion,
|
|
187
194
|
};
|
package/src/realtime-bridge.js
CHANGED
|
@@ -279,7 +279,8 @@ export class RealtimeBridge {
|
|
|
279
279
|
return;
|
|
280
280
|
}
|
|
281
281
|
try {
|
|
282
|
-
this.gatewayWs.
|
|
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 {
|
|
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
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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] -
|
|
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'
|
|
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
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
//
|
|
182
|
+
// 内存路径:与 MemoryQueue 一致——memBytes < memBudget 即接受(含 single overshoot)
|
|
165
183
|
if (!this.spilled) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
486
|
-
|
|
487
|
-
if (newLines.length > 0 &&
|
|
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
|
-
* -
|
|
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
|
|
21
|
-
* warn / remoteLog: rpc-queue.disk-cap-start conn=X size=N
|
|
22
|
-
*
|
|
23
|
-
* warn:
|
|
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:
|
|
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
|
-
* 诊断完整性预留——
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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=${
|
|
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
|
-
// - '
|
|
13
|
-
// - '
|
|
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
|
|
16
|
-
const RPC_QUEUE_IMPL = '
|
|
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
|
|
704
|
+
const oldSender = session.rpcDcSender;
|
|
705
|
+
const oldQueue = session.rpcQueue;
|
|
706
|
+
const oldLoop = session.rpcConsumeLoop;
|
|
696
707
|
const oldMonitor = session.rpcDropMonitor;
|
|
697
|
-
|
|
698
|
-
|
|
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(当前 '
|
|
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
|
|
844
|
+
// 磁盘字段,MemoryQueue 路径下恒 0/false)+ monitor.getStats() 提供 dropCount/dropBytes。
|
|
826
845
|
// 输出文本 8 token 字节级保持与现行格式一致。
|
|
827
846
|
// monitor 与 queue 在 setupDataChannel 末尾同 tick 装载,q 真值时 monitor 必定也存在;
|
|
828
847
|
// `?? { ... }` 是防御性兜底,结构上不可达。
|