@geravant/sinain 1.0.18 → 1.1.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/README.md +10 -1
- package/cli.js +176 -0
- package/index.ts +163 -1257
- package/install.js +12 -2
- package/launcher.js +622 -0
- package/openclaw.plugin.json +4 -0
- package/pack-prepare.js +48 -0
- package/package.json +26 -5
- package/sense_client/README.md +82 -0
- package/sense_client/__init__.py +1 -0
- package/sense_client/__main__.py +462 -0
- package/sense_client/app_detector.py +54 -0
- package/sense_client/app_detector_win.py +83 -0
- package/sense_client/capture.py +215 -0
- package/sense_client/capture_win.py +88 -0
- package/sense_client/change_detector.py +86 -0
- package/sense_client/config.py +64 -0
- package/sense_client/gate.py +145 -0
- package/sense_client/ocr.py +347 -0
- package/sense_client/privacy.py +65 -0
- package/sense_client/requirements.txt +13 -0
- package/sense_client/roi_extractor.py +84 -0
- package/sense_client/sender.py +173 -0
- package/sense_client/tests/__init__.py +0 -0
- package/sense_client/tests/test_stream1_optimizations.py +234 -0
- package/setup-overlay.js +82 -0
- package/sinain-agent/.env.example +17 -0
- package/sinain-agent/CLAUDE.md +80 -0
- package/sinain-agent/mcp-config.json +12 -0
- package/sinain-agent/run.sh +248 -0
- package/sinain-core/.env.example +93 -0
- package/sinain-core/package-lock.json +552 -0
- package/sinain-core/package.json +21 -0
- package/sinain-core/src/agent/analyzer.ts +366 -0
- package/sinain-core/src/agent/context-window.ts +172 -0
- package/sinain-core/src/agent/loop.ts +404 -0
- package/sinain-core/src/agent/situation-writer.ts +187 -0
- package/sinain-core/src/agent/traits.ts +520 -0
- package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
- package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
- package/sinain-core/src/audio/capture-spawner.ts +14 -0
- package/sinain-core/src/audio/pipeline.ts +335 -0
- package/sinain-core/src/audio/transcription-local.ts +141 -0
- package/sinain-core/src/audio/transcription.ts +278 -0
- package/sinain-core/src/buffers/feed-buffer.ts +71 -0
- package/sinain-core/src/buffers/sense-buffer.ts +425 -0
- package/sinain-core/src/config.ts +245 -0
- package/sinain-core/src/escalation/escalation-slot.ts +136 -0
- package/sinain-core/src/escalation/escalator.ts +812 -0
- package/sinain-core/src/escalation/message-builder.ts +323 -0
- package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
- package/sinain-core/src/escalation/scorer.ts +166 -0
- package/sinain-core/src/index.ts +507 -0
- package/sinain-core/src/learning/feedback-store.ts +253 -0
- package/sinain-core/src/learning/signal-collector.ts +218 -0
- package/sinain-core/src/log.ts +24 -0
- package/sinain-core/src/overlay/commands.ts +126 -0
- package/sinain-core/src/overlay/ws-handler.ts +267 -0
- package/sinain-core/src/privacy/index.ts +18 -0
- package/sinain-core/src/privacy/presets.ts +40 -0
- package/sinain-core/src/privacy/redact.ts +92 -0
- package/sinain-core/src/profiler.ts +181 -0
- package/sinain-core/src/recorder.ts +186 -0
- package/sinain-core/src/server.ts +417 -0
- package/sinain-core/src/trace/trace-store.ts +73 -0
- package/sinain-core/src/trace/tracer.ts +94 -0
- package/sinain-core/src/types.ts +427 -0
- package/sinain-core/src/util/dedup.ts +48 -0
- package/sinain-core/src/util/task-store.ts +84 -0
- package/sinain-core/tsconfig.json +18 -0
- package/sinain-knowledge/adapters/generic/adapter.ts +103 -0
- package/sinain-knowledge/adapters/interface.ts +72 -0
- package/sinain-knowledge/adapters/openclaw/adapter.ts +223 -0
- package/sinain-knowledge/curation/engine.ts +493 -0
- package/sinain-knowledge/curation/resilience.ts +336 -0
- package/sinain-knowledge/data/git-store.ts +312 -0
- package/sinain-knowledge/data/schema.ts +89 -0
- package/sinain-knowledge/data/snapshot.ts +226 -0
- package/sinain-knowledge/data/store.ts +488 -0
- package/sinain-knowledge/deploy/cli.ts +214 -0
- package/sinain-knowledge/deploy/manifest.ts +80 -0
- package/sinain-knowledge/protocol/bindings/generic.md +5 -0
- package/sinain-knowledge/protocol/bindings/openclaw.md +5 -0
- package/sinain-knowledge/protocol/heartbeat.md +62 -0
- package/sinain-knowledge/protocol/renderer.ts +56 -0
- package/sinain-knowledge/protocol/skill.md +335 -0
- package/sinain-mcp-server/index.ts +337 -0
- package/sinain-mcp-server/package.json +19 -0
- package/sinain-mcp-server/tsconfig.json +15 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import WebSocket from "ws";
|
|
7
|
+
import type { OpenClawConfig } from "../types.js";
|
|
8
|
+
import { log, warn, error } from "../log.js";
|
|
9
|
+
|
|
10
|
+
const TAG = "openclaw";
|
|
11
|
+
|
|
12
|
+
// ── Device Identity ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface DeviceIdentity {
|
|
15
|
+
deviceId: string;
|
|
16
|
+
publicKeyPem: string;
|
|
17
|
+
privateKeyPem: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
21
|
+
|
|
22
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
23
|
+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
27
|
+
const spki = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
|
28
|
+
if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
29
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
|
|
30
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
31
|
+
}
|
|
32
|
+
return spki;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fingerprintPublicKey(publicKeyPem: string): string {
|
|
36
|
+
return createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function publicKeyRawBase64Url(publicKeyPem: string): string {
|
|
40
|
+
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function signDevicePayload(privateKeyPem: string, payload: string): string {
|
|
44
|
+
const key = createPrivateKey(privateKeyPem);
|
|
45
|
+
return base64UrlEncode(sign(null, Buffer.from(payload, "utf8"), key) as unknown as Buffer);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildDeviceAuthPayloadV3(params: {
|
|
49
|
+
deviceId: string; clientId: string; clientMode: string;
|
|
50
|
+
role: string; scopes: string[]; signedAtMs: number;
|
|
51
|
+
token: string | null; nonce: string; platform: string;
|
|
52
|
+
}): string {
|
|
53
|
+
return [
|
|
54
|
+
"v3", params.deviceId, params.clientId, params.clientMode,
|
|
55
|
+
params.role, params.scopes.join(","), String(params.signedAtMs),
|
|
56
|
+
params.token ?? "", params.nonce, params.platform.toLowerCase(), "",
|
|
57
|
+
].join("|");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEVICE_IDENTITY_PATH = join(homedir(), ".sinain", "device-identity.json");
|
|
61
|
+
|
|
62
|
+
function loadOrCreateDeviceIdentity(): DeviceIdentity {
|
|
63
|
+
try {
|
|
64
|
+
if (existsSync(DEVICE_IDENTITY_PATH)) {
|
|
65
|
+
const parsed = JSON.parse(readFileSync(DEVICE_IDENTITY_PATH, "utf8"));
|
|
66
|
+
if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) {
|
|
67
|
+
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
|
68
|
+
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
|
|
73
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
74
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }) as string;
|
|
75
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string;
|
|
76
|
+
const deviceId = fingerprintPublicKey(publicKeyPem);
|
|
77
|
+
|
|
78
|
+
const dir = dirname(DEVICE_IDENTITY_PATH);
|
|
79
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
80
|
+
writeFileSync(DEVICE_IDENTITY_PATH, JSON.stringify({ version: 1, deviceId, publicKeyPem, privateKeyPem }, null, 2) + "\n", { mode: 0o600 });
|
|
81
|
+
log(TAG, `generated device identity → ${deviceId.slice(0, 12)}… (${DEVICE_IDENTITY_PATH})`);
|
|
82
|
+
|
|
83
|
+
return { deviceId, publicKeyPem, privateKeyPem };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Ping timeout — if no pong arrives within this window after a ping, terminate. */
|
|
87
|
+
const PING_TIMEOUT_MS = 5_000;
|
|
88
|
+
|
|
89
|
+
interface PendingRpc {
|
|
90
|
+
method: string;
|
|
91
|
+
resolve: (value: any) => void;
|
|
92
|
+
reject: (reason: any) => void;
|
|
93
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
94
|
+
expectFinal: boolean;
|
|
95
|
+
sentAt: number;
|
|
96
|
+
// For split RPCs (sendAgentRpcSplit):
|
|
97
|
+
isSplit?: boolean;
|
|
98
|
+
acceptedResolve?: () => void;
|
|
99
|
+
acceptedReject?: (err: any) => void;
|
|
100
|
+
finalResolve?: (value: any) => void;
|
|
101
|
+
finalReject?: (reason: any) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface PendingFinalRpc {
|
|
105
|
+
finalResolve: (value: any) => void;
|
|
106
|
+
finalReject: (reason: any) => void;
|
|
107
|
+
finalTimeout: ReturnType<typeof setTimeout>;
|
|
108
|
+
sentAt: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Persistent WebSocket client to OpenClaw gateway.
|
|
113
|
+
* Ported from relay with added circuit breaker and exponential backoff.
|
|
114
|
+
*
|
|
115
|
+
* Protocol:
|
|
116
|
+
* 1. Server sends connect.challenge → client responds with connect + auth token
|
|
117
|
+
* 2. Client sends 'agent' RPC → server responds with two-frame protocol (accepted + final)
|
|
118
|
+
* 3. Client extracts text from payload.result.payloads[].text
|
|
119
|
+
*
|
|
120
|
+
* Split RPC protocol (sendAgentRpcSplit):
|
|
121
|
+
* Phase 1 (10s timeout): await accepted frame → blocks queue worker
|
|
122
|
+
* Phase 2 (120s timeout): final frame → resolved async, never trips circuit
|
|
123
|
+
*/
|
|
124
|
+
export class OpenClawWsClient extends EventEmitter {
|
|
125
|
+
private ws: WebSocket | null = null;
|
|
126
|
+
private authenticated = false;
|
|
127
|
+
private deviceIdentity: DeviceIdentity;
|
|
128
|
+
private rpcId = 1;
|
|
129
|
+
private pending = new Map<string, PendingRpc>();
|
|
130
|
+
/** Phase 2 of split RPCs — resolved by final frame, rejected by 120s timeout or disconnect. */
|
|
131
|
+
private pendingFinal = new Map<string, PendingFinalRpc>();
|
|
132
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
133
|
+
private stopped = false;
|
|
134
|
+
|
|
135
|
+
// Exponential backoff
|
|
136
|
+
private reconnectDelay = 1000;
|
|
137
|
+
private maxReconnectDelay = 60000;
|
|
138
|
+
|
|
139
|
+
// Circuit breaker (time-window based)
|
|
140
|
+
private recentFailures: number[] = []; // timestamps of recent failures
|
|
141
|
+
private circuitOpen = false;
|
|
142
|
+
private circuitResetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
143
|
+
private static readonly CIRCUIT_THRESHOLD = 5;
|
|
144
|
+
private static readonly CIRCUIT_WINDOW_MS = 2 * 60 * 1000; // 2-minute sliding window
|
|
145
|
+
private circuitResetDelay = 300_000; // starts at 5 min, doubles on each trip
|
|
146
|
+
private readonly MAX_CIRCUIT_RESET = 1_800_000; // caps at 30 min
|
|
147
|
+
|
|
148
|
+
// Connection attempt counter for diagnostics
|
|
149
|
+
private connectAttempts = 0;
|
|
150
|
+
private connectedAt: number | null = null;
|
|
151
|
+
|
|
152
|
+
// WS ping keepalive
|
|
153
|
+
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
154
|
+
private pingTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
155
|
+
|
|
156
|
+
constructor(private config: OpenClawConfig) {
|
|
157
|
+
super();
|
|
158
|
+
this.deviceIdentity = loadOrCreateDeviceIdentity();
|
|
159
|
+
log(TAG, `device identity: ${this.deviceIdentity.deviceId.slice(0, 12)}…`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Connect to the OpenClaw gateway. */
|
|
163
|
+
connect(): void {
|
|
164
|
+
if (!this.config.gatewayToken && !this.config.hookUrl) {
|
|
165
|
+
log(TAG, "connect: no gateway token or hookUrl — skipping");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (this.stopped) {
|
|
169
|
+
log(TAG, "connect: stopped — skipping");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (this.circuitOpen) {
|
|
173
|
+
log(TAG, "connect: circuit breaker open — skipping");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// If a ws instance exists but is in a non-usable state, terminate it cleanly
|
|
178
|
+
// before creating a new one. This prevents the circuit-reset path from being
|
|
179
|
+
// blocked by a stale CLOSING/CLOSED socket that hasn't triggered cleanup yet.
|
|
180
|
+
if (this.ws) {
|
|
181
|
+
const state = this.ws.readyState;
|
|
182
|
+
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
|
|
183
|
+
log(TAG, `connect: already ${state === WebSocket.OPEN ? "open" : "connecting"} — skipping`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
log(TAG, `connect: terminating stale socket (readyState=${state})`);
|
|
187
|
+
try { this.ws.terminate(); } catch {}
|
|
188
|
+
this.ws = null;
|
|
189
|
+
this.authenticated = false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.connectAttempts++;
|
|
193
|
+
const wsUrl = this.config.gatewayWsUrl;
|
|
194
|
+
log(TAG, `connect: attempt #${this.connectAttempts} → ${wsUrl}`);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
this.ws = new WebSocket(wsUrl);
|
|
198
|
+
|
|
199
|
+
this.ws.on("open", () => {
|
|
200
|
+
log(TAG, `ws open (attempt #${this.connectAttempts}), awaiting challenge...`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
this.ws.on("message", (raw) => {
|
|
204
|
+
try {
|
|
205
|
+
const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString());
|
|
206
|
+
this.handleMessage(msg);
|
|
207
|
+
} catch (err: any) {
|
|
208
|
+
error(TAG, "ws message parse error:", err.message);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
this.ws.on("pong", () => {
|
|
213
|
+
if (this.pingTimeoutTimer) {
|
|
214
|
+
clearTimeout(this.pingTimeoutTimer);
|
|
215
|
+
this.pingTimeoutTimer = null;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
this.ws.on("close", (code, reason) => {
|
|
220
|
+
const reasonStr = reason?.toString() || "";
|
|
221
|
+
const uptime = this.connectedAt ? `${Math.round((Date.now() - this.connectedAt) / 1000)}s uptime` : "never authenticated";
|
|
222
|
+
log(TAG, `ws closed: code=${code}${reasonStr ? ` reason="${reasonStr}"` : ""} (${uptime})`);
|
|
223
|
+
this.connectedAt = null;
|
|
224
|
+
this.cleanup("ws closed");
|
|
225
|
+
this.scheduleReconnect();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
this.ws.on("error", (err) => {
|
|
229
|
+
// error event always precedes close; log it but let close handler drive cleanup
|
|
230
|
+
error(TAG, `ws error: ${err.message}`);
|
|
231
|
+
});
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
error(TAG, `connect: instantiation failed: ${err.message}`);
|
|
234
|
+
this.ws = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Send an agent RPC call. Returns the response payload. */
|
|
239
|
+
async sendAgentRpc(
|
|
240
|
+
message: string,
|
|
241
|
+
idemKey: string,
|
|
242
|
+
sessionKey: string,
|
|
243
|
+
): Promise<any> {
|
|
244
|
+
if (this.circuitOpen) {
|
|
245
|
+
warn(TAG, "sendAgentRpc: circuit breaker open — skipping");
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
log(TAG, `sendAgentRpc: session=${sessionKey} idemKey=${idemKey} msgLen=${message.length}`);
|
|
249
|
+
const result = await this.sendRpc("agent", {
|
|
250
|
+
message,
|
|
251
|
+
idempotencyKey: idemKey,
|
|
252
|
+
sessionKey,
|
|
253
|
+
deliver: false,
|
|
254
|
+
}, 45000, { expectFinal: true });
|
|
255
|
+
if (result?.ok) {
|
|
256
|
+
this.circuitResetDelay = 300_000; // reset backoff on success
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Send a split-phase agent RPC.
|
|
263
|
+
*
|
|
264
|
+
* Returns two promises:
|
|
265
|
+
* acceptedPromise — resolves when Phase 1 (accepted frame) arrives within 10s.
|
|
266
|
+
* Rejection counts as a circuit-breaker failure (real delivery failure).
|
|
267
|
+
* finalPromise — resolves when Phase 2 (final frame) arrives within 120s.
|
|
268
|
+
* Timeout/rejection does NOT trip circuit breaker (agent slowness ≠ delivery failure).
|
|
269
|
+
*
|
|
270
|
+
* Queue worker awaits acceptedPromise, then releases immediately.
|
|
271
|
+
* finalPromise is handled async — response arrives later.
|
|
272
|
+
*/
|
|
273
|
+
sendAgentRpcSplit(
|
|
274
|
+
message: string,
|
|
275
|
+
idemKey: string,
|
|
276
|
+
sessionKey: string,
|
|
277
|
+
): { acceptedPromise: Promise<void>; finalPromise: Promise<any> } {
|
|
278
|
+
let acceptedResolve!: () => void;
|
|
279
|
+
let acceptedReject!: (err: any) => void;
|
|
280
|
+
let finalResolve!: (value: any) => void;
|
|
281
|
+
let finalReject!: (err: any) => void;
|
|
282
|
+
|
|
283
|
+
const acceptedPromise = new Promise<void>((res, rej) => {
|
|
284
|
+
acceptedResolve = res;
|
|
285
|
+
acceptedReject = rej;
|
|
286
|
+
});
|
|
287
|
+
const finalPromise = new Promise<any>((res, rej) => {
|
|
288
|
+
finalResolve = res;
|
|
289
|
+
finalReject = rej;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (this.circuitOpen) {
|
|
293
|
+
const err = new Error("circuit breaker open");
|
|
294
|
+
acceptedReject(err);
|
|
295
|
+
finalReject(err);
|
|
296
|
+
return { acceptedPromise, finalPromise };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.authenticated) {
|
|
300
|
+
const reason = !this.ws ? "no socket" : !this.authenticated ? "not authenticated" : `ws state=${this.ws.readyState}`;
|
|
301
|
+
const err = new Error(`gateway not connected: ${reason}`);
|
|
302
|
+
acceptedReject(err);
|
|
303
|
+
finalReject(err);
|
|
304
|
+
return { acceptedPromise, finalPromise };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const id = String(this.rpcId++);
|
|
308
|
+
const sentAt = Date.now();
|
|
309
|
+
log(TAG, `sendAgentRpcSplit → id=${id} idemKey=${idemKey} msgLen=${message.length}`);
|
|
310
|
+
|
|
311
|
+
const phase1Timeout = setTimeout(() => {
|
|
312
|
+
if (this.pending.has(id)) {
|
|
313
|
+
this.pending.delete(id);
|
|
314
|
+
const elapsed = Date.now() - sentAt;
|
|
315
|
+
warn(TAG, `rpc ${id} (agent) Phase 1 TIMEOUT after ${elapsed}ms`);
|
|
316
|
+
this.onRpcFailure();
|
|
317
|
+
const err = new Error(`rpc phase1 timeout: agent`);
|
|
318
|
+
acceptedReject(err);
|
|
319
|
+
finalReject(err);
|
|
320
|
+
}
|
|
321
|
+
}, this.config.phase1TimeoutMs);
|
|
322
|
+
|
|
323
|
+
this.pending.set(id, {
|
|
324
|
+
method: "agent",
|
|
325
|
+
resolve: () => {}, // unused for split RPCs
|
|
326
|
+
reject: () => {}, // unused for split RPCs
|
|
327
|
+
timeout: phase1Timeout,
|
|
328
|
+
expectFinal: false, // we handle the accepted frame ourselves
|
|
329
|
+
sentAt,
|
|
330
|
+
isSplit: true,
|
|
331
|
+
acceptedResolve,
|
|
332
|
+
acceptedReject,
|
|
333
|
+
finalResolve,
|
|
334
|
+
finalReject,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
this.ws.send(JSON.stringify({
|
|
339
|
+
type: "req",
|
|
340
|
+
method: "agent",
|
|
341
|
+
id,
|
|
342
|
+
params: {
|
|
343
|
+
message,
|
|
344
|
+
idempotencyKey: idemKey,
|
|
345
|
+
sessionKey,
|
|
346
|
+
deliver: false,
|
|
347
|
+
},
|
|
348
|
+
}));
|
|
349
|
+
} catch (err: any) {
|
|
350
|
+
clearTimeout(phase1Timeout);
|
|
351
|
+
this.pending.delete(id);
|
|
352
|
+
error(TAG, `sendAgentRpcSplit: ws.send() threw: ${err.message}`);
|
|
353
|
+
const sendErr = new Error(`ws.send failed: ${err.message}`);
|
|
354
|
+
acceptedReject(sendErr);
|
|
355
|
+
finalReject(sendErr);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { acceptedPromise, finalPromise };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Check if connected and authenticated. */
|
|
362
|
+
get isConnected(): boolean {
|
|
363
|
+
return !!(this.ws && this.ws.readyState === WebSocket.OPEN && this.authenticated);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Check if the circuit breaker is currently open. */
|
|
367
|
+
get isCircuitOpen(): boolean {
|
|
368
|
+
return this.circuitOpen;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Force reconnection — resets stopped state and reconnect delay. */
|
|
372
|
+
resetConnection(): void {
|
|
373
|
+
log(TAG, "resetConnection: clearing stopped state, reconnecting");
|
|
374
|
+
this.stopped = false;
|
|
375
|
+
this.reconnectDelay = 1000;
|
|
376
|
+
if (this.ws) {
|
|
377
|
+
try { this.ws.close(1000, "reset"); } catch {}
|
|
378
|
+
this.ws = null;
|
|
379
|
+
this.authenticated = false;
|
|
380
|
+
}
|
|
381
|
+
this.connect();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Graceful disconnect — does not schedule reconnect. */
|
|
385
|
+
disconnect(): void {
|
|
386
|
+
log(TAG, `disconnect: stopping (pending=${this.pending.size}, pendingFinal=${this.pendingFinal.size})`);
|
|
387
|
+
this.stopped = true;
|
|
388
|
+
this.stopPing();
|
|
389
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
390
|
+
if (this.circuitResetTimer) { clearTimeout(this.circuitResetTimer); this.circuitResetTimer = null; }
|
|
391
|
+
if (this.ws) { try { this.ws.close(1000, "graceful shutdown"); } catch {} this.ws = null; }
|
|
392
|
+
this.authenticated = false;
|
|
393
|
+
this.connectedAt = null;
|
|
394
|
+
this.rejectAllPending("disconnected");
|
|
395
|
+
this.rejectAllPendingFinal("disconnected");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Private ──
|
|
399
|
+
|
|
400
|
+
private handleMessage(msg: any): void {
|
|
401
|
+
// Handle connect.challenge
|
|
402
|
+
if (msg.type === "event" && msg.event === "connect.challenge") {
|
|
403
|
+
const nonce: string = msg.payload?.nonce ?? msg.nonce ?? "";
|
|
404
|
+
const tokenHash = this.config.gatewayToken
|
|
405
|
+
? createHash("sha256").update(this.config.gatewayToken).digest("hex").slice(0, 12)
|
|
406
|
+
: "none";
|
|
407
|
+
log(TAG, `received connect.challenge — sending auth (tokenHash=${tokenHash}, device=${this.deviceIdentity.deviceId.slice(0, 12)}…)`);
|
|
408
|
+
|
|
409
|
+
const scopes = ["operator.read", "operator.write", "operator.admin"];
|
|
410
|
+
const signedAtMs = Date.now();
|
|
411
|
+
const payload = buildDeviceAuthPayloadV3({
|
|
412
|
+
deviceId: this.deviceIdentity.deviceId,
|
|
413
|
+
clientId: "gateway-client",
|
|
414
|
+
clientMode: "backend",
|
|
415
|
+
role: "operator",
|
|
416
|
+
scopes,
|
|
417
|
+
signedAtMs,
|
|
418
|
+
token: this.config.gatewayToken || null,
|
|
419
|
+
nonce,
|
|
420
|
+
platform: process.platform,
|
|
421
|
+
});
|
|
422
|
+
const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
|
|
423
|
+
|
|
424
|
+
this.ws?.send(JSON.stringify({
|
|
425
|
+
type: "req",
|
|
426
|
+
id: "connect-1",
|
|
427
|
+
method: "connect",
|
|
428
|
+
params: {
|
|
429
|
+
minProtocol: 3,
|
|
430
|
+
maxProtocol: 3,
|
|
431
|
+
role: "operator",
|
|
432
|
+
scopes,
|
|
433
|
+
client: {
|
|
434
|
+
id: "gateway-client",
|
|
435
|
+
displayName: "Sinain Core",
|
|
436
|
+
version: "1.0.0",
|
|
437
|
+
platform: process.platform,
|
|
438
|
+
mode: "backend",
|
|
439
|
+
},
|
|
440
|
+
auth: { token: this.config.gatewayToken },
|
|
441
|
+
device: {
|
|
442
|
+
id: this.deviceIdentity.deviceId,
|
|
443
|
+
publicKey: publicKeyRawBase64Url(this.deviceIdentity.publicKeyPem),
|
|
444
|
+
signature,
|
|
445
|
+
signedAt: signedAtMs,
|
|
446
|
+
nonce,
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
}));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Handle connect response
|
|
454
|
+
if (msg.type === "res" && msg.id === "connect-1") {
|
|
455
|
+
if (msg.ok) {
|
|
456
|
+
this.authenticated = true;
|
|
457
|
+
this.connectedAt = Date.now();
|
|
458
|
+
this.reconnectDelay = 1000; // Reset backoff on successful auth
|
|
459
|
+
log(TAG, `authenticated ✓ (attempt #${this.connectAttempts}, reconnectDelay reset to 1s)`);
|
|
460
|
+
this.startPing();
|
|
461
|
+
this.emit("connected");
|
|
462
|
+
} else {
|
|
463
|
+
const errInfo = msg.error || msg.payload?.error || "unknown";
|
|
464
|
+
const authReason = msg.error?.details?.authReason
|
|
465
|
+
|| msg.payload?.error?.details?.authReason;
|
|
466
|
+
error(TAG, `auth failed: ${JSON.stringify(errInfo)}`);
|
|
467
|
+
|
|
468
|
+
// Auth errors — retry with long backoff (token may become valid after gateway restart)
|
|
469
|
+
if (authReason === "token_mismatch") {
|
|
470
|
+
error(TAG, "auth failure (token_mismatch) — will retry with long backoff. Check OPENCLAW_GATEWAY_TOKEN.");
|
|
471
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 4, this.maxReconnectDelay);
|
|
472
|
+
this.ws?.close();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
log(TAG, "auth failed (transient) — closing to trigger reconnect");
|
|
477
|
+
this.ws?.close();
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Handle RPC responses
|
|
483
|
+
const msgId = msg.id != null ? String(msg.id) : null;
|
|
484
|
+
|
|
485
|
+
// Check pendingFinal first — Phase 2 final frames for split RPCs
|
|
486
|
+
if (msg.type === "res" && msgId && this.pendingFinal.has(msgId)) {
|
|
487
|
+
const pf = this.pendingFinal.get(msgId)!;
|
|
488
|
+
clearTimeout(pf.finalTimeout);
|
|
489
|
+
this.pendingFinal.delete(msgId);
|
|
490
|
+
const elapsed = Date.now() - pf.sentAt;
|
|
491
|
+
if (msg.ok) {
|
|
492
|
+
log(TAG, `rpc ${msgId} Phase 2 final: ok in ${elapsed}ms, status=${msg.payload?.status ?? "n/a"}`);
|
|
493
|
+
this.circuitResetDelay = 300_000; // success — reset circuit backoff
|
|
494
|
+
pf.finalResolve(msg);
|
|
495
|
+
} else {
|
|
496
|
+
warn(TAG, `rpc ${msgId} Phase 2 final: error in ${elapsed}ms: ${JSON.stringify(msg.error ?? msg.payload?.error).slice(0, 200)}`);
|
|
497
|
+
pf.finalReject(new Error(JSON.stringify(msg.error ?? msg.payload?.error ?? "phase2 error").slice(0, 200)));
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (msg.type === "res" && msgId && this.pending.has(msgId)) {
|
|
503
|
+
const pending = this.pending.get(msgId)!;
|
|
504
|
+
const elapsed = Date.now() - pending.sentAt;
|
|
505
|
+
|
|
506
|
+
// Split RPC: handle accepted frame → transition to Phase 2
|
|
507
|
+
if (pending.isSplit) {
|
|
508
|
+
clearTimeout(pending.timeout);
|
|
509
|
+
this.pending.delete(msgId);
|
|
510
|
+
|
|
511
|
+
if (msg.payload?.status === "accepted") {
|
|
512
|
+
log(TAG, `rpc ${msgId} (agent) Phase 1 accepted in ${elapsed}ms`);
|
|
513
|
+
const phase2Timeout = setTimeout(() => {
|
|
514
|
+
if (this.pendingFinal.has(msgId)) {
|
|
515
|
+
const pf = this.pendingFinal.get(msgId)!;
|
|
516
|
+
this.pendingFinal.delete(msgId);
|
|
517
|
+
warn(TAG, `rpc ${msgId} Phase 2 TIMEOUT (${Date.now() - pf.sentAt}ms) — agent slow, unblocking`);
|
|
518
|
+
pf.finalReject(new Error("rpc phase2 timeout: agent"));
|
|
519
|
+
// Intentionally NOT calling onRpcFailure() — agent slowness ≠ delivery failure
|
|
520
|
+
}
|
|
521
|
+
}, this.config.phase2TimeoutMs);
|
|
522
|
+
this.pendingFinal.set(msgId, {
|
|
523
|
+
finalResolve: pending.finalResolve!,
|
|
524
|
+
finalReject: pending.finalReject!,
|
|
525
|
+
finalTimeout: phase2Timeout,
|
|
526
|
+
sentAt: pending.sentAt,
|
|
527
|
+
});
|
|
528
|
+
pending.acceptedResolve!();
|
|
529
|
+
} else {
|
|
530
|
+
// Phase 1 error response (not accepted)
|
|
531
|
+
warn(TAG, `rpc ${msgId} (agent) Phase 1 error in ${elapsed}ms: ${JSON.stringify(msg.error ?? msg.payload?.error).slice(0, 200)}`);
|
|
532
|
+
this.onRpcFailure();
|
|
533
|
+
const err = new Error(JSON.stringify(msg.error ?? msg.payload?.error ?? "phase1 error").slice(0, 200));
|
|
534
|
+
pending.acceptedReject!(err);
|
|
535
|
+
pending.finalReject!(err);
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Regular RPC: skip intermediate "accepted" frame when expecting final
|
|
541
|
+
if (pending.expectFinal && msg.payload?.status === "accepted") {
|
|
542
|
+
log(TAG, `rpc ${msgId} (${pending.method}): accepted after ${elapsed}ms, waiting for final`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
clearTimeout(pending.timeout);
|
|
547
|
+
this.pending.delete(msgId);
|
|
548
|
+
|
|
549
|
+
if (msg.ok) {
|
|
550
|
+
log(TAG, `rpc ${msgId} (${pending.method}): ok in ${elapsed}ms, status=${msg.payload?.status ?? "n/a"}`);
|
|
551
|
+
} else {
|
|
552
|
+
warn(TAG, `rpc ${msgId} (${pending.method}): error in ${elapsed}ms: ${JSON.stringify(msg.error ?? msg.payload?.error).slice(0, 200)}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
pending.resolve(msg);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Unmatched response — might be for a pending that was already timed out
|
|
560
|
+
if (msg.type === "res" && msgId) {
|
|
561
|
+
warn(TAG, `rpc ${msgId}: received response for unknown/expired pending call`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Send a generic RPC call. Returns the response. */
|
|
566
|
+
sendRpc(
|
|
567
|
+
method: string,
|
|
568
|
+
params: Record<string, unknown>,
|
|
569
|
+
timeoutMs = 90000,
|
|
570
|
+
opts: { expectFinal?: boolean } = {},
|
|
571
|
+
): Promise<any> {
|
|
572
|
+
return new Promise((resolve, reject) => {
|
|
573
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.authenticated) {
|
|
574
|
+
const reason = !this.ws ? "no socket" : !this.authenticated ? "not authenticated" : `ws state=${this.ws.readyState}`;
|
|
575
|
+
warn(TAG, `sendRpc(${method}): not ready — ${reason}`);
|
|
576
|
+
reject(new Error(`gateway not connected: ${reason}`));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const id = String(this.rpcId++);
|
|
581
|
+
const sentAt = Date.now();
|
|
582
|
+
log(TAG, `sendRpc → id=${id} method=${method} timeout=${timeoutMs}ms pending=${this.pending.size + 1}`);
|
|
583
|
+
|
|
584
|
+
const timeout = setTimeout(() => {
|
|
585
|
+
if (this.pending.has(id)) {
|
|
586
|
+
this.pending.delete(id);
|
|
587
|
+
const elapsed = Date.now() - sentAt;
|
|
588
|
+
warn(TAG, `rpc ${id} (${method}): TIMEOUT after ${elapsed}ms`);
|
|
589
|
+
this.onRpcFailure();
|
|
590
|
+
reject(new Error(`rpc timeout: ${method}`));
|
|
591
|
+
}
|
|
592
|
+
}, timeoutMs);
|
|
593
|
+
|
|
594
|
+
this.pending.set(id, {
|
|
595
|
+
method,
|
|
596
|
+
resolve,
|
|
597
|
+
reject: (reason) => { this.onRpcFailure(); reject(reason); },
|
|
598
|
+
timeout,
|
|
599
|
+
expectFinal: !!opts.expectFinal,
|
|
600
|
+
sentAt,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
this.ws.send(JSON.stringify({ type: "req", method, id, params }));
|
|
605
|
+
} catch (err: any) {
|
|
606
|
+
// send() threw synchronously — connection is broken
|
|
607
|
+
clearTimeout(timeout);
|
|
608
|
+
this.pending.delete(id);
|
|
609
|
+
error(TAG, `sendRpc(${method}): ws.send() threw: ${err.message}`);
|
|
610
|
+
reject(new Error(`ws.send failed: ${err.message}`));
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private startPing(): void {
|
|
616
|
+
this.stopPing();
|
|
617
|
+
if (!this.config.pingIntervalMs || this.config.pingIntervalMs <= 0) return;
|
|
618
|
+
this.pingTimer = setInterval(() => {
|
|
619
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
620
|
+
this.pingTimeoutTimer = setTimeout(() => {
|
|
621
|
+
warn(TAG, "ping timeout — terminating connection");
|
|
622
|
+
this.ws?.terminate();
|
|
623
|
+
}, PING_TIMEOUT_MS);
|
|
624
|
+
this.ws.ping();
|
|
625
|
+
}, this.config.pingIntervalMs);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private stopPing(): void {
|
|
629
|
+
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
|
|
630
|
+
if (this.pingTimeoutTimer) { clearTimeout(this.pingTimeoutTimer); this.pingTimeoutTimer = null; }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private cleanup(reason: string): void {
|
|
634
|
+
this.stopPing();
|
|
635
|
+
const pendingCount = this.pending.size;
|
|
636
|
+
const finalCount = this.pendingFinal.size;
|
|
637
|
+
this.ws = null;
|
|
638
|
+
this.authenticated = false;
|
|
639
|
+
if (pendingCount > 0 || finalCount > 0) {
|
|
640
|
+
log(TAG, `cleanup (${reason}): rejecting ${pendingCount} pending, ${finalCount} pendingFinal RPCs`);
|
|
641
|
+
this.rejectAllPending(`gateway disconnected: ${reason}`);
|
|
642
|
+
// Phase 2 rejections — no onRpcFailure() (agent slowness ≠ connection failure)
|
|
643
|
+
this.rejectAllPendingFinal(`gateway disconnected: ${reason}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private rejectAllPending(reason: string): void {
|
|
648
|
+
for (const [id, pending] of this.pending) {
|
|
649
|
+
clearTimeout(pending.timeout);
|
|
650
|
+
log(TAG, ` rejecting pending rpc ${id} (${pending.method}), was pending ${Date.now() - pending.sentAt}ms`);
|
|
651
|
+
if (pending.isSplit) {
|
|
652
|
+
// Phase 1 disconnect = real delivery failure → count for circuit breaker
|
|
653
|
+
this.onRpcFailure();
|
|
654
|
+
const err = new Error(reason);
|
|
655
|
+
pending.acceptedReject?.(err);
|
|
656
|
+
pending.finalReject?.(err);
|
|
657
|
+
} else {
|
|
658
|
+
pending.reject(new Error(reason));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
this.pending.clear();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Reject all Phase 2 pending RPCs. Does NOT call onRpcFailure(). */
|
|
665
|
+
private rejectAllPendingFinal(reason: string): void {
|
|
666
|
+
for (const [id, pf] of this.pendingFinal) {
|
|
667
|
+
clearTimeout(pf.finalTimeout);
|
|
668
|
+
log(TAG, ` rejecting pendingFinal rpc ${id}, was pending ${Date.now() - pf.sentAt}ms`);
|
|
669
|
+
pf.finalReject(new Error(reason));
|
|
670
|
+
}
|
|
671
|
+
this.pendingFinal.clear();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private scheduleReconnect(): void {
|
|
675
|
+
if (this.stopped) {
|
|
676
|
+
log(TAG, "scheduleReconnect: stopped — not scheduling");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (this.reconnectTimer) {
|
|
680
|
+
log(TAG, "scheduleReconnect: already scheduled");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (this.circuitOpen) {
|
|
684
|
+
log(TAG, "scheduleReconnect: circuit open — deferring to circuit reset");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
log(TAG, `scheduleReconnect: in ${this.reconnectDelay}ms (backoff=${this.reconnectDelay}ms)`);
|
|
688
|
+
this.reconnectTimer = setTimeout(() => {
|
|
689
|
+
this.reconnectTimer = null;
|
|
690
|
+
log(TAG, "scheduleReconnect: firing — calling connect()");
|
|
691
|
+
this.connect();
|
|
692
|
+
}, this.reconnectDelay);
|
|
693
|
+
// Exponential backoff
|
|
694
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private onRpcFailure(): void {
|
|
698
|
+
const now = Date.now();
|
|
699
|
+
this.recentFailures.push(now);
|
|
700
|
+
|
|
701
|
+
// Trim entries outside the sliding window
|
|
702
|
+
const cutoff = now - OpenClawWsClient.CIRCUIT_WINDOW_MS;
|
|
703
|
+
this.recentFailures = this.recentFailures.filter(ts => ts > cutoff);
|
|
704
|
+
|
|
705
|
+
if (this.recentFailures.length >= 3) {
|
|
706
|
+
warn(TAG, `${this.recentFailures.length} RPC failures in last ${OpenClawWsClient.CIRCUIT_WINDOW_MS / 1000}s (threshold: ${OpenClawWsClient.CIRCUIT_THRESHOLD})`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (this.recentFailures.length >= OpenClawWsClient.CIRCUIT_THRESHOLD && !this.circuitOpen) {
|
|
710
|
+
this.circuitOpen = true;
|
|
711
|
+
// Add 0-30s random jitter to prevent thundering herd on service recovery
|
|
712
|
+
const jitterMs = Math.floor(Math.random() * 30000);
|
|
713
|
+
const resetDelayMs = this.circuitResetDelay + jitterMs;
|
|
714
|
+
warn(TAG, `circuit breaker OPENED after ${this.recentFailures.length} failures — pausing ${Math.round(resetDelayMs / 1000)}s (next reset: ${Math.round(Math.min(this.circuitResetDelay * 2, this.MAX_CIRCUIT_RESET) / 1000)}s)`);
|
|
715
|
+
this.circuitResetTimer = setTimeout(() => {
|
|
716
|
+
this.circuitResetTimer = null;
|
|
717
|
+
this.circuitOpen = false;
|
|
718
|
+
this.recentFailures = [];
|
|
719
|
+
log(TAG, "circuit breaker RESET — calling connect()");
|
|
720
|
+
this.connect();
|
|
721
|
+
}, resetDelayMs);
|
|
722
|
+
// Progressive backoff: double the delay for next trip, capped at MAX
|
|
723
|
+
this.circuitResetDelay = Math.min(this.circuitResetDelay * 2, this.MAX_CIRCUIT_RESET);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|