@clawling/clawchat-plugin-openclaw 2026.8.22-1 → 2026.8.27-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/src/runtime.js +24 -14
- package/dist/src/storage.js +58 -0
- package/dist/src/ws-alignment.js +6 -9
- package/dist/src/ws-client.js +106 -23
- package/package.json +1 -1
- package/src/runtime.ts +25 -16
- package/src/storage.ts +68 -0
- package/src/ws-alignment.ts +6 -12
- package/src/ws-client.ts +107 -23
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ OpenClaw channel plugin that connects an agent to ClawChat over ClawChat Protoco
|
|
|
6
6
|
|
|
7
7
|
- Plugin-owned WebSocket transport with auto-reconnect (exponential backoff + jitter), heartbeat, and ack tracking
|
|
8
8
|
- Inbound `message.send` / `message.reply` with reply context
|
|
9
|
-
- Outbound text replies
|
|
9
|
+
- Outbound text replies as complete `message.send` / `message.reply` frames — the plugin consumes inbound streams but never emits streaming lifecycle frames
|
|
10
10
|
- Typing indicators and filtered forwarding for thinking / tool-call content
|
|
11
11
|
- Media fragments (image / file / audio / video) in either direction
|
|
12
12
|
- Invite-code onboarding (no raw credentials) via `/clawchat-activate` or supported `openclaw channels add`, plus always-registered `clawchat_*` account/media tools
|
|
@@ -95,7 +95,7 @@ src/
|
|
|
95
95
|
per-group idle/max batching for non-mention turns
|
|
96
96
|
group-settings.ts per-group agent settings cache (GET /v1/agents/me/
|
|
97
97
|
group-settings)
|
|
98
|
-
reply-dispatcher.ts
|
|
98
|
+
reply-dispatcher.ts outbound reply routing (complete frames only)
|
|
99
99
|
no-reply.ts clawchat:no-reply / :silent suppression token guard
|
|
100
100
|
login.runtime.ts invite-code exchange flow
|
|
101
101
|
refresh-manager.ts ClawChat token refresh + auto-logout orchestration
|
|
@@ -153,7 +153,7 @@ The main reference is
|
|
|
153
153
|
- Full configuration reference
|
|
154
154
|
- Onboarding / activation details
|
|
155
155
|
- REST endpoint table
|
|
156
|
-
-
|
|
156
|
+
- Inbound streaming frame shapes (`message.created` / `message.add` / `message.done` / `message.reply`) — consumed, never produced
|
|
157
157
|
- End-to-end sequence diagram
|
|
158
158
|
- Media pipeline (inbound download / outbound upload)
|
|
159
159
|
- Troubleshooting
|
package/dist/src/runtime.js
CHANGED
|
@@ -108,20 +108,26 @@ function isRecord(value) {
|
|
|
108
108
|
}
|
|
109
109
|
/**
|
|
110
110
|
* §A.2 — classify a WS `hello-fail` reason for refresh gating.
|
|
111
|
-
* - "token-rejected":
|
|
111
|
+
* - "token-rejected": the ONE terminal reason → refresh.
|
|
112
112
|
* - "auth-unavailable": 5xx auth-backend outage → backoff, DO NOT refresh.
|
|
113
|
-
* - "generic":
|
|
113
|
+
* - "generic": anything else → transient; backoff with the current token.
|
|
114
114
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
115
|
+
* msghub Protocol v2 §3.5 pins the matching contract: `"authentication failed"`
|
|
116
|
+
* is matched by exact equality (case / surrounding whitespace tolerant, like the
|
|
117
|
+
* hermes plugin's `_is_token_rejected`), never by substring or prefix, and no
|
|
118
|
+
* speculative matchers (`invalid token`, `token expired`, `unauthorized`, …) may
|
|
119
|
+
* be added — msghub does not emit them, and matching them would turn a reason
|
|
120
|
+
* added later into a forced refresh and, on a rotated refresh token, a re-pair.
|
|
121
|
+
* The ws-client already routes every non-terminal reason as a TransportError
|
|
122
|
+
* (backoff), so only the exact terminal string normally reaches this classifier;
|
|
123
|
+
* it stays defensive for the live-session path.
|
|
117
124
|
*/
|
|
118
125
|
export function classifyHelloFailReason(reason) {
|
|
119
|
-
const r = (reason || "").toLowerCase();
|
|
126
|
+
const r = (reason || "").trim().toLowerCase();
|
|
120
127
|
if (/auth service unavailable|temporarily unavailable/.test(r))
|
|
121
128
|
return "auth-unavailable";
|
|
122
|
-
if (
|
|
129
|
+
if (r === "authentication failed")
|
|
123
130
|
return "token-rejected";
|
|
124
|
-
}
|
|
125
131
|
return "generic";
|
|
126
132
|
}
|
|
127
133
|
/** Read `channels.<CHANNEL_ID>.refreshToken` from a live config, or null. */
|
|
@@ -1534,7 +1540,6 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1534
1540
|
const protocolControlLogger = createProtocolControlHandler({
|
|
1535
1541
|
accountId,
|
|
1536
1542
|
log: (msg) => log?.info?.(msg),
|
|
1537
|
-
send: () => { },
|
|
1538
1543
|
context: wsLogContext,
|
|
1539
1544
|
});
|
|
1540
1545
|
const notifySignalObserver = createNotifySignalObserver({
|
|
@@ -2914,18 +2919,23 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2914
2919
|
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
2915
2920
|
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
2916
2921
|
//
|
|
2917
|
-
// Synchronous and fire-and-forget on purpose
|
|
2918
|
-
// frame is BestEffort and non-ackable)
|
|
2919
|
-
//
|
|
2920
|
-
//
|
|
2921
|
-
//
|
|
2922
|
+
// Synchronous and fire-and-forget on purpose: there is no ack to send (the
|
|
2923
|
+
// frame is BestEffort and non-ackable).
|
|
2924
|
+
//
|
|
2925
|
+
// §9.8 requires a permanent tombstone consulted on every ingest path, and
|
|
2926
|
+
// `store.recallMessage` writes one in the same transaction as the delete.
|
|
2927
|
+
// That is what makes this handler's timing irrelevant: the inbound ledger
|
|
2928
|
+
// write sits behind an await, so on a replay carrying the original and its
|
|
2929
|
+
// recall back-to-back the DELETE runs first — and the tombstone then refuses
|
|
2930
|
+
// the INSERT. It also closes the Kafka-redelivery resurrection this comment
|
|
2931
|
+
// previously accepted as a known limitation.
|
|
2922
2932
|
client.on("message:recall", (env) => {
|
|
2923
2933
|
const target = recallTargetMessageId(env.payload);
|
|
2924
2934
|
if (!target) {
|
|
2925
2935
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`);
|
|
2926
2936
|
return;
|
|
2927
2937
|
}
|
|
2928
|
-
const removed = store?.
|
|
2938
|
+
const removed = store?.recallMessage?.({
|
|
2929
2939
|
platform: "openclaw",
|
|
2930
2940
|
accountId,
|
|
2931
2941
|
messageId: target,
|
package/dist/src/storage.js
CHANGED
|
@@ -171,6 +171,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
|
|
|
171
171
|
updated_at INTEGER NOT NULL,
|
|
172
172
|
PRIMARY KEY (platform, account_id)
|
|
173
173
|
);
|
|
174
|
+
`,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
version: 10,
|
|
178
|
+
name: "recalled_messages",
|
|
179
|
+
sql: `
|
|
180
|
+
CREATE TABLE IF NOT EXISTS recalled_messages (
|
|
181
|
+
platform TEXT NOT NULL,
|
|
182
|
+
account_id TEXT NOT NULL,
|
|
183
|
+
message_id TEXT NOT NULL,
|
|
184
|
+
recalled_at INTEGER NOT NULL,
|
|
185
|
+
PRIMARY KEY (platform, account_id, message_id)
|
|
186
|
+
);
|
|
174
187
|
`,
|
|
175
188
|
},
|
|
176
189
|
];
|
|
@@ -412,6 +425,14 @@ export class ClawChatStore {
|
|
|
412
425
|
}
|
|
413
426
|
claimMessageOnce(input) {
|
|
414
427
|
return this.write(() => {
|
|
428
|
+
// Protocol v2 §9.8: consult the recall tombstone on every ingest path.
|
|
429
|
+
// Doing it inside this same write transaction is what makes the delete
|
|
430
|
+
// and the insert order-independent: whichever frame the server delivers
|
|
431
|
+
// first, a recalled message_id can never end up in the ledger. Without
|
|
432
|
+
// it, a replay carrying the original and its recall back-to-back ran the
|
|
433
|
+
// DELETE before the INSERT and the row survived.
|
|
434
|
+
if (input.messageId && this.isMessageRecalled(input))
|
|
435
|
+
return false;
|
|
415
436
|
const result = this.requireDb()
|
|
416
437
|
.prepare(`INSERT OR IGNORE INTO clawchat_messages(
|
|
417
438
|
platform, account_id, kind, direction, event_type, trace_id, chat_id,
|
|
@@ -462,6 +483,43 @@ export class ClawChatStore {
|
|
|
462
483
|
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
463
484
|
* store is unavailable.
|
|
464
485
|
*/
|
|
486
|
+
/** True when `message_id` was recalled for this (platform, account). */
|
|
487
|
+
isMessageRecalled(input) {
|
|
488
|
+
if (!input.messageId)
|
|
489
|
+
return false;
|
|
490
|
+
const row = this.requireDb()
|
|
491
|
+
.prepare(`SELECT 1 FROM recalled_messages
|
|
492
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
493
|
+
.get(input.platform, input.accountId, input.messageId);
|
|
494
|
+
return row !== undefined;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Record the permanent recall tombstone and purge any row already stored,
|
|
498
|
+
* atomically. Returns the number of ledger rows deleted (0 when the recall
|
|
499
|
+
* arrived before the message did — the tombstone still stands and the later
|
|
500
|
+
* ingest is refused by {@link claimMessageOnce}).
|
|
501
|
+
*/
|
|
502
|
+
recallMessage(input) {
|
|
503
|
+
// An empty id would tombstone and delete rows keyed by '' — every row the
|
|
504
|
+
// store never managed to key. Refuse rather than guess.
|
|
505
|
+
if (!input.messageId)
|
|
506
|
+
return 0;
|
|
507
|
+
return this.write(() => {
|
|
508
|
+
const db = this.requireDb();
|
|
509
|
+
db.prepare(`INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
|
|
510
|
+
VALUES (?, ?, ?, ?)`).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
|
|
511
|
+
const result = db
|
|
512
|
+
.prepare(`DELETE FROM clawchat_messages
|
|
513
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
514
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
515
|
+
return Number(result.changes);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Low-level purge by message id. NOT the recall path: it writes no tombstone,
|
|
520
|
+
* so a recall routed through here loses to a redelivery or to an ingest that
|
|
521
|
+
* is still behind an await. Use {@link recallMessage} for `message.recall`.
|
|
522
|
+
*/
|
|
465
523
|
deleteMessagesByMessageId(input) {
|
|
466
524
|
// An empty id would match every row whose message_id is '' — deleting
|
|
467
525
|
// everything the store never managed to key. Refuse rather than guess.
|
package/dist/src/ws-alignment.js
CHANGED
|
@@ -171,15 +171,12 @@ export function createProtocolControlHandler(options) {
|
|
|
171
171
|
return {
|
|
172
172
|
handleInbound(env) {
|
|
173
173
|
if (env.event === "ping") {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
emitted_at: env.emitted_at ?? Date.now(),
|
|
181
|
-
payload: {},
|
|
182
|
-
}));
|
|
174
|
+
// §12: the server never emits a JSON-level `ping`, so this is an
|
|
175
|
+
// anomaly worth seeing — but answering it would send exactly the
|
|
176
|
+
// unsolicited `pong` the spec bans, which the server drops as an
|
|
177
|
+
// unknown uplink event. Log it and drop it; this handler has no send
|
|
178
|
+
// capability, so the banned frame cannot be produced here at all.
|
|
179
|
+
logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
|
|
183
180
|
return true;
|
|
184
181
|
}
|
|
185
182
|
if (env.event === "pong") {
|
package/dist/src/ws-client.js
CHANGED
|
@@ -185,6 +185,62 @@ export function isValidChatId(chatId) {
|
|
|
185
185
|
}
|
|
186
186
|
return true;
|
|
187
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* §3.5 terminal `hello-fail` reason. Exact equality after trimming and
|
|
190
|
+
* lower-casing (parity with the hermes plugin's `_is_token_rejected`); never a
|
|
191
|
+
* substring or prefix match — see `onHelloFail`.
|
|
192
|
+
*/
|
|
193
|
+
export const TERMINAL_HELLO_FAIL_REASON = "authentication failed";
|
|
194
|
+
export function isTerminalHelloFailReason(reason) {
|
|
195
|
+
return (reason ?? "").trim().toLowerCase() === TERMINAL_HELLO_FAIL_REASON;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* §3.6 duplicate-session refusal. msghub turns away the NEWER connection with
|
|
199
|
+
* this close code and a JSON reason `{reason, retry_after_ms}` when the opt-in
|
|
200
|
+
* guard is on. The server owns the 60s → 300s escalation, so the client only
|
|
201
|
+
* has to wait the value it is handed.
|
|
202
|
+
*/
|
|
203
|
+
export const CLOSE_CODE_DUPLICATE_THROTTLED = 4002;
|
|
204
|
+
/** Floor used when a 4002 close carries no parseable retry_after_ms. */
|
|
205
|
+
export const DEFAULT_REFUSAL_FLOOR_MS = 60_000;
|
|
206
|
+
/**
|
|
207
|
+
* §3.6 duplicate-session takeover. msghub closes the OLDER socket with 4001
|
|
208
|
+
* when a newer session for the same (user, device) takes over. Reconnecting at
|
|
209
|
+
* the ordinary initial delay races that session and kicks it back — the
|
|
210
|
+
* mutual-eviction storm.
|
|
211
|
+
*
|
|
212
|
+
* A single eviction waits the base floor; consecutive evictions of the same
|
|
213
|
+
* connection escalate it (doubling, capped), so whichever instance keeps losing
|
|
214
|
+
* withdraws further each round until the other simply stays connected. Prod
|
|
215
|
+
* measured 9–163 s per eviction (msghub 2026-08-18), longer than the base, so a
|
|
216
|
+
* flat floor cannot break a persistent loop — only the escalation does. The
|
|
217
|
+
* streak survives `hello-ok` (an eviction happens *after* we connect) and is
|
|
218
|
+
* reset only by a non-4001 close, mirroring the hermes plugin.
|
|
219
|
+
*/
|
|
220
|
+
export const CLOSE_CODE_REPLACED = 4001;
|
|
221
|
+
/** Base floor for the first takeover eviction (the §3.6 minimum). */
|
|
222
|
+
export const TAKEOVER_FLOOR_MS = 5_000;
|
|
223
|
+
/** Ceiling for the escalating takeover floor. */
|
|
224
|
+
export const TAKEOVER_FLOOR_MAX_MS = 600_000;
|
|
225
|
+
/** Reconnect floor for `streak` consecutive 4001 evictions (0 → no floor). */
|
|
226
|
+
export function takeoverFloorMs(streak) {
|
|
227
|
+
if (streak <= 0)
|
|
228
|
+
return 0;
|
|
229
|
+
return Math.min(TAKEOVER_FLOOR_MS * 2 ** (streak - 1), TAKEOVER_FLOOR_MAX_MS);
|
|
230
|
+
}
|
|
231
|
+
export function parseRefusalRetryAfterMs(reason) {
|
|
232
|
+
if (reason) {
|
|
233
|
+
try {
|
|
234
|
+
const value = JSON.parse(reason).retry_after_ms;
|
|
235
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0)
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// fall through to the default floor
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return DEFAULT_REFUSAL_FLOOR_MS;
|
|
243
|
+
}
|
|
188
244
|
export class ClawChatClient extends EventEmitter {
|
|
189
245
|
opts;
|
|
190
246
|
currentState = "idle";
|
|
@@ -194,6 +250,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
194
250
|
pongTimer;
|
|
195
251
|
reconnectTimer;
|
|
196
252
|
reconnectAttempts = 0;
|
|
253
|
+
// Consecutive 4001 duplicate-session evictions. Unlike reconnectAttempts it
|
|
254
|
+
// is NOT reset on hello-ok (the eviction lands after we connect); a non-4001
|
|
255
|
+
// close resets it. Drives the escalating takeover floor (§3.6).
|
|
256
|
+
takeoverStreak = 0;
|
|
197
257
|
closing = false;
|
|
198
258
|
authFailed = false;
|
|
199
259
|
expectedConnectTraceId;
|
|
@@ -548,8 +608,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
548
608
|
return this.onHelloOk(env);
|
|
549
609
|
if (env.event === EVENT.HELLO_FAIL)
|
|
550
610
|
return this.onHelloFail(env);
|
|
551
|
-
|
|
552
|
-
|
|
611
|
+
// §12: the server never emits a JSON-level `ping`, and an unsolicited
|
|
612
|
+
// `pong` is an unknown uplink event the server drops. We therefore do NOT
|
|
613
|
+
// answer a downlink ping. `pong` below is still handled: it is the reply
|
|
614
|
+
// to the client-initiated heartbeat probe §12 does allow.
|
|
553
615
|
if (env.event === EVENT.PONG)
|
|
554
616
|
return this.onPong();
|
|
555
617
|
if (env.event === EVENT.MESSAGE_ACK)
|
|
@@ -662,13 +724,16 @@ export class ClawChatClient extends EventEmitter {
|
|
|
662
724
|
this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
|
|
663
725
|
return;
|
|
664
726
|
}
|
|
665
|
-
// §
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
727
|
+
// §3.5: `"authentication failed"` — matched by EXACT equality, never a
|
|
728
|
+
// substring — is the one terminal reason (the token was rejected by upstream
|
|
729
|
+
// auth). Every other reason is transient: `remote auth service unavailable`
|
|
730
|
+
// (the auth backend is down, the token may still be valid — §14.1), `nonce
|
|
731
|
+
// mismatch`, `invalid connect …`, and any string msghub adds after this
|
|
732
|
+
// client shipped. For all of those: backoff-reconnect with the SAME token,
|
|
733
|
+
// keep the outbound queue, and do NOT refresh (a 5xx storm must not become
|
|
734
|
+
// a mass token-refresh storm; a new server-side reason must not brick old
|
|
735
|
+
// clients). Widening the terminal branch breaks that forward-compat rule.
|
|
736
|
+
if (!isTerminalHelloFailReason(reason)) {
|
|
672
737
|
const err = new TransportError(reason);
|
|
673
738
|
this.expectedConnectTraceId = undefined;
|
|
674
739
|
this.clearTimers();
|
|
@@ -678,8 +743,12 @@ export class ClawChatClient extends EventEmitter {
|
|
|
678
743
|
this.connectReject = undefined;
|
|
679
744
|
this.emitError(err);
|
|
680
745
|
if (this.opts.transport.state !== "closed") {
|
|
681
|
-
// Close WITHOUT marking closing/authFailed so handleClose
|
|
682
|
-
|
|
746
|
+
// Close WITHOUT marking closing/authFailed so handleClose
|
|
747
|
+
// backoff-reconnects. Use 1000, NOT 4001: 4001 is the server's
|
|
748
|
+
// duplicate-session takeover code that handleClose now floors to 5s
|
|
749
|
+
// (§3.6), and this client-initiated transient teardown must reconnect
|
|
750
|
+
// on the ordinary backoff instead of inheriting that floor.
|
|
751
|
+
this.opts.transport.close(1000, reason);
|
|
683
752
|
}
|
|
684
753
|
else if (!this.closing && this.opts.reconnect.enabled) {
|
|
685
754
|
this.scheduleReconnect(reason);
|
|
@@ -704,15 +773,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
704
773
|
this.opts.transport.close(4001, "auth failed");
|
|
705
774
|
}
|
|
706
775
|
}
|
|
707
|
-
onPing(env) {
|
|
708
|
-
this.sendRawEnvelope({
|
|
709
|
-
version: "2",
|
|
710
|
-
event: EVENT.PONG,
|
|
711
|
-
trace_id: env.trace_id,
|
|
712
|
-
emitted_at: env.emitted_at,
|
|
713
|
-
payload: {},
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
776
|
onPong() {
|
|
717
777
|
if (this.pongTimer)
|
|
718
778
|
clearTimeout(this.pongTimer);
|
|
@@ -803,7 +863,27 @@ export class ClawChatClient extends EventEmitter {
|
|
|
803
863
|
this.connectReject?.(closeError);
|
|
804
864
|
return;
|
|
805
865
|
}
|
|
806
|
-
|
|
866
|
+
// §3.6: raise an explicit reconnect floor for the two server-timed
|
|
867
|
+
// duplicate-session closes. 4002 (refusal) waits the server's
|
|
868
|
+
// retry_after_ms, which exceeds the maxDelay cap; 4001 (takeover) waits at
|
|
869
|
+
// least 5s so an instant reconnect doesn't re-enter the eviction storm.
|
|
870
|
+
let floorMs;
|
|
871
|
+
if (code === CLOSE_CODE_DUPLICATE_THROTTLED) {
|
|
872
|
+
// A refusal is not a takeover-loss — reset the streak and wait the
|
|
873
|
+
// server's retry_after_ms.
|
|
874
|
+
this.takeoverStreak = 0;
|
|
875
|
+
floorMs = parseRefusalRetryAfterMs(reason);
|
|
876
|
+
}
|
|
877
|
+
else if (code === CLOSE_CODE_REPLACED) {
|
|
878
|
+
this.takeoverStreak += 1;
|
|
879
|
+
floorMs = takeoverFloorMs(this.takeoverStreak);
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
// Any other close (ordinary drop, backpressure kick) is not an eviction
|
|
883
|
+
// loss — reset so the next takeover starts from the base floor again.
|
|
884
|
+
this.takeoverStreak = 0;
|
|
885
|
+
}
|
|
886
|
+
this.scheduleReconnect(reason || `close ${code}`, floorMs);
|
|
807
887
|
}
|
|
808
888
|
failHandshake(err, code, reason) {
|
|
809
889
|
this.clearTimers();
|
|
@@ -820,7 +900,7 @@ export class ClawChatClient extends EventEmitter {
|
|
|
820
900
|
this.opts.transport.close(code, reason);
|
|
821
901
|
}
|
|
822
902
|
}
|
|
823
|
-
scheduleReconnect(reason) {
|
|
903
|
+
scheduleReconnect(reason, floorMs) {
|
|
824
904
|
if (this.reconnectTimer)
|
|
825
905
|
return;
|
|
826
906
|
if (this.reconnectAttempts >= this.opts.reconnect.maxRetries) {
|
|
@@ -830,7 +910,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
830
910
|
return;
|
|
831
911
|
}
|
|
832
912
|
this.reconnectAttempts += 1;
|
|
833
|
-
const
|
|
913
|
+
const cappedDelay = Math.min(this.opts.reconnect.maxDelay, this.opts.reconnect.initialDelay * 2 ** Math.max(0, this.reconnectAttempts - 1));
|
|
914
|
+
// An explicit floor (a 4002 retry_after_ms) overrides the maxDelay cap: the
|
|
915
|
+
// server's wait is deliberately longer than the ordinary backoff ceiling.
|
|
916
|
+
const baseDelay = floorMs !== undefined ? Math.max(cappedDelay, floorMs) : cappedDelay;
|
|
834
917
|
const jitter = baseDelay * this.opts.reconnect.jitterRatio * Math.random();
|
|
835
918
|
const delay = Math.round(baseDelay + jitter);
|
|
836
919
|
this.transition("reconnecting");
|
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -124,7 +124,7 @@ type RuntimeConnectionStore = Pick<
|
|
|
124
124
|
| "getActivationConversation"
|
|
125
125
|
| "getLastResolvedDeviceId"
|
|
126
126
|
| "listRecentGroupMessages"
|
|
127
|
-
| "
|
|
127
|
+
| "recallMessage"
|
|
128
128
|
| "upsertOwnerProfile"
|
|
129
129
|
>
|
|
130
130
|
>;
|
|
@@ -210,21 +210,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
212
|
* §A.2 — classify a WS `hello-fail` reason for refresh gating.
|
|
213
|
-
* - "token-rejected":
|
|
213
|
+
* - "token-rejected": the ONE terminal reason → refresh.
|
|
214
214
|
* - "auth-unavailable": 5xx auth-backend outage → backoff, DO NOT refresh.
|
|
215
|
-
* - "generic":
|
|
215
|
+
* - "generic": anything else → transient; backoff with the current token.
|
|
216
216
|
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
217
|
+
* msghub Protocol v2 §3.5 pins the matching contract: `"authentication failed"`
|
|
218
|
+
* is matched by exact equality (case / surrounding whitespace tolerant, like the
|
|
219
|
+
* hermes plugin's `_is_token_rejected`), never by substring or prefix, and no
|
|
220
|
+
* speculative matchers (`invalid token`, `token expired`, `unauthorized`, …) may
|
|
221
|
+
* be added — msghub does not emit them, and matching them would turn a reason
|
|
222
|
+
* added later into a forced refresh and, on a rotated refresh token, a re-pair.
|
|
223
|
+
* The ws-client already routes every non-terminal reason as a TransportError
|
|
224
|
+
* (backoff), so only the exact terminal string normally reaches this classifier;
|
|
225
|
+
* it stays defensive for the live-session path.
|
|
219
226
|
*/
|
|
220
227
|
export function classifyHelloFailReason(
|
|
221
228
|
reason: string,
|
|
222
229
|
): "token-rejected" | "auth-unavailable" | "generic" {
|
|
223
|
-
const r = (reason || "").toLowerCase();
|
|
230
|
+
const r = (reason || "").trim().toLowerCase();
|
|
224
231
|
if (/auth service unavailable|temporarily unavailable/.test(r)) return "auth-unavailable";
|
|
225
|
-
if (
|
|
226
|
-
return "token-rejected";
|
|
227
|
-
}
|
|
232
|
+
if (r === "authentication failed") return "token-rejected";
|
|
228
233
|
return "generic";
|
|
229
234
|
}
|
|
230
235
|
|
|
@@ -1922,7 +1927,6 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1922
1927
|
const protocolControlLogger = createProtocolControlHandler({
|
|
1923
1928
|
accountId,
|
|
1924
1929
|
log: (msg) => log?.info?.(msg),
|
|
1925
|
-
send: () => {},
|
|
1926
1930
|
context: wsLogContext,
|
|
1927
1931
|
});
|
|
1928
1932
|
const notifySignalObserver = createNotifySignalObserver({
|
|
@@ -3456,11 +3460,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3456
3460
|
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
3457
3461
|
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
3458
3462
|
//
|
|
3459
|
-
// Synchronous and fire-and-forget on purpose
|
|
3460
|
-
// frame is BestEffort and non-ackable)
|
|
3461
|
-
//
|
|
3462
|
-
//
|
|
3463
|
-
//
|
|
3463
|
+
// Synchronous and fire-and-forget on purpose: there is no ack to send (the
|
|
3464
|
+
// frame is BestEffort and non-ackable).
|
|
3465
|
+
//
|
|
3466
|
+
// §9.8 requires a permanent tombstone consulted on every ingest path, and
|
|
3467
|
+
// `store.recallMessage` writes one in the same transaction as the delete.
|
|
3468
|
+
// That is what makes this handler's timing irrelevant: the inbound ledger
|
|
3469
|
+
// write sits behind an await, so on a replay carrying the original and its
|
|
3470
|
+
// recall back-to-back the DELETE runs first — and the tombstone then refuses
|
|
3471
|
+
// the INSERT. It also closes the Kafka-redelivery resurrection this comment
|
|
3472
|
+
// previously accepted as a known limitation.
|
|
3464
3473
|
client.on("message:recall", (env: Envelope) => {
|
|
3465
3474
|
const target = recallTargetMessageId(env.payload);
|
|
3466
3475
|
if (!target) {
|
|
@@ -3469,7 +3478,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3469
3478
|
);
|
|
3470
3479
|
return;
|
|
3471
3480
|
}
|
|
3472
|
-
const removed = store?.
|
|
3481
|
+
const removed = store?.recallMessage?.({
|
|
3473
3482
|
platform: "openclaw",
|
|
3474
3483
|
accountId,
|
|
3475
3484
|
messageId: target,
|
package/src/storage.ts
CHANGED
|
@@ -389,6 +389,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
|
|
|
389
389
|
updated_at INTEGER NOT NULL,
|
|
390
390
|
PRIMARY KEY (platform, account_id)
|
|
391
391
|
);
|
|
392
|
+
`,
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
version: 10,
|
|
396
|
+
name: "recalled_messages",
|
|
397
|
+
sql: `
|
|
398
|
+
CREATE TABLE IF NOT EXISTS recalled_messages (
|
|
399
|
+
platform TEXT NOT NULL,
|
|
400
|
+
account_id TEXT NOT NULL,
|
|
401
|
+
message_id TEXT NOT NULL,
|
|
402
|
+
recalled_at INTEGER NOT NULL,
|
|
403
|
+
PRIMARY KEY (platform, account_id, message_id)
|
|
404
|
+
);
|
|
392
405
|
`,
|
|
393
406
|
},
|
|
394
407
|
];
|
|
@@ -722,6 +735,13 @@ export class ClawChatStore {
|
|
|
722
735
|
|
|
723
736
|
claimMessageOnce(input: MessageInput): true | false | null {
|
|
724
737
|
return this.write(() => {
|
|
738
|
+
// Protocol v2 §9.8: consult the recall tombstone on every ingest path.
|
|
739
|
+
// Doing it inside this same write transaction is what makes the delete
|
|
740
|
+
// and the insert order-independent: whichever frame the server delivers
|
|
741
|
+
// first, a recalled message_id can never end up in the ledger. Without
|
|
742
|
+
// it, a replay carrying the original and its recall back-to-back ran the
|
|
743
|
+
// DELETE before the INSERT and the row survived.
|
|
744
|
+
if (input.messageId && this.isMessageRecalled(input)) return false;
|
|
725
745
|
const result = this.requireDb()
|
|
726
746
|
.prepare(
|
|
727
747
|
`INSERT OR IGNORE INTO clawchat_messages(
|
|
@@ -821,6 +841,54 @@ export class ClawChatStore {
|
|
|
821
841
|
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
822
842
|
* store is unavailable.
|
|
823
843
|
*/
|
|
844
|
+
/** True when `message_id` was recalled for this (platform, account). */
|
|
845
|
+
isMessageRecalled(input: { platform: string; accountId: string; messageId?: string | null }): boolean {
|
|
846
|
+
if (!input.messageId) return false;
|
|
847
|
+
const row = this.requireDb()
|
|
848
|
+
.prepare(
|
|
849
|
+
`SELECT 1 FROM recalled_messages
|
|
850
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`,
|
|
851
|
+
)
|
|
852
|
+
.get(input.platform, input.accountId, input.messageId);
|
|
853
|
+
return row !== undefined;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Record the permanent recall tombstone and purge any row already stored,
|
|
858
|
+
* atomically. Returns the number of ledger rows deleted (0 when the recall
|
|
859
|
+
* arrived before the message did — the tombstone still stands and the later
|
|
860
|
+
* ingest is refused by {@link claimMessageOnce}).
|
|
861
|
+
*/
|
|
862
|
+
recallMessage(input: {
|
|
863
|
+
platform: string;
|
|
864
|
+
accountId: string;
|
|
865
|
+
messageId: string;
|
|
866
|
+
recalledAt?: number;
|
|
867
|
+
}): number | null {
|
|
868
|
+
// An empty id would tombstone and delete rows keyed by '' — every row the
|
|
869
|
+
// store never managed to key. Refuse rather than guess.
|
|
870
|
+
if (!input.messageId) return 0;
|
|
871
|
+
return this.write(() => {
|
|
872
|
+
const db = this.requireDb();
|
|
873
|
+
db.prepare(
|
|
874
|
+
`INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
|
|
875
|
+
VALUES (?, ?, ?, ?)`,
|
|
876
|
+
).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
|
|
877
|
+
const result = db
|
|
878
|
+
.prepare(
|
|
879
|
+
`DELETE FROM clawchat_messages
|
|
880
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`,
|
|
881
|
+
)
|
|
882
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
883
|
+
return Number(result.changes);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Low-level purge by message id. NOT the recall path: it writes no tombstone,
|
|
889
|
+
* so a recall routed through here loses to a redelivery or to an ingest that
|
|
890
|
+
* is still behind an await. Use {@link recallMessage} for `message.recall`.
|
|
891
|
+
*/
|
|
824
892
|
deleteMessagesByMessageId(input: {
|
|
825
893
|
platform: string;
|
|
826
894
|
accountId: string;
|
package/src/ws-alignment.ts
CHANGED
|
@@ -238,7 +238,6 @@ export interface ProtocolControlEnvelope {
|
|
|
238
238
|
export interface CreateProtocolControlHandlerOptions {
|
|
239
239
|
accountId: string;
|
|
240
240
|
log: (msg: string) => void;
|
|
241
|
-
send: (wire: string) => void;
|
|
242
241
|
scheduleReconnect?: (reason: string) => void;
|
|
243
242
|
attempt?: number;
|
|
244
243
|
reconnectCount?: number;
|
|
@@ -275,17 +274,12 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
|
|
|
275
274
|
return {
|
|
276
275
|
handleInbound(env: ProtocolControlEnvelope): boolean {
|
|
277
276
|
if (env.event === "ping") {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// §12: echo the sender's emitted_at verbatim (do not restamp).
|
|
285
|
-
emitted_at: env.emitted_at ?? Date.now(),
|
|
286
|
-
payload: {},
|
|
287
|
-
}),
|
|
288
|
-
);
|
|
277
|
+
// §12: the server never emits a JSON-level `ping`, so this is an
|
|
278
|
+
// anomaly worth seeing — but answering it would send exactly the
|
|
279
|
+
// unsolicited `pong` the spec bans, which the server drops as an
|
|
280
|
+
// unknown uplink event. Log it and drop it; this handler has no send
|
|
281
|
+
// capability, so the banned frame cannot be produced here at all.
|
|
282
|
+
logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
|
|
289
283
|
return true;
|
|
290
284
|
}
|
|
291
285
|
if (env.event === "pong") {
|
package/src/ws-client.ts
CHANGED
|
@@ -270,6 +270,65 @@ export function isValidChatId(chatId: unknown): chatId is string {
|
|
|
270
270
|
*/
|
|
271
271
|
export type DeadChatSource = "signal" | "server";
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* §3.5 terminal `hello-fail` reason. Exact equality after trimming and
|
|
275
|
+
* lower-casing (parity with the hermes plugin's `_is_token_rejected`); never a
|
|
276
|
+
* substring or prefix match — see `onHelloFail`.
|
|
277
|
+
*/
|
|
278
|
+
export const TERMINAL_HELLO_FAIL_REASON = "authentication failed";
|
|
279
|
+
|
|
280
|
+
export function isTerminalHelloFailReason(reason: string | undefined): boolean {
|
|
281
|
+
return (reason ?? "").trim().toLowerCase() === TERMINAL_HELLO_FAIL_REASON;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* §3.6 duplicate-session refusal. msghub turns away the NEWER connection with
|
|
286
|
+
* this close code and a JSON reason `{reason, retry_after_ms}` when the opt-in
|
|
287
|
+
* guard is on. The server owns the 60s → 300s escalation, so the client only
|
|
288
|
+
* has to wait the value it is handed.
|
|
289
|
+
*/
|
|
290
|
+
export const CLOSE_CODE_DUPLICATE_THROTTLED = 4002;
|
|
291
|
+
/** Floor used when a 4002 close carries no parseable retry_after_ms. */
|
|
292
|
+
export const DEFAULT_REFUSAL_FLOOR_MS = 60_000;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* §3.6 duplicate-session takeover. msghub closes the OLDER socket with 4001
|
|
296
|
+
* when a newer session for the same (user, device) takes over. Reconnecting at
|
|
297
|
+
* the ordinary initial delay races that session and kicks it back — the
|
|
298
|
+
* mutual-eviction storm.
|
|
299
|
+
*
|
|
300
|
+
* A single eviction waits the base floor; consecutive evictions of the same
|
|
301
|
+
* connection escalate it (doubling, capped), so whichever instance keeps losing
|
|
302
|
+
* withdraws further each round until the other simply stays connected. Prod
|
|
303
|
+
* measured 9–163 s per eviction (msghub 2026-08-18), longer than the base, so a
|
|
304
|
+
* flat floor cannot break a persistent loop — only the escalation does. The
|
|
305
|
+
* streak survives `hello-ok` (an eviction happens *after* we connect) and is
|
|
306
|
+
* reset only by a non-4001 close, mirroring the hermes plugin.
|
|
307
|
+
*/
|
|
308
|
+
export const CLOSE_CODE_REPLACED = 4001;
|
|
309
|
+
/** Base floor for the first takeover eviction (the §3.6 minimum). */
|
|
310
|
+
export const TAKEOVER_FLOOR_MS = 5_000;
|
|
311
|
+
/** Ceiling for the escalating takeover floor. */
|
|
312
|
+
export const TAKEOVER_FLOOR_MAX_MS = 600_000;
|
|
313
|
+
|
|
314
|
+
/** Reconnect floor for `streak` consecutive 4001 evictions (0 → no floor). */
|
|
315
|
+
export function takeoverFloorMs(streak: number): number {
|
|
316
|
+
if (streak <= 0) return 0;
|
|
317
|
+
return Math.min(TAKEOVER_FLOOR_MS * 2 ** (streak - 1), TAKEOVER_FLOOR_MAX_MS);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function parseRefusalRetryAfterMs(reason: string | undefined): number {
|
|
321
|
+
if (reason) {
|
|
322
|
+
try {
|
|
323
|
+
const value = (JSON.parse(reason) as { retry_after_ms?: unknown }).retry_after_ms;
|
|
324
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
325
|
+
} catch {
|
|
326
|
+
// fall through to the default floor
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return DEFAULT_REFUSAL_FLOOR_MS;
|
|
330
|
+
}
|
|
331
|
+
|
|
273
332
|
export class ClawChatClient extends EventEmitter {
|
|
274
333
|
private currentState: ConnState = "idle";
|
|
275
334
|
private connectResolve?: () => void;
|
|
@@ -278,6 +337,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
278
337
|
private pongTimer?: ReturnType<typeof setTimeout>;
|
|
279
338
|
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
280
339
|
private reconnectAttempts = 0;
|
|
340
|
+
// Consecutive 4001 duplicate-session evictions. Unlike reconnectAttempts it
|
|
341
|
+
// is NOT reset on hello-ok (the eviction lands after we connect); a non-4001
|
|
342
|
+
// close resets it. Drives the escalating takeover floor (§3.6).
|
|
343
|
+
private takeoverStreak = 0;
|
|
281
344
|
private closing = false;
|
|
282
345
|
private authFailed = false;
|
|
283
346
|
private expectedConnectTraceId?: string;
|
|
@@ -646,7 +709,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
646
709
|
if (env.event === EVENT.CONNECT_CHALLENGE) return this.onChallenge(env);
|
|
647
710
|
if (env.event === EVENT.HELLO_OK) return this.onHelloOk(env);
|
|
648
711
|
if (env.event === EVENT.HELLO_FAIL) return this.onHelloFail(env);
|
|
649
|
-
|
|
712
|
+
// §12: the server never emits a JSON-level `ping`, and an unsolicited
|
|
713
|
+
// `pong` is an unknown uplink event the server drops. We therefore do NOT
|
|
714
|
+
// answer a downlink ping. `pong` below is still handled: it is the reply
|
|
715
|
+
// to the client-initiated heartbeat probe §12 does allow.
|
|
650
716
|
if (env.event === EVENT.PONG) return this.onPong();
|
|
651
717
|
if (env.event === EVENT.MESSAGE_ACK) return this.onAck(env as Envelope<MessageAckPayload>);
|
|
652
718
|
if (env.event === EVENT.MESSAGE_ERROR) return this.onMessageError(env as Envelope<MessageErrorPayload>);
|
|
@@ -744,13 +810,16 @@ export class ClawChatClient extends EventEmitter {
|
|
|
744
810
|
this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
|
|
745
811
|
return;
|
|
746
812
|
}
|
|
747
|
-
// §
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
813
|
+
// §3.5: `"authentication failed"` — matched by EXACT equality, never a
|
|
814
|
+
// substring — is the one terminal reason (the token was rejected by upstream
|
|
815
|
+
// auth). Every other reason is transient: `remote auth service unavailable`
|
|
816
|
+
// (the auth backend is down, the token may still be valid — §14.1), `nonce
|
|
817
|
+
// mismatch`, `invalid connect …`, and any string msghub adds after this
|
|
818
|
+
// client shipped. For all of those: backoff-reconnect with the SAME token,
|
|
819
|
+
// keep the outbound queue, and do NOT refresh (a 5xx storm must not become
|
|
820
|
+
// a mass token-refresh storm; a new server-side reason must not brick old
|
|
821
|
+
// clients). Widening the terminal branch breaks that forward-compat rule.
|
|
822
|
+
if (!isTerminalHelloFailReason(reason)) {
|
|
754
823
|
const err = new TransportError(reason);
|
|
755
824
|
this.expectedConnectTraceId = undefined;
|
|
756
825
|
this.clearTimers();
|
|
@@ -760,8 +829,12 @@ export class ClawChatClient extends EventEmitter {
|
|
|
760
829
|
this.connectReject = undefined;
|
|
761
830
|
this.emitError(err);
|
|
762
831
|
if (this.opts.transport.state !== "closed") {
|
|
763
|
-
// Close WITHOUT marking closing/authFailed so handleClose
|
|
764
|
-
|
|
832
|
+
// Close WITHOUT marking closing/authFailed so handleClose
|
|
833
|
+
// backoff-reconnects. Use 1000, NOT 4001: 4001 is the server's
|
|
834
|
+
// duplicate-session takeover code that handleClose now floors to 5s
|
|
835
|
+
// (§3.6), and this client-initiated transient teardown must reconnect
|
|
836
|
+
// on the ordinary backoff instead of inheriting that floor.
|
|
837
|
+
this.opts.transport.close(1000, reason);
|
|
765
838
|
} else if (!this.closing && this.opts.reconnect.enabled) {
|
|
766
839
|
this.scheduleReconnect(reason);
|
|
767
840
|
} else {
|
|
@@ -785,16 +858,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
785
858
|
}
|
|
786
859
|
}
|
|
787
860
|
|
|
788
|
-
private onPing(env: Envelope): void {
|
|
789
|
-
this.sendRawEnvelope({
|
|
790
|
-
version: "2",
|
|
791
|
-
event: EVENT.PONG,
|
|
792
|
-
trace_id: env.trace_id,
|
|
793
|
-
emitted_at: env.emitted_at,
|
|
794
|
-
payload: {} satisfies EmptyPayload,
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
|
|
798
861
|
private onPong(): void {
|
|
799
862
|
if (this.pongTimer) clearTimeout(this.pongTimer);
|
|
800
863
|
this.pongTimer = undefined;
|
|
@@ -882,7 +945,25 @@ export class ClawChatClient extends EventEmitter {
|
|
|
882
945
|
this.connectReject?.(closeError);
|
|
883
946
|
return;
|
|
884
947
|
}
|
|
885
|
-
|
|
948
|
+
// §3.6: raise an explicit reconnect floor for the two server-timed
|
|
949
|
+
// duplicate-session closes. 4002 (refusal) waits the server's
|
|
950
|
+
// retry_after_ms, which exceeds the maxDelay cap; 4001 (takeover) waits at
|
|
951
|
+
// least 5s so an instant reconnect doesn't re-enter the eviction storm.
|
|
952
|
+
let floorMs: number | undefined;
|
|
953
|
+
if (code === CLOSE_CODE_DUPLICATE_THROTTLED) {
|
|
954
|
+
// A refusal is not a takeover-loss — reset the streak and wait the
|
|
955
|
+
// server's retry_after_ms.
|
|
956
|
+
this.takeoverStreak = 0;
|
|
957
|
+
floorMs = parseRefusalRetryAfterMs(reason);
|
|
958
|
+
} else if (code === CLOSE_CODE_REPLACED) {
|
|
959
|
+
this.takeoverStreak += 1;
|
|
960
|
+
floorMs = takeoverFloorMs(this.takeoverStreak);
|
|
961
|
+
} else {
|
|
962
|
+
// Any other close (ordinary drop, backpressure kick) is not an eviction
|
|
963
|
+
// loss — reset so the next takeover starts from the base floor again.
|
|
964
|
+
this.takeoverStreak = 0;
|
|
965
|
+
}
|
|
966
|
+
this.scheduleReconnect(reason || `close ${code}`, floorMs);
|
|
886
967
|
}
|
|
887
968
|
|
|
888
969
|
private failHandshake(err: Error, code: number, reason: string): void {
|
|
@@ -901,7 +982,7 @@ export class ClawChatClient extends EventEmitter {
|
|
|
901
982
|
}
|
|
902
983
|
}
|
|
903
984
|
|
|
904
|
-
private scheduleReconnect(reason: string): void {
|
|
985
|
+
private scheduleReconnect(reason: string, floorMs?: number): void {
|
|
905
986
|
if (this.reconnectTimer) return;
|
|
906
987
|
if (this.reconnectAttempts >= this.opts.reconnect.maxRetries) {
|
|
907
988
|
this.sendQueue.length = 0;
|
|
@@ -910,10 +991,13 @@ export class ClawChatClient extends EventEmitter {
|
|
|
910
991
|
return;
|
|
911
992
|
}
|
|
912
993
|
this.reconnectAttempts += 1;
|
|
913
|
-
const
|
|
994
|
+
const cappedDelay = Math.min(
|
|
914
995
|
this.opts.reconnect.maxDelay,
|
|
915
996
|
this.opts.reconnect.initialDelay * 2 ** Math.max(0, this.reconnectAttempts - 1),
|
|
916
997
|
);
|
|
998
|
+
// An explicit floor (a 4002 retry_after_ms) overrides the maxDelay cap: the
|
|
999
|
+
// server's wait is deliberately longer than the ordinary backoff ceiling.
|
|
1000
|
+
const baseDelay = floorMs !== undefined ? Math.max(cappedDelay, floorMs) : cappedDelay;
|
|
917
1001
|
const jitter = baseDelay * this.opts.reconnect.jitterRatio * Math.random();
|
|
918
1002
|
const delay = Math.round(baseDelay + jitter);
|
|
919
1003
|
this.transition("reconnecting");
|