openclaw-lark-multi-agent 1.8.1 → 1.8.3
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/dist/feishu-bot.d.ts +5 -0
- package/dist/feishu-bot.js +54 -8
- package/dist/openclaw-client.d.ts +20 -0
- package/dist/openclaw-client.js +92 -7
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -50,6 +50,11 @@ export declare class FeishuBot {
|
|
|
50
50
|
* DONE when that batch completes successfully; cleared (unflipped) when the
|
|
51
51
|
* batch scope ends any other way. */
|
|
52
52
|
private admittedSteerAcks;
|
|
53
|
+
/** Identity token of the CURRENT queue owner per chat. Steer acks carry
|
|
54
|
+
* the token of the batch that admitted them; a completion path only flips
|
|
55
|
+
* acks of its OWN batch, so a superseded inner can never DONE a newer
|
|
56
|
+
* batch's steered inputs (2026-10-16 audit B9). */
|
|
57
|
+
private queueRunTokens;
|
|
53
58
|
/** Go / Go-Checker supervision controller (per-bot instance). */
|
|
54
59
|
private readonly goDeliveryReceipts;
|
|
55
60
|
private readonly goReceiptApis;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -5,6 +5,7 @@ import { persistBotModel } from "./config.js";
|
|
|
5
5
|
import { getI18n, normalizeLocale } from "./i18n.js";
|
|
6
6
|
import { InactiveRunObservation } from "./openclaw-client.js";
|
|
7
7
|
import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
8
|
+
import { SupersededObservation } from "./openclaw-client.js";
|
|
8
9
|
import { LiveStatusController } from "./live-status.js";
|
|
9
10
|
import { CompactProgressController } from "./compact-progress.js";
|
|
10
11
|
import { GoController } from "./go-controller.js";
|
|
@@ -148,6 +149,11 @@ export class FeishuBot {
|
|
|
148
149
|
* DONE when that batch completes successfully; cleared (unflipped) when the
|
|
149
150
|
* batch scope ends any other way. */
|
|
150
151
|
admittedSteerAcks = new Map();
|
|
152
|
+
/** Identity token of the CURRENT queue owner per chat. Steer acks carry
|
|
153
|
+
* the token of the batch that admitted them; a completion path only flips
|
|
154
|
+
* acks of its OWN batch, so a superseded inner can never DONE a newer
|
|
155
|
+
* batch's steered inputs (2026-10-16 audit B9). */
|
|
156
|
+
queueRunTokens = new Map();
|
|
151
157
|
/** Go / Go-Checker supervision controller (per-bot instance). */
|
|
152
158
|
goDeliveryReceipts = new AsyncLocalStorage();
|
|
153
159
|
goReceiptApis = new WeakSet();
|
|
@@ -1226,7 +1232,7 @@ export class FeishuBot {
|
|
|
1226
1232
|
// never (2026.9.9 removed mirrors); either way the message belongs
|
|
1227
1233
|
// to this batch for the Get -> DONE flip.
|
|
1228
1234
|
const steerAckList = this.admittedSteerAcks.get(chatId) || [];
|
|
1229
|
-
steerAckList.push({ messageId, insertedId });
|
|
1235
|
+
steerAckList.push({ messageId, insertedId, batch: this.queueRunTokens.get(chatId) });
|
|
1230
1236
|
this.admittedSteerAcks.set(chatId, steerAckList);
|
|
1231
1237
|
}
|
|
1232
1238
|
if (insertedId > 0 && !consumedBeforeAck) {
|
|
@@ -1253,8 +1259,17 @@ export class FeishuBot {
|
|
|
1253
1259
|
this.admittedSteerAcks.set(chatId, (this.admittedSteerAcks.get(chatId) || []).filter((a) => a.insertedId !== insertedId));
|
|
1254
1260
|
console.log(`[${this.config.name}] steer fallback restored after failed run for ${chatId.slice(-8)}: "${cleanText.substring(0, 40)}..."`);
|
|
1255
1261
|
// Deferred: the current queue run is still active here; a fresh
|
|
1256
|
-
// drain must start after its finally releases the chat.
|
|
1257
|
-
|
|
1262
|
+
// drain must start after its finally releases the chat. A 0ms
|
|
1263
|
+
// timer is NOT enough — the owner's catch block still has
|
|
1264
|
+
// in-flight awaits (error delivery), so processQueue would join
|
|
1265
|
+
// the dying owner and return, stranding the restored trigger
|
|
1266
|
+
// until the next user message (2026-10-16 audit finding).
|
|
1267
|
+
const ownerNow = this.queueRuns.get(chatId);
|
|
1268
|
+
const drainRestored = () => { void this.processQueue(chatId).catch(() => { }); };
|
|
1269
|
+
if (ownerNow)
|
|
1270
|
+
void ownerNow.then(drainRestored, drainRestored).catch(() => { });
|
|
1271
|
+
else
|
|
1272
|
+
setTimeout(drainRestored, 0).unref?.();
|
|
1258
1273
|
});
|
|
1259
1274
|
this.runFailureObservers.set(chatId, observers);
|
|
1260
1275
|
const owner = this.queueRuns.get(chatId);
|
|
@@ -1293,8 +1308,14 @@ export class FeishuBot {
|
|
|
1293
1308
|
// path and it will never settle). Force-expire it and process this input
|
|
1294
1309
|
// NOW instead of queueing behind a dead promise forever.
|
|
1295
1310
|
if (steer.status === "no_active_run" && this.queueRuns.has(chatId)) {
|
|
1311
|
+
const ownerAtCheck = this.queueRuns.get(chatId);
|
|
1296
1312
|
const live = await this.openclawClient.getSessionRuntimeStatus(sessionKeyForSteer).catch(() => null);
|
|
1297
|
-
|
|
1313
|
+
// B1 (2026-10-16 audit): require POSITIVE idle evidence. An RPC
|
|
1314
|
+
// failure normalizes to {status:"unknown", running:false} and must
|
|
1315
|
+
// never authorize expiring a legitimately running owner. The owner
|
|
1316
|
+
// identity is also CAS-checked across the status RPC: a newer batch
|
|
1317
|
+
// that took over meanwhile must not be expired by stale evidence.
|
|
1318
|
+
if (live && !live.running && live.status !== "unknown" && this.queueRuns.get(chatId) === ownerAtCheck) {
|
|
1298
1319
|
console.warn(`[${this.config.name}] gateway reports session idle while queue owner exists for ${chatId.slice(-8)} — expiring zombie owner (status=${live.status})`);
|
|
1299
1320
|
this.forceExpireQueueOwner(chatId, `gateway idle (steer=${steer.status}, session=${live.status})`);
|
|
1300
1321
|
// Fall through to the normal send path below; the durable trigger
|
|
@@ -1468,7 +1489,9 @@ export class FeishuBot {
|
|
|
1468
1489
|
console.log(`[${this.config.name}] processQueue already running for ${chatId.slice(-8)}, joining existing run`);
|
|
1469
1490
|
return existing;
|
|
1470
1491
|
}
|
|
1471
|
-
const
|
|
1492
|
+
const batchToken = {};
|
|
1493
|
+
this.queueRunTokens.set(chatId, batchToken);
|
|
1494
|
+
const run = this.processQueueInner(chatId, batchToken).finally(() => {
|
|
1472
1495
|
// Ownership token guard: only the CURRENT owner may release the slot. A
|
|
1473
1496
|
// zombie owner that settles after forceExpireQueueOwner must not delete
|
|
1474
1497
|
// the replacement owner's entry (that would open a concurrent-run window).
|
|
@@ -1476,6 +1499,7 @@ export class FeishuBot {
|
|
|
1476
1499
|
this.queueRuns.delete(chatId);
|
|
1477
1500
|
this.runFailureObservers.delete(chatId);
|
|
1478
1501
|
this.admittedSteerAcks.delete(chatId);
|
|
1502
|
+
this.queueRunTokens.delete(chatId);
|
|
1479
1503
|
}
|
|
1480
1504
|
});
|
|
1481
1505
|
this.queueRuns.set(chatId, run);
|
|
@@ -1493,13 +1517,21 @@ export class FeishuBot {
|
|
|
1493
1517
|
if (!owner)
|
|
1494
1518
|
return false;
|
|
1495
1519
|
console.warn(`[${this.config.name}] force-expiring zombie queue owner for ${chatId.slice(-8)}: ${reason}`);
|
|
1520
|
+
// Fence the stale batch's local observation BEFORE releasing custody: an
|
|
1521
|
+
// alive old poller would keep destructively consuming the replacement
|
|
1522
|
+
// batch's frames from the shared session bucket (2026-10-16 regression).
|
|
1523
|
+
try {
|
|
1524
|
+
this.openclawClient.revokeSessionCollectors(this.getSessionKey(chatId), `queue owner force-expired: ${reason}`);
|
|
1525
|
+
}
|
|
1526
|
+
catch { /* isolated */ }
|
|
1496
1527
|
this.queueRuns.delete(chatId);
|
|
1497
1528
|
this.fireRunFailureObservers(chatId);
|
|
1498
1529
|
this.runFailureObservers.delete(chatId);
|
|
1499
1530
|
this.admittedSteerAcks.delete(chatId);
|
|
1531
|
+
this.queueRunTokens.delete(chatId);
|
|
1500
1532
|
return true;
|
|
1501
1533
|
}
|
|
1502
|
-
async processQueueInner(chatId) {
|
|
1534
|
+
async processQueueInner(chatId, batchToken) {
|
|
1503
1535
|
while (true) {
|
|
1504
1536
|
// A fresh batch starts: steered inputs admitted into the PREVIOUS batch
|
|
1505
1537
|
// completed with it (consumed or not); their failure observers are stale.
|
|
@@ -1981,9 +2013,15 @@ export class FeishuBot {
|
|
|
1981
2013
|
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
1982
2014
|
// Steered messages admitted into THIS batch were consumed with it:
|
|
1983
2015
|
// flip their Get → DONE alongside the batch's own triggers.
|
|
1984
|
-
|
|
2016
|
+
// B9 (2026-10-16 audit): flip only THIS batch's admitted steer acks. A
|
|
2017
|
+
// superseded inner completing late must never DONE a newer batch's
|
|
2018
|
+
// steered inputs — foreign acks stay for their owning batch.
|
|
2019
|
+
const allSteers = this.admittedSteerAcks.get(chatId) || [];
|
|
2020
|
+
const admittedSteers = allSteers.filter((s) => s.batch === batchToken);
|
|
2021
|
+
if (allSteers.length > 0) {
|
|
2022
|
+
this.admittedSteerAcks.set(chatId, allSteers.filter((s) => s.batch !== batchToken));
|
|
2023
|
+
}
|
|
1985
2024
|
if (admittedSteers.length > 0) {
|
|
1986
|
-
this.admittedSteerAcks.delete(chatId);
|
|
1987
2025
|
for (const s of admittedSteers) {
|
|
1988
2026
|
await this.removeReaction(s.messageId, "Get").catch(() => { });
|
|
1989
2027
|
await this.addReaction(s.messageId, "DONE").catch(() => { });
|
|
@@ -1993,6 +2031,14 @@ export class FeishuBot {
|
|
|
1993
2031
|
}
|
|
1994
2032
|
}
|
|
1995
2033
|
catch (err) {
|
|
2034
|
+
if (err instanceof SupersededObservation) {
|
|
2035
|
+
// This batch's local observation was fenced by a newer batch (or a
|
|
2036
|
+
// custody revocation). The new owner handles delivery from here; end
|
|
2037
|
+
// quietly — no error notice, no replay, no Gateway abort. The old
|
|
2038
|
+
// run's eventual reply still flows through the proactive path.
|
|
2039
|
+
console.log(`[${this.config.name}] batch observation superseded for ${chatId.slice(-8)}; ending quietly`);
|
|
2040
|
+
break;
|
|
2041
|
+
}
|
|
1996
2042
|
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
1997
2043
|
if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
1998
2044
|
const state = await this.readSessionStatus(chatId);
|
|
@@ -21,6 +21,16 @@ export type ProgressEvent = {
|
|
|
21
21
|
export declare class InactiveRunObservation extends Error {
|
|
22
22
|
constructor();
|
|
23
23
|
}
|
|
24
|
+
/** The local collectReply observation was fenced because a newer batch took
|
|
25
|
+
* over this session's queue custody. Purely local: no Gateway run was stopped,
|
|
26
|
+
* and the superseded run's eventual reply still flows through the proactive
|
|
27
|
+
* delivery path. Introduced for the 2026-10-16 stale-collector regression:
|
|
28
|
+
* an alive old poller destructively consumed the replacement batch's frames
|
|
29
|
+
* from the shared session bucket, starving the new collector's idle clock and
|
|
30
|
+
* swallowing its final. */
|
|
31
|
+
export declare class SupersededObservation extends Error {
|
|
32
|
+
constructor(reason?: string);
|
|
33
|
+
}
|
|
24
34
|
export type ChatAttachment = {
|
|
25
35
|
type?: string;
|
|
26
36
|
mimeType?: string;
|
|
@@ -59,6 +69,11 @@ export declare class OpenClawClient {
|
|
|
59
69
|
private recentToolEventKeys;
|
|
60
70
|
private sessionMessageCallbacks;
|
|
61
71
|
private progressCallbacks;
|
|
72
|
+
/** Live collectReply fences per canonical session key. The most recent
|
|
73
|
+
* collector for a session owns the shared event bucket; a new batch (or a
|
|
74
|
+
* queue-custody revocation) fences stale ones synchronously so they cannot
|
|
75
|
+
* steal frames from the replacement (2026-10-16 regression). */
|
|
76
|
+
private sessionCollectorFences;
|
|
62
77
|
/** Per-session steered messages awaiting consumption confirmation. Each
|
|
63
78
|
* accepted message is retained independently so identical/overlapping text
|
|
64
79
|
* cannot overwrite another correlation. */
|
|
@@ -209,6 +224,11 @@ export declare class OpenClawClient {
|
|
|
209
224
|
*/
|
|
210
225
|
/** Stop claiming an exact run's final output after observation has paused. */
|
|
211
226
|
releasePausedWait(runId: string): void;
|
|
227
|
+
/** Synchronously fence every live collectReply observation for a session.
|
|
228
|
+
* Local only — never aborts a Gateway run. Returns the number superseded.
|
|
229
|
+
* Used when queue custody is revoked or a new chat.send takes over, so a
|
|
230
|
+
* stale poller cannot steal the replacement batch's frames (2026-10-16). */
|
|
231
|
+
revokeSessionCollectors(sessionKey: string, reason: string): number;
|
|
212
232
|
getSessionRuntimeStatus(sessionKey: string): Promise<SessionRuntimeStatus>;
|
|
213
233
|
private classifyWaitTimeout;
|
|
214
234
|
getSessionInfo(sessionKey: string): Promise<any>;
|
package/dist/openclaw-client.js
CHANGED
|
@@ -12,6 +12,19 @@ export class InactiveRunObservation extends Error {
|
|
|
12
12
|
this.name = "InactiveRunObservation";
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/** The local collectReply observation was fenced because a newer batch took
|
|
16
|
+
* over this session's queue custody. Purely local: no Gateway run was stopped,
|
|
17
|
+
* and the superseded run's eventual reply still flows through the proactive
|
|
18
|
+
* delivery path. Introduced for the 2026-10-16 stale-collector regression:
|
|
19
|
+
* an alive old poller destructively consumed the replacement batch's frames
|
|
20
|
+
* from the shared session bucket, starving the new collector's idle clock and
|
|
21
|
+
* swallowing its final. */
|
|
22
|
+
export class SupersededObservation extends Error {
|
|
23
|
+
constructor(reason = "本地观察已被更新的批次取代") {
|
|
24
|
+
super(`${reason}(未停止网关运行)`);
|
|
25
|
+
this.name = "SupersededObservation";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
15
28
|
export const GATEWAY_PROTOCOL_MIN = 4;
|
|
16
29
|
export const GATEWAY_PROTOCOL_MAX = 4;
|
|
17
30
|
const REQUIRED_GATEWAY_METHODS = [
|
|
@@ -63,6 +76,11 @@ export class OpenClawClient {
|
|
|
63
76
|
recentToolEventKeys = new Map();
|
|
64
77
|
sessionMessageCallbacks = new Map();
|
|
65
78
|
progressCallbacks = new Map();
|
|
79
|
+
/** Live collectReply fences per canonical session key. The most recent
|
|
80
|
+
* collector for a session owns the shared event bucket; a new batch (or a
|
|
81
|
+
* queue-custody revocation) fences stale ones synchronously so they cannot
|
|
82
|
+
* steal frames from the replacement (2026-10-16 regression). */
|
|
83
|
+
sessionCollectorFences = new Map();
|
|
66
84
|
/** Per-session steered messages awaiting consumption confirmation. Each
|
|
67
85
|
* accepted message is retained independently so identical/overlapping text
|
|
68
86
|
* cannot overwrite another correlation. */
|
|
@@ -852,9 +870,14 @@ export class OpenClawClient {
|
|
|
852
870
|
return;
|
|
853
871
|
if (snapshot.status === "ok" && snapshot.yielded !== true) {
|
|
854
872
|
const terminal = snapshot.terminalReply;
|
|
873
|
+
// B6 (2026-10-16 audit): disposition "empty" is an authoritative
|
|
874
|
+
// no-visible-text terminal — never recycle an interim draft as the
|
|
875
|
+
// final answer. Only genuinely unspecified metadata may fall back.
|
|
855
876
|
const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
|
|
856
|
-
? terminal.text
|
|
857
|
-
:
|
|
877
|
+
? terminal.text
|
|
878
|
+
: terminal?.disposition === "silent" || terminal?.disposition === "empty"
|
|
879
|
+
? "NO_REPLY"
|
|
880
|
+
: this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
858
881
|
resolve(visible || "NO_REPLY");
|
|
859
882
|
}
|
|
860
883
|
else if (snapshot.status === "error" || snapshot.status === "timeout") {
|
|
@@ -1356,7 +1379,9 @@ export class OpenClawClient {
|
|
|
1356
1379
|
return;
|
|
1357
1380
|
}
|
|
1358
1381
|
if (isWaitTimeout(errText)) {
|
|
1359
|
-
|
|
1382
|
+
// B5 (2026-10-16 audit): already dequeued by the unified splice
|
|
1383
|
+
// above; a second splice here deleted the NEXT buffered frame
|
|
1384
|
+
// (a real chatFinal following a timeout error was swallowed).
|
|
1360
1385
|
if (!decidingTimeout) {
|
|
1361
1386
|
decidingTimeout = true;
|
|
1362
1387
|
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
@@ -1410,11 +1435,13 @@ export class OpenClawClient {
|
|
|
1410
1435
|
if (this.isRecoverableAgentError(errText)) {
|
|
1411
1436
|
pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${safeErrorSummary(errText)}`;
|
|
1412
1437
|
console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${safeErrorSummary(errText)}`);
|
|
1413
|
-
|
|
1438
|
+
// B5: already dequeued above — no second splice.
|
|
1414
1439
|
continue;
|
|
1415
1440
|
}
|
|
1416
1441
|
if (isWaitTimeout(errText)) {
|
|
1417
|
-
|
|
1442
|
+
// B5 (2026-10-16 audit): already dequeued by the unified splice
|
|
1443
|
+
// above; a second splice here deleted the NEXT buffered frame
|
|
1444
|
+
// (a real chatFinal following a timeout error was swallowed).
|
|
1418
1445
|
if (!decidingTimeout) {
|
|
1419
1446
|
decidingTimeout = true;
|
|
1420
1447
|
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
@@ -1479,6 +1506,34 @@ export class OpenClawClient {
|
|
|
1479
1506
|
if (observationKey && this.runStateChecks.get(observationKey) === checkAfterNoActiveRun)
|
|
1480
1507
|
this.runStateChecks.delete(observationKey);
|
|
1481
1508
|
};
|
|
1509
|
+
// Fence registration: a newer batch on this session must be able to end
|
|
1510
|
+
// THIS observation synchronously (local cleanup + superseded settlement)
|
|
1511
|
+
// so the shared bucket is no longer destructively consumed by a stale
|
|
1512
|
+
// poller. The Gateway run is never aborted by fencing.
|
|
1513
|
+
const fenceSuperseded = () => {
|
|
1514
|
+
if (observationClosed)
|
|
1515
|
+
return;
|
|
1516
|
+
cleanupObservation();
|
|
1517
|
+
reject(new SupersededObservation());
|
|
1518
|
+
};
|
|
1519
|
+
if (observationKey) {
|
|
1520
|
+
let fences = this.sessionCollectorFences.get(observationKey);
|
|
1521
|
+
if (!fences) {
|
|
1522
|
+
fences = new Set();
|
|
1523
|
+
this.sessionCollectorFences.set(observationKey, fences);
|
|
1524
|
+
}
|
|
1525
|
+
fences.add(fenceSuperseded);
|
|
1526
|
+
const unregisterFence = () => {
|
|
1527
|
+
const current = this.sessionCollectorFences.get(observationKey);
|
|
1528
|
+
if (current) {
|
|
1529
|
+
current.delete(fenceSuperseded);
|
|
1530
|
+
if (current.size === 0)
|
|
1531
|
+
this.sessionCollectorFences.delete(observationKey);
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
const baseCleanup = cleanupObservation;
|
|
1535
|
+
cleanupObservation = () => { baseCleanup(); unregisterFence(); };
|
|
1536
|
+
}
|
|
1482
1537
|
}).finally(() => cleanupObservation());
|
|
1483
1538
|
}
|
|
1484
1539
|
/**
|
|
@@ -1787,6 +1842,25 @@ export class OpenClawClient {
|
|
|
1787
1842
|
this.ownedDeliveryRunTimers.delete(runId);
|
|
1788
1843
|
this.ownedDeliveryRuns.delete(runId);
|
|
1789
1844
|
}
|
|
1845
|
+
/** Synchronously fence every live collectReply observation for a session.
|
|
1846
|
+
* Local only — never aborts a Gateway run. Returns the number superseded.
|
|
1847
|
+
* Used when queue custody is revoked or a new chat.send takes over, so a
|
|
1848
|
+
* stale poller cannot steal the replacement batch's frames (2026-10-16). */
|
|
1849
|
+
revokeSessionCollectors(sessionKey, reason) {
|
|
1850
|
+
const key = this.canonicalSessionKey(sessionKey);
|
|
1851
|
+
const fences = this.sessionCollectorFences.get(key);
|
|
1852
|
+
if (!fences || fences.size === 0)
|
|
1853
|
+
return 0;
|
|
1854
|
+
const count = fences.size;
|
|
1855
|
+
for (const fence of [...fences]) {
|
|
1856
|
+
try {
|
|
1857
|
+
fence();
|
|
1858
|
+
}
|
|
1859
|
+
catch { /* each fence is isolated */ }
|
|
1860
|
+
}
|
|
1861
|
+
console.warn(`[OpenClaw] superseded ${count} stale collector(s) for ${key.slice(-24)}: ${reason}`);
|
|
1862
|
+
return count;
|
|
1863
|
+
}
|
|
1790
1864
|
async getSessionRuntimeStatus(sessionKey) {
|
|
1791
1865
|
let timer;
|
|
1792
1866
|
try {
|
|
@@ -2360,6 +2434,10 @@ export class OpenClawClient {
|
|
|
2360
2434
|
let result;
|
|
2361
2435
|
const sendStartedAt = Date.now();
|
|
2362
2436
|
try {
|
|
2437
|
+
// A stale collector from a previous batch must not steal this batch's
|
|
2438
|
+
// frames from the shared session bucket (2026-10-16 regression). Fence
|
|
2439
|
+
// any still-live local observation BEFORE issuing chat.send.
|
|
2440
|
+
this.revokeSessionCollectors(sk, "new chat.send supersedes stale observation");
|
|
2363
2441
|
await params.onSendAttempt?.();
|
|
2364
2442
|
const sendChat = () => this.rpc("chat.send", {
|
|
2365
2443
|
sessionKey: this.canonicalSessionKey(sk),
|
|
@@ -2407,8 +2485,15 @@ export class OpenClawClient {
|
|
|
2407
2485
|
// collectReply returns. Keep a short grace window so normal chat replies
|
|
2408
2486
|
// are not delivered twice via the proactive-message path. Cron/LMA runs
|
|
2409
2487
|
// are unaffected because they do not go through chatSend.
|
|
2410
|
-
|
|
2411
|
-
|
|
2488
|
+
// CAS: a superseded send's finally must not delete a newer send's
|
|
2489
|
+
// progress callback (the fence settles the old promise after the new
|
|
2490
|
+
// batch may already have registered its own callback).
|
|
2491
|
+
if (params.onProgress) {
|
|
2492
|
+
if (this.progressCallbacks.get(sk) === params.onProgress)
|
|
2493
|
+
this.progressCallbacks.delete(sk);
|
|
2494
|
+
if (this.progressCallbacks.get(fullSessionKey) === params.onProgress)
|
|
2495
|
+
this.progressCallbacks.delete(fullSessionKey);
|
|
2496
|
+
}
|
|
2412
2497
|
if (waitPaused)
|
|
2413
2498
|
this.releasePausedWait(ownedRunId);
|
|
2414
2499
|
else
|