@coclaw/openclaw-coclaw 0.19.2 → 0.20.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.19.2",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -39,7 +39,10 @@
39
39
  "openclaw": {
40
40
  "extensions": [
41
41
  "./index.js"
42
- ]
42
+ ],
43
+ "install": {
44
+ "minHostVersion": ">=2026.2.19"
45
+ }
43
46
  },
44
47
  "scripts": {
45
48
  "build": "echo 'No build step needed (pure ES modules)'",
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * state.js — upgrade-state.json 与 upgrade-log.jsonl 读写
3
3
  *
4
- * 状态文件存储在 OpenClaw state 目录下(~/.openclaw/coclaw/),
5
- * bindings.json 共享同一目录。路径解析优先级:
4
+ * 例外:本文件 gateway 主进程与 auto-upgrade worker 子进程共用,worker 没 runtime
5
+ * 注入,故保留独立的双轨解析(不走 claw-paths.js):
6
6
  * 1. runtime.state.resolveStateDir()(gateway 进程内)
7
- * 2. OPENCLAW_STATE_DIR 环境变量(worker 进程,由 spawner 传入)
7
+ * 2. OPENCLAW_STATE_DIR 环境变量(worker 子进程,由 spawner 传入)
8
8
  * 3. ~/.openclaw(兜底默认值)
9
9
  */
10
10
  import fs from 'node:fs/promises';
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
 
4
+ import { agentSessionsDir } from '../claw-paths.js';
5
5
  import { atomicWriteJsonFile } from '../utils/atomic-write.js';
6
6
  import { createMutex } from '../utils/mutex.js';
7
7
 
@@ -28,13 +28,13 @@ function emptyStore() {
28
28
  export class ChatHistoryManager {
29
29
  /**
30
30
  * @param {object} [opts]
31
- * @param {string} [opts.rootDir] - agents 根目录,默认 ~/.openclaw/agents
32
31
  * @param {object} [opts.logger]
32
+ * @param {Function} [opts.resolveSessionsDir] - 测试注入:自定义 sessions 目录解析
33
33
  * @param {Function} [opts.readFile] - 测试注入
34
34
  * @param {Function} [opts.writeJsonFile] - 测试注入
35
35
  */
36
36
  constructor(opts = {}) {
37
- this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
37
+ this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
38
38
  this.__logger = opts.logger ?? console;
39
39
  /* c8 ignore next 2 -- ?? fallback:测试始终注入 */
40
40
  this.__readFile = opts.readFile ?? fs.readFile;
@@ -48,7 +48,7 @@ export class ChatHistoryManager {
48
48
  }
49
49
 
50
50
  __sessionsDir(agentId) {
51
- return nodePath.join(this.__rootDir, agentId, 'sessions');
51
+ return this.__resolveSessionsDir(agentId);
52
52
  }
53
53
 
54
54
  __historyFilePath(agentId) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * claw-paths.js — OpenClaw 路径解析的唯一入口(gateway 主进程内)
3
+ *
4
+ * 设计原则:
5
+ * - clawStateDir:高度稳定的 OpenClaw API(自 2026-02-19 起注入 runtime),直接信任
6
+ * - session 三件套(store / transcript / sessions dir):自 2026-03-16 起才注入 runtime,
7
+ * 做防御性 fallback,回退到 OpenClaw 自家长期稳定的固定布局
8
+ * - 不读 OPENCLAW_STATE_DIR 环境变量;不回退到 ~/.openclaw 家目录
9
+ * - runtime 缺失或字段缺失(除 session helper 外)即抛错,bug 早暴露
10
+ *
11
+ * 例外:auto-upgrade/state.js 是 gateway 与 worker 子进程共用的,worker 没 runtime,
12
+ * 故那个文件保留独立的 env 兜底,不走本模块。
13
+ */
14
+ import nodePath from 'node:path';
15
+
16
+ import { getRuntime } from './runtime.js';
17
+
18
+ const CHANNEL_ID = 'coclaw';
19
+
20
+ /**
21
+ * OpenClaw 真实 state 目录
22
+ * @returns {string}
23
+ */
24
+ export function clawStateDir() {
25
+ const rt = getRuntime();
26
+ if (!rt?.state?.resolveStateDir) {
27
+ throw new Error('claw-paths: runtime not injected; cannot resolve state dir');
28
+ }
29
+ return rt.state.resolveStateDir();
30
+ }
31
+
32
+ /**
33
+ * CoClaw 自管文件根目录(bindings / settings / device-identity / rpc-queues)
34
+ * @returns {string}
35
+ */
36
+ export function pluginDir() {
37
+ return nodePath.join(clawStateDir(), CHANNEL_ID);
38
+ }
39
+
40
+ /**
41
+ * sessions.json 全路径(session-manager 读会话索引用)
42
+ *
43
+ * 优先 runtime helper(自 2026-03-16 起),允许跟随 OpenClaw 自定义 store 配置;
44
+ * runtime 没注入 helper 时回退到固定布局。
45
+ * @param {string} agentId
46
+ * @returns {string}
47
+ */
48
+ export function sessionStorePath(agentId) {
49
+ const rt = getRuntime();
50
+ const helper = rt?.agent?.session?.resolveStorePath;
51
+ if (helper) {
52
+ return helper(undefined, { agentId });
53
+ }
54
+ return nodePath.join(clawStateDir(), 'agents', agentId, 'sessions', 'sessions.json');
55
+ }
56
+
57
+ /**
58
+ * sessions 所在目录(topic / chat-history 写自己的扩展文件用)
59
+ *
60
+ * 通过 sessionStorePath 反推 dirname,使 CoClaw 扩展文件随 OpenClaw 真实存储位置走。
61
+ * @param {string} agentId
62
+ * @returns {string}
63
+ */
64
+ export function agentSessionsDir(agentId) {
65
+ return nodePath.dirname(sessionStorePath(agentId));
66
+ }
67
+
68
+ /**
69
+ * 单条 session 的 JSONL transcript 全路径(session-manager 读单会话用)
70
+ * @param {string} sessionId
71
+ * @param {string} agentId
72
+ * @param {{ sessionFile?: string }} [entry] - sessions.json 索引条目,可能含 sessionFile 覆盖
73
+ * @returns {string}
74
+ */
75
+ export function sessionTranscriptPath(sessionId, agentId, entry) {
76
+ const rt = getRuntime();
77
+ const helper = rt?.agent?.session?.resolveSessionFilePath;
78
+ if (helper) {
79
+ return helper(sessionId, entry, { agentId });
80
+ }
81
+ return nodePath.join(agentSessionsDir(agentId), `${sessionId}.jsonl`);
82
+ }
83
+
84
+ export { CHANNEL_ID };
package/src/config.js CHANGED
@@ -1,27 +1,16 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
 
5
- import { getRuntime } from './runtime.js';
4
+ import { CHANNEL_ID, pluginDir } from './claw-paths.js';
6
5
  import { atomicWriteJsonFile } from './utils/atomic-write.js';
7
6
  import { createMutex } from './utils/mutex.js';
8
7
 
9
8
  export const DEFAULT_ACCOUNT_ID = 'default';
10
- export const CHANNEL_ID = 'coclaw';
9
+ export { CHANNEL_ID };
11
10
  const BINDINGS_FILENAME = 'bindings.json';
12
11
 
13
- export function resolveStateDir() {
14
- const rt = getRuntime();
15
- if (rt?.state?.resolveStateDir) {
16
- return rt.state.resolveStateDir();
17
- }
18
- return process.env.OPENCLAW_STATE_DIR
19
- ? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
20
- : nodePath.join(os.homedir(), '.openclaw');
21
- }
22
-
23
12
  export function getBindingsPath() {
24
- return nodePath.join(resolveStateDir(), CHANNEL_ID, BINDINGS_FILENAME);
13
+ return nodePath.join(pluginDir(), BINDINGS_FILENAME);
25
14
  }
26
15
 
27
16
  function toRecord(value) {
@@ -1,12 +1,10 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
- import os from 'node:os';
4
3
  import nodePath from 'node:path';
5
4
 
6
- import { getRuntime } from './runtime.js';
5
+ import { pluginDir } from './claw-paths.js';
7
6
  import { atomicWriteFileSync } from './utils/atomic-write.js';
8
7
 
9
- const CHANNEL_ID = 'coclaw';
10
8
  const IDENTITY_FILENAME = 'device-identity.json';
11
9
 
12
10
  // Ed25519 SPKI 前缀(固定 12 字节),公钥裸字节从 SPKI DER 中截取
@@ -27,22 +25,12 @@ function normalizeMetadataForAuth(value) {
27
25
  return trimmed ? toLowerAscii(trimmed) : '';
28
26
  }
29
27
 
30
- function resolveStateDir() {
31
- const rt = getRuntime();
32
- if (rt?.state?.resolveStateDir) {
33
- return rt.state.resolveStateDir();
34
- }
35
- return process.env.OPENCLAW_STATE_DIR
36
- ? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
37
- : nodePath.join(os.homedir(), '.openclaw');
38
- }
39
-
40
28
  /**
41
29
  * 获取身份文件路径
42
30
  * @returns {string}
43
31
  */
44
32
  export function getIdentityPath() {
45
- return nodePath.join(resolveStateDir(), CHANNEL_ID, IDENTITY_FILENAME);
33
+ return nodePath.join(pluginDir(), IDENTITY_FILENAME);
46
34
  }
47
35
 
48
36
  /**
@@ -89,7 +77,7 @@ function generateIdentity() {
89
77
  * 加载或创建设备身份(Ed25519 密钥对)
90
78
  *
91
79
  * 存储格式与 OpenClaw device-identity.ts 保持一致。
92
- * @param {string} [filePath] - 自定义路径,默认 ~/.openclaw/coclaw/device-identity.json
80
+ * @param {string} [filePath] - 自定义路径,默认 <state-dir>/coclaw/device-identity.json
93
81
  * @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
94
82
  */
95
83
  export function loadOrCreateDeviceIdentity(filePath) {
@@ -1,9 +1,9 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
1
  import nodePath from 'node:path';
4
2
  import { WebSocket as WsWebSocket } from 'ws';
5
3
 
4
+ import { pluginDir } from './claw-paths.js';
6
5
  import { clearConfig, getBindingsPath, readConfig } from './config.js';
6
+ import { cleanupResiduals as defaultCleanupResiduals, measureDiskCap as defaultMeasureDiskCap } from './rpc-queue-startup.js';
7
7
  import { getHostName, readSettings } from './settings.js';
8
8
  import {
9
9
  loadOrCreateDeviceIdentity,
@@ -15,6 +15,7 @@ import { getRuntime } from './runtime.js';
15
15
  import { setSender as setRemoteLogSender, remoteLog } from './remote-log.js';
16
16
  import { getPluginVersion } from './plugin-version.js';
17
17
  import { getPlatformInfoLine } from './platform-info.js';
18
+ import { RunEventRoutes, DEFAULT_TTL_MS as RUN_EVENT_DEFAULT_TTL_MS, DEFAULT_SCAN_MS as RUN_EVENT_DEFAULT_SCAN_MS } from './rpc-routing/run-event-routes.js';
18
19
 
19
20
  const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
20
21
  const RECONNECT_MS = 10_000;
@@ -87,24 +88,18 @@ function maskUrlToken(url) {
87
88
  return url.replace(/([?&]token=)[^&]+/, '$1***');
88
89
  }
89
90
 
90
- /* c8 ignore start -- 仅在未注入 resolveGatewayAuthToken 时使用,依赖 runtime/env/文件系统 */
91
- function defaultResolveGatewayAuthToken() {
91
+ // 仅在未注入 resolveGatewayAuthToken 时使用,依赖 runtime / 环境变量
92
+ export function defaultResolveGatewayAuthToken() {
92
93
  const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
93
94
  if (envToken) {
94
95
  return envToken;
95
96
  }
96
97
  try {
97
98
  const rt = getRuntime();
98
- if (rt?.config?.loadConfig) {
99
- const cfg = rt.config.loadConfig();
100
- const token = cfg?.gateway?.auth?.token;
101
- return typeof token === 'string' && token.trim() ? token.trim() : '';
102
- }
103
- const cfgPath = process.env.OPENCLAW_CONFIG_PATH
104
- ? nodePath.resolve(process.env.OPENCLAW_CONFIG_PATH)
105
- : nodePath.join(os.homedir(), '.openclaw', 'openclaw.json');
106
- const raw = fs.readFileSync(cfgPath, 'utf8');
107
- const cfg = JSON.parse(raw);
99
+ if (!rt?.config?.loadConfig) {
100
+ return '';
101
+ }
102
+ const cfg = rt.config.loadConfig();
108
103
  const token = cfg?.gateway?.auth?.token;
109
104
  return typeof token === 'string' && token.trim() ? token.trim() : '';
110
105
  }
@@ -113,7 +108,6 @@ function defaultResolveGatewayAuthToken() {
113
108
  return '';
114
109
  }
115
110
  }
116
- /* c8 ignore stop */
117
111
 
118
112
  /**
119
113
  * WebSocket 桥接器:CoClaw server ↔ OpenClaw gateway
@@ -132,6 +126,8 @@ export class RealtimeBridge {
132
126
  * @param {number} [deps.gatewayReadyTimeoutMs] - __waitGatewayReady 默认超时(测试可注入短值)
133
127
  * @param {number} [deps.dcReqTtlMs] - UI 转发 RPC 路由表条目 TTL(测试可注入短值)
134
128
  * @param {number} [deps.dcReqScanMs] - UI 转发 RPC 路由表周期扫描间隔(测试可注入短值)
129
+ * @param {number} [deps.runEventRoutesTtlMs] - runId → connId 路由表条目 TTL(测试可注入短值)
130
+ * @param {number} [deps.runEventRoutesScanMs] - runId → connId 路由表周期扫描间隔(测试可注入短值)
135
131
  */
136
132
  constructor(deps = {}) {
137
133
  this.__readConfig = deps.readConfig ?? readConfig;
@@ -145,6 +141,11 @@ export class RealtimeBridge {
145
141
  this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
146
142
  this.__dcReqTtlMs = deps.dcReqTtlMs ?? DC_REQ_TTL_MS;
147
143
  this.__dcReqScanMs = deps.dcReqScanMs ?? DC_REQ_SCAN_MS;
144
+ this.__runEventRoutesTtlMs = deps.runEventRoutesTtlMs ?? RUN_EVENT_DEFAULT_TTL_MS;
145
+ this.__runEventRoutesScanMs = deps.runEventRoutesScanMs ?? RUN_EVENT_DEFAULT_SCAN_MS;
146
+ // rpc-queues/ 启动期预热钩子(B-stage1 plan-2)。仅供测试覆盖错误分支注入;生产路径走默认。
147
+ this.__cleanupRpcQueueResiduals = deps.cleanupRpcQueueResiduals ?? defaultCleanupResiduals;
148
+ this.__measureRpcQueueDiskCap = deps.measureRpcQueueDiskCap ?? defaultMeasureDiskCap;
148
149
 
149
150
  this.serverWs = null;
150
151
  this.gatewayWs = null;
@@ -179,6 +180,11 @@ export class RealtimeBridge {
179
180
  // 用于 res 帧按发起方单播;查不到时回退广播兜底(兼容旧 UI / 撞号 / 上游新增中间态字符串等)
180
181
  this.__dcPendingRequests = new Map();
181
182
  this.__dcPendingScanTimer = null;
183
+ // runId → connId 路由表:用于 event:agent 帧按发起方单播。
184
+ // 实例延迟到 start() 真 logger 到位时再 new;stop() destroy 后置 null。
185
+ this.__runEventRoutes = null;
186
+ // rpc DC 文件回退队列的磁盘容量(B-stage1 plan-2 探测,B-stage2 才消费)
187
+ this.__diskCap = null;
182
188
  }
183
189
 
184
190
  __resolveWebSocket() {
@@ -287,6 +293,8 @@ export class RealtimeBridge {
287
293
  this.gatewayPendingRequests.clear();
288
294
  // 清空 UI 转发 RPC 路由表:gateway 已断,不会再有响应回来;不主动通知 UI,由 UI 30/60s 超时兜底
289
295
  this.__dcPendingRequests.clear();
296
+ // 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
297
+ this.__runEventRoutes?.clear();
290
298
  }
291
299
 
292
300
  /** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
@@ -842,10 +850,28 @@ export class RealtimeBridge {
842
850
  if (payload.type === 'res' && typeof payload.id === 'string') {
843
851
  const info = this.__dcPendingRequests.get(payload.id);
844
852
  if (info) {
845
- // 终态才清条目;accepted 类中间态保留等下一帧
853
+ // runId 路由表维护:accepted 时 add(首发优先),非 accepted 时 remove。
854
+ // 写入要求 reqId 表命中以拿 connId;删除嵌在 reqId 命中分支内——
855
+ // 因 reqId 表 miss 意味着写入也未发生过(设计上等价 no-op),
856
+ // 极端错位(reqId 表先 TTL 过期、runId 表条目仍存)由 24h TTL 兜底。
857
+ const runId = payload.payload?.runId;
858
+ if (typeof runId === 'string' && runId) {
859
+ if (payload.payload?.status === 'accepted') {
860
+ this.__runEventRoutes?.add(runId, info.connId, payload.id);
861
+ this.logger.debug?.(`[coclaw/run-event-route] add runId=${runId} connId=${info.connId} reqId=${payload.id}`);
862
+ }
863
+ else {
864
+ this.__runEventRoutes?.remove(runId, payload.id);
865
+ this.logger.debug?.(`[coclaw/run-event-route] remove runId=${runId} reqId=${payload.id}`);
866
+ }
867
+ }
868
+ // 终态才清条目;accepted 类中间态保留等下一帧
846
869
  if (isFinalResMsg(payload)) {
847
870
  this.__dcPendingRequests.delete(payload.id);
871
+ this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${payload.id} reason=final-res`);
848
872
  }
873
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
874
+ this.logger.debug?.(`[coclaw/rpc-res-route] hit, reqId=${payload.id} → connId=${info.connId}`);
849
875
  // sendTo 阶段 1 改为 async(admission 决策 await);外层 listener 已是 async
850
876
  const delivered = await this.webrtcPeer?.sendTo(info.connId, payload);
851
877
  if (!delivered) {
@@ -856,6 +882,24 @@ export class RealtimeBridge {
856
882
  }
857
883
  return;
858
884
  }
885
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
886
+ this.logger.debug?.(`[coclaw/rpc-res-route] miss, broadcast, reqId=${payload.id}`);
887
+ }
888
+ // (c2) agent event 按 runId 单播:命中即送达,不退兜底广播;miss 走 (d) 兜底
889
+ if (payload.type === 'event' && payload.event === 'agent') {
890
+ const runId = payload.payload?.runId;
891
+ if (typeof runId === 'string' && runId) {
892
+ const connId = this.__runEventRoutes?.lookup(runId);
893
+ if (connId !== undefined) {
894
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
895
+ this.logger.debug?.(`[coclaw/run-event-route] hit, runId=${runId} → connId=${connId}`);
896
+ // sendTo 失败不打 log(PC 状态翻转日志已足够,drop 是正确语义)
897
+ await this.webrtcPeer?.sendTo(connId, payload);
898
+ return;
899
+ }
900
+ }
901
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
902
+ this.logger.debug?.(`[coclaw/run-event-route] miss, broadcast, runId=${runId ?? '<missing>'}`);
859
903
  }
860
904
  // (d) 兜底广播:覆盖 event 类型 / 映射未命中场景
861
905
  this.webrtcPeer?.broadcast(payload);
@@ -894,6 +938,8 @@ export class RealtimeBridge {
894
938
  this.gatewayPendingRequests.clear();
895
939
  // 同步清空 UI 转发 RPC 路由表(同 __closeGatewayWs 语义)
896
940
  this.__dcPendingRequests.clear();
941
+ // 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
942
+ this.__runEventRoutes?.clear();
897
943
  // 调度下一次尝试:仅在 bridge 仍活着、未 gave-up、server WS 健康时;
898
944
  // 其他场景(如 bridge stop、server WS 已断)由上游流程兜底,不参与 gateway 重试。
899
945
  if (this.started && !this.__gatewayGaveUp
@@ -1013,6 +1059,7 @@ export class RealtimeBridge {
1013
1059
  connId,
1014
1060
  expireAt: Date.now() + this.__dcReqTtlMs,
1015
1061
  });
1062
+ this.logger.debug?.(`[coclaw/rpc-res-route] add reqId=${id} connId=${connId}`);
1016
1063
  }
1017
1064
  try {
1018
1065
  this.__logDebug(`gateway req -> id=${id} method=${payload.method}`);
@@ -1030,7 +1077,10 @@ export class RealtimeBridge {
1030
1077
  catch {
1031
1078
  // SEND_FAILED:撤回映射后广播错误响应
1032
1079
  if (typeof id === 'string') {
1033
- this.__dcPendingRequests.delete(id);
1080
+ const removed = this.__dcPendingRequests.delete(id);
1081
+ if (removed) {
1082
+ this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${id} reason=send-failed`);
1083
+ }
1034
1084
  }
1035
1085
  this.webrtcPeer?.broadcast({
1036
1086
  type: 'res',
@@ -1336,6 +1386,24 @@ export class RealtimeBridge {
1336
1386
  this.logger = logger ?? console;
1337
1387
  this.pluginConfig = pluginConfig ?? {};
1338
1388
  this.started = true;
1389
+ // rpc DC 文件回退队列的启动期预热(B-stage1 plan-2):清残留 *.jsonl + 探测磁盘容量。
1390
+ // 远早于第一条 rpc DC 建立(dump 设计);__diskCap 暂存供 B-stage2 切 FBQ 时取用。
1391
+ // 整块包 try/catch:模块自身不抛,但仍可能进入 catch 的路径——pluginDir() 同步抛
1392
+ // (runtime 未注入 / nodePath.join 参数异常 / 测试注入的 stub 抛错)。任何路径都不能把
1393
+ // bridge.start 卡死。
1394
+ try {
1395
+ const queueDir = nodePath.join(pluginDir(), 'rpc-queues');
1396
+ await this.__cleanupRpcQueueResiduals(queueDir, { logger: this.logger });
1397
+ this.__diskCap = await this.__measureRpcQueueDiskCap(queueDir, { logger: this.logger });
1398
+ }
1399
+ catch (err) {
1400
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,logger.warn 总存在 */
1401
+ this.logger.warn?.(`[coclaw] rpc-queues startup prep failed (skipped): ${err?.message ?? err}`);
1402
+ this.__diskCap = null;
1403
+ }
1404
+ // race 守卫:cleanup/measure 期间若 stop() 已执行,不应再启动 native WebRTC 进程。
1405
+ // preload 后还有一道 started 检查兜底(含 pion cleanup),这里先挡住一次无意义的 preload。
1406
+ if (!this.started) return;
1339
1407
  // 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
1340
1408
  // 优先级:pion → ndc → werift → none
1341
1409
  const preloadResult = await this.__preloadWebrtc();
@@ -1378,6 +1446,13 @@ export class RealtimeBridge {
1378
1446
  }
1379
1447
  }, this.__dcReqScanMs);
1380
1448
  this.__dcPendingScanTimer.unref?.();
1449
+ // 启动 runId → connId 路由表(agent event 单播)。延迟到 start 才 new,确保拿到真 logger。
1450
+ this.__runEventRoutes = new RunEventRoutes({
1451
+ logger: this.logger,
1452
+ ttlMs: this.__runEventRoutesTtlMs,
1453
+ scanMs: this.__runEventRoutesScanMs,
1454
+ });
1455
+ this.__runEventRoutes.init();
1381
1456
  await this.__connectIfNeeded();
1382
1457
  }
1383
1458
 
@@ -1428,6 +1503,11 @@ export class RealtimeBridge {
1428
1503
  clearInterval(this.__dcPendingScanTimer);
1429
1504
  this.__dcPendingScanTimer = null;
1430
1505
  }
1506
+ // 销毁 runId 路由表(停 timer + clear + 标 destroyed);refresh 时会重建
1507
+ if (this.__runEventRoutes) {
1508
+ this.__runEventRoutes.destroy();
1509
+ this.__runEventRoutes = null;
1510
+ }
1431
1511
  this.__closeGatewayWs();
1432
1512
  if (this.webrtcPeer) {
1433
1513
  await this.webrtcPeer.closeAll().catch(() => {});
@@ -0,0 +1,103 @@
1
+ /**
2
+ * rpc-queues/ 启动期预热(B-stage1 plan-2)。
3
+ *
4
+ * 提供两个 async 函数,均**永不抛**——bridge.start 不能被启动期 fs 操作阻断:
5
+ *
6
+ * - `cleanupResiduals(dir, opts)`:mkdir { recursive: true } → readdir →
7
+ * 按 `*.jsonl` 白名单逐个 unlink。任何子步失败均 warn 并跳过。
8
+ * 白名单确保不会误删邻近文件(dump 设计:保留账本类小文件的扩展位)。
9
+ *
10
+ * - `measureDiskCap(dir, opts)`:fs.statfs → 公式
11
+ * `min(1GB, max(64MB, floor(free × 0.5)))`;statfs 抛错或缺失(Node <18.15)
12
+ * 走 catch 路径回退固定 1GB。返回值由 bridge 暂存到 `__diskCap`,
13
+ * B-stage2 切 FBQ 时再消费(路径 TBD)。
14
+ *
15
+ * `fsOps` 注入仅供测试覆盖错误分支;生产路径默认 `fs.promises`,调用方不传。
16
+ *
17
+ * 行为契约(红线):
18
+ * - 不抛错,只 warn
19
+ * - 不递归删——白名单仅 `*.jsonl`
20
+ * - statfs 失败/缺失统一回退 1GB
21
+ */
22
+
23
+ import fs from 'node:fs/promises';
24
+ import nodePath from 'node:path';
25
+
26
+ export const ONE_GB = 1024 * 1024 * 1024;
27
+ export const SIXTY_FOUR_MB = 64 * 1024 * 1024;
28
+
29
+ /**
30
+ * @param {string} dir - 队列目录绝对路径
31
+ * @param {object} [opts]
32
+ * @param {object} [opts.logger] - pino 风格 logger(warn? / info? / error?)
33
+ * @param {object} [opts.fsOps] - fs.promises 兼容子集(mkdir/readdir/unlink),仅供测试
34
+ */
35
+ export async function cleanupResiduals(dir, { logger, fsOps = fs } = {}) {
36
+ try {
37
+ await fsOps.mkdir(dir, { recursive: true });
38
+ }
39
+ catch (err) {
40
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
41
+ logger?.warn?.(`[coclaw] rpc-queues cleanup mkdir failed: ${err?.message ?? err}`);
42
+ return;
43
+ }
44
+
45
+ let names;
46
+ try {
47
+ names = await fsOps.readdir(dir);
48
+ }
49
+ catch (err) {
50
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
51
+ logger?.warn?.(`[coclaw] rpc-queues cleanup readdir failed: ${err?.message ?? err}`);
52
+ return;
53
+ }
54
+
55
+ for (const name of names) {
56
+ // readdir 默认返回 string[],但若调用方注入 Buffer/Dirent 风格 mock,name.endsWith
57
+ // 会抛出冲过"模块永不抛"红线。生产路径不会触发——纯防御。
58
+ if (typeof name !== 'string') {
59
+ logger?.warn?.(`[coclaw] rpc-queues unexpected non-string entry: ${typeof name}`);
60
+ continue;
61
+ }
62
+ if (!name.endsWith('.jsonl')) continue;
63
+ // nodePath.join 与 unlink 共享同一 try/catch:dir 若误传非 string(生产路径不会,
64
+ // 但 typeof readdir 防御已挡 name 那一头),nodePath.join(dir, name) 会抛 TypeError,
65
+ // 必须落在同一个 catch 里兜住才不破"模块永不抛"红线。
66
+ try {
67
+ const p = nodePath.join(dir, name);
68
+ await fsOps.unlink(p);
69
+ }
70
+ catch (err) {
71
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
72
+ logger?.warn?.(`[coclaw] rpc-queues unlink failed file=${name} err=${err?.message ?? err}`);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * @param {string} dir - 队列目录绝对路径(statfs 自动定位所在文件系统)
79
+ * @param {object} [opts]
80
+ * @param {object} [opts.logger]
81
+ * @param {object} [opts.fsOps] - fs.promises 兼容子集(statfs),仅供测试
82
+ * @returns {Promise<number>} diskCap 字节数;statfs 失败回退 1GB
83
+ */
84
+ export async function measureDiskCap(dir, { logger, fsOps = fs } = {}) {
85
+ try {
86
+ const st = await fsOps.statfs(dir);
87
+ const free = Number(st.bavail) * Number(st.bsize);
88
+ // 真实生产环境(容器、网络挂载、ENOSYS 走 catch 之外的怪环境)下 statfs 偶有
89
+ // 返回非 number / NaN / 负数字段的情况;Number(NaN/undefined) 乘任何东西都是 NaN,
90
+ // floor(NaN * 0.5) = NaN,max/min 链路也会冒泡 NaN——不防御会让 __diskCap 为 NaN。
91
+ if (!Number.isFinite(free) || free < 0) {
92
+ /* c8 ignore next -- ?./?? fallback */
93
+ logger?.warn?.(`[coclaw] rpc-queues statfs failed (non-finite, fallback 1GB): bavail=${st?.bavail} bsize=${st?.bsize}`);
94
+ return ONE_GB;
95
+ }
96
+ return Math.min(ONE_GB, Math.max(SIXTY_FOUR_MB, Math.floor(free * 0.5)));
97
+ }
98
+ catch (err) {
99
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error 或 TypeError,.message 总存在 */
100
+ logger?.warn?.(`[coclaw] rpc-queues statfs failed (fallback 1GB): ${err?.message ?? err}`);
101
+ return ONE_GB;
102
+ }
103
+ }