openclaw-lark-multi-agent 1.8.0 → 1.8.2
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 +8 -0
- package/dist/feishu-bot.js +86 -6
- package/dist/openclaw-client.d.ts +21 -1
- package/dist/openclaw-client.js +82 -2
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -137,6 +137,14 @@ export declare class FeishuBot {
|
|
|
137
137
|
/** Fire and clear run-failure observers for a chat (isolated callbacks). */
|
|
138
138
|
private fireRunFailureObservers;
|
|
139
139
|
private processQueue;
|
|
140
|
+
/**
|
|
141
|
+
* Force-release a stuck queue owner after the gateway authoritatively
|
|
142
|
+
* reported the session idle (no active run). The zombie owner's deep waits
|
|
143
|
+
* can otherwise hold chat custody forever: completion events routed through
|
|
144
|
+
* the proactive path are invisible to them. Steer-fallback triggers are
|
|
145
|
+
* restored via the failure observers so their input drains normally.
|
|
146
|
+
*/
|
|
147
|
+
private forceExpireQueueOwner;
|
|
140
148
|
private processQueueInner;
|
|
141
149
|
private shouldHandleBridgeCommand;
|
|
142
150
|
private shouldRespond;
|
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";
|
|
@@ -1253,8 +1254,17 @@ export class FeishuBot {
|
|
|
1253
1254
|
this.admittedSteerAcks.set(chatId, (this.admittedSteerAcks.get(chatId) || []).filter((a) => a.insertedId !== insertedId));
|
|
1254
1255
|
console.log(`[${this.config.name}] steer fallback restored after failed run for ${chatId.slice(-8)}: "${cleanText.substring(0, 40)}..."`);
|
|
1255
1256
|
// Deferred: the current queue run is still active here; a fresh
|
|
1256
|
-
// drain must start after its finally releases the chat.
|
|
1257
|
-
|
|
1257
|
+
// drain must start after its finally releases the chat. A 0ms
|
|
1258
|
+
// timer is NOT enough — the owner's catch block still has
|
|
1259
|
+
// in-flight awaits (error delivery), so processQueue would join
|
|
1260
|
+
// the dying owner and return, stranding the restored trigger
|
|
1261
|
+
// until the next user message (2026-10-16 audit finding).
|
|
1262
|
+
const ownerNow = this.queueRuns.get(chatId);
|
|
1263
|
+
const drainRestored = () => { void this.processQueue(chatId).catch(() => { }); };
|
|
1264
|
+
if (ownerNow)
|
|
1265
|
+
void ownerNow.then(drainRestored, drainRestored).catch(() => { });
|
|
1266
|
+
else
|
|
1267
|
+
setTimeout(drainRestored, 0).unref?.();
|
|
1258
1268
|
});
|
|
1259
1269
|
this.runFailureObservers.set(chatId, observers);
|
|
1260
1270
|
const owner = this.queueRuns.get(chatId);
|
|
@@ -1286,7 +1296,39 @@ export class FeishuBot {
|
|
|
1286
1296
|
stopWatchingConsumption();
|
|
1287
1297
|
if ("cancelPending" in steer)
|
|
1288
1298
|
steer.cancelPending?.();
|
|
1289
|
-
|
|
1299
|
+
// Zombie-owner detection (2026-10-15 guangzi incident): steer found no
|
|
1300
|
+
// active run while our in-memory owner still holds the chat. Ask the
|
|
1301
|
+
// gateway for the authoritative session status: if it is idle, the owner
|
|
1302
|
+
// is a zombie (its completion event was routed through the proactive
|
|
1303
|
+
// path and it will never settle). Force-expire it and process this input
|
|
1304
|
+
// NOW instead of queueing behind a dead promise forever.
|
|
1305
|
+
if (steer.status === "no_active_run" && this.queueRuns.has(chatId)) {
|
|
1306
|
+
const live = await this.openclawClient.getSessionRuntimeStatus(sessionKeyForSteer).catch(() => null);
|
|
1307
|
+
if (live && !live.running) {
|
|
1308
|
+
console.warn(`[${this.config.name}] gateway reports session idle while queue owner exists for ${chatId.slice(-8)} — expiring zombie owner (status=${live.status})`);
|
|
1309
|
+
this.forceExpireQueueOwner(chatId, `gateway idle (steer=${steer.status}, session=${live.status})`);
|
|
1310
|
+
// Fall through to the normal send path below; the durable trigger
|
|
1311
|
+
// for this input is still set, so the fresh processQueue drains it.
|
|
1312
|
+
}
|
|
1313
|
+
else {
|
|
1314
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}, session running=${live?.running}), queuing: "${cleanText.substring(0, 50)}..."`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
|
|
1319
|
+
}
|
|
1320
|
+
if (!this.queueRuns.has(chatId)) {
|
|
1321
|
+
// Owner gone (force-expired or settled during the steer RPC): send now.
|
|
1322
|
+
await this.removeReaction(messageId, "Typing").catch(() => { });
|
|
1323
|
+
await this.addReaction(messageId, "Get").catch(() => { });
|
|
1324
|
+
const acksNow = this.pendingAckMessages.get(chatId) || [];
|
|
1325
|
+
for (const a of acksNow)
|
|
1326
|
+
if (a.messageId === messageId)
|
|
1327
|
+
a.emoji = "Get";
|
|
1328
|
+
this.pendingAckMessages.set(chatId, acksNow);
|
|
1329
|
+
await this.processQueue(chatId);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1290
1332
|
// Reconciliation may have finished the old owner during the steer RPC.
|
|
1291
1333
|
// Ensure this new, still-pending input is drained even when that owner
|
|
1292
1334
|
// exited through an error path. Already-consumed input is never replayed.
|
|
@@ -1437,13 +1479,43 @@ export class FeishuBot {
|
|
|
1437
1479
|
return existing;
|
|
1438
1480
|
}
|
|
1439
1481
|
const run = this.processQueueInner(chatId).finally(() => {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1482
|
+
// Ownership token guard: only the CURRENT owner may release the slot. A
|
|
1483
|
+
// zombie owner that settles after forceExpireQueueOwner must not delete
|
|
1484
|
+
// the replacement owner's entry (that would open a concurrent-run window).
|
|
1485
|
+
if (this.queueRuns.get(chatId) === run) {
|
|
1486
|
+
this.queueRuns.delete(chatId);
|
|
1487
|
+
this.runFailureObservers.delete(chatId);
|
|
1488
|
+
this.admittedSteerAcks.delete(chatId);
|
|
1489
|
+
}
|
|
1443
1490
|
});
|
|
1444
1491
|
this.queueRuns.set(chatId, run);
|
|
1445
1492
|
return run;
|
|
1446
1493
|
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Force-release a stuck queue owner after the gateway authoritatively
|
|
1496
|
+
* reported the session idle (no active run). The zombie owner's deep waits
|
|
1497
|
+
* can otherwise hold chat custody forever: completion events routed through
|
|
1498
|
+
* the proactive path are invisible to them. Steer-fallback triggers are
|
|
1499
|
+
* restored via the failure observers so their input drains normally.
|
|
1500
|
+
*/
|
|
1501
|
+
forceExpireQueueOwner(chatId, reason) {
|
|
1502
|
+
const owner = this.queueRuns.get(chatId);
|
|
1503
|
+
if (!owner)
|
|
1504
|
+
return false;
|
|
1505
|
+
console.warn(`[${this.config.name}] force-expiring zombie queue owner for ${chatId.slice(-8)}: ${reason}`);
|
|
1506
|
+
// Fence the stale batch's local observation BEFORE releasing custody: an
|
|
1507
|
+
// alive old poller would keep destructively consuming the replacement
|
|
1508
|
+
// batch's frames from the shared session bucket (2026-10-16 regression).
|
|
1509
|
+
try {
|
|
1510
|
+
this.openclawClient.revokeSessionCollectors(this.getSessionKey(chatId), `queue owner force-expired: ${reason}`);
|
|
1511
|
+
}
|
|
1512
|
+
catch { /* isolated */ }
|
|
1513
|
+
this.queueRuns.delete(chatId);
|
|
1514
|
+
this.fireRunFailureObservers(chatId);
|
|
1515
|
+
this.runFailureObservers.delete(chatId);
|
|
1516
|
+
this.admittedSteerAcks.delete(chatId);
|
|
1517
|
+
return true;
|
|
1518
|
+
}
|
|
1447
1519
|
async processQueueInner(chatId) {
|
|
1448
1520
|
while (true) {
|
|
1449
1521
|
// A fresh batch starts: steered inputs admitted into the PREVIOUS batch
|
|
@@ -1938,6 +2010,14 @@ export class FeishuBot {
|
|
|
1938
2010
|
}
|
|
1939
2011
|
}
|
|
1940
2012
|
catch (err) {
|
|
2013
|
+
if (err instanceof SupersededObservation) {
|
|
2014
|
+
// This batch's local observation was fenced by a newer batch (or a
|
|
2015
|
+
// custody revocation). The new owner handles delivery from here; end
|
|
2016
|
+
// quietly — no error notice, no replay, no Gateway abort. The old
|
|
2017
|
+
// run's eventual reply still flows through the proactive path.
|
|
2018
|
+
console.log(`[${this.config.name}] batch observation superseded for ${chatId.slice(-8)}; ending quietly`);
|
|
2019
|
+
break;
|
|
2020
|
+
}
|
|
1941
2021
|
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
1942
2022
|
if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
1943
2023
|
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. */
|
|
@@ -178,7 +193,7 @@ export declare class OpenClawClient {
|
|
|
178
193
|
* the ambiguous public admission as a realtime-insertion success signal.
|
|
179
194
|
*/
|
|
180
195
|
steer(sessionKey: string, text: string, displayText?: string): Promise<{
|
|
181
|
-
status: "steered" | "unavailable";
|
|
196
|
+
status: "steered" | "unavailable" | "no_active_run";
|
|
182
197
|
runId?: string;
|
|
183
198
|
/** Cancel provisional transcript correlation when LMA falls back to a normal run. */
|
|
184
199
|
cancelPending?: () => void;
|
|
@@ -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. */
|
|
@@ -1479,6 +1497,34 @@ export class OpenClawClient {
|
|
|
1479
1497
|
if (observationKey && this.runStateChecks.get(observationKey) === checkAfterNoActiveRun)
|
|
1480
1498
|
this.runStateChecks.delete(observationKey);
|
|
1481
1499
|
};
|
|
1500
|
+
// Fence registration: a newer batch on this session must be able to end
|
|
1501
|
+
// THIS observation synchronously (local cleanup + superseded settlement)
|
|
1502
|
+
// so the shared bucket is no longer destructively consumed by a stale
|
|
1503
|
+
// poller. The Gateway run is never aborted by fencing.
|
|
1504
|
+
const fenceSuperseded = () => {
|
|
1505
|
+
if (observationClosed)
|
|
1506
|
+
return;
|
|
1507
|
+
cleanupObservation();
|
|
1508
|
+
reject(new SupersededObservation());
|
|
1509
|
+
};
|
|
1510
|
+
if (observationKey) {
|
|
1511
|
+
let fences = this.sessionCollectorFences.get(observationKey);
|
|
1512
|
+
if (!fences) {
|
|
1513
|
+
fences = new Set();
|
|
1514
|
+
this.sessionCollectorFences.set(observationKey, fences);
|
|
1515
|
+
}
|
|
1516
|
+
fences.add(fenceSuperseded);
|
|
1517
|
+
const unregisterFence = () => {
|
|
1518
|
+
const current = this.sessionCollectorFences.get(observationKey);
|
|
1519
|
+
if (current) {
|
|
1520
|
+
current.delete(fenceSuperseded);
|
|
1521
|
+
if (current.size === 0)
|
|
1522
|
+
this.sessionCollectorFences.delete(observationKey);
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
const baseCleanup = cleanupObservation;
|
|
1526
|
+
cleanupObservation = () => { baseCleanup(); unregisterFence(); };
|
|
1527
|
+
}
|
|
1482
1528
|
}).finally(() => cleanupObservation());
|
|
1483
1529
|
}
|
|
1484
1530
|
/**
|
|
@@ -1628,6 +1674,10 @@ export class OpenClawClient {
|
|
|
1628
1674
|
console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
|
|
1629
1675
|
}
|
|
1630
1676
|
cancelPending();
|
|
1677
|
+
// Pass the gateway's authoritative "no active run" verdict through: the
|
|
1678
|
+
// caller uses it to detect a zombie queue owner (2026-10-15 incident).
|
|
1679
|
+
if (res?.status === "no_active_run")
|
|
1680
|
+
return { status: "no_active_run" };
|
|
1631
1681
|
return { status: "unavailable" };
|
|
1632
1682
|
}
|
|
1633
1683
|
return { status: "steered", cancelPending };
|
|
@@ -1783,6 +1833,25 @@ export class OpenClawClient {
|
|
|
1783
1833
|
this.ownedDeliveryRunTimers.delete(runId);
|
|
1784
1834
|
this.ownedDeliveryRuns.delete(runId);
|
|
1785
1835
|
}
|
|
1836
|
+
/** Synchronously fence every live collectReply observation for a session.
|
|
1837
|
+
* Local only — never aborts a Gateway run. Returns the number superseded.
|
|
1838
|
+
* Used when queue custody is revoked or a new chat.send takes over, so a
|
|
1839
|
+
* stale poller cannot steal the replacement batch's frames (2026-10-16). */
|
|
1840
|
+
revokeSessionCollectors(sessionKey, reason) {
|
|
1841
|
+
const key = this.canonicalSessionKey(sessionKey);
|
|
1842
|
+
const fences = this.sessionCollectorFences.get(key);
|
|
1843
|
+
if (!fences || fences.size === 0)
|
|
1844
|
+
return 0;
|
|
1845
|
+
const count = fences.size;
|
|
1846
|
+
for (const fence of [...fences]) {
|
|
1847
|
+
try {
|
|
1848
|
+
fence();
|
|
1849
|
+
}
|
|
1850
|
+
catch { /* each fence is isolated */ }
|
|
1851
|
+
}
|
|
1852
|
+
console.warn(`[OpenClaw] superseded ${count} stale collector(s) for ${key.slice(-24)}: ${reason}`);
|
|
1853
|
+
return count;
|
|
1854
|
+
}
|
|
1786
1855
|
async getSessionRuntimeStatus(sessionKey) {
|
|
1787
1856
|
let timer;
|
|
1788
1857
|
try {
|
|
@@ -2356,6 +2425,10 @@ export class OpenClawClient {
|
|
|
2356
2425
|
let result;
|
|
2357
2426
|
const sendStartedAt = Date.now();
|
|
2358
2427
|
try {
|
|
2428
|
+
// A stale collector from a previous batch must not steal this batch's
|
|
2429
|
+
// frames from the shared session bucket (2026-10-16 regression). Fence
|
|
2430
|
+
// any still-live local observation BEFORE issuing chat.send.
|
|
2431
|
+
this.revokeSessionCollectors(sk, "new chat.send supersedes stale observation");
|
|
2359
2432
|
await params.onSendAttempt?.();
|
|
2360
2433
|
const sendChat = () => this.rpc("chat.send", {
|
|
2361
2434
|
sessionKey: this.canonicalSessionKey(sk),
|
|
@@ -2403,8 +2476,15 @@ export class OpenClawClient {
|
|
|
2403
2476
|
// collectReply returns. Keep a short grace window so normal chat replies
|
|
2404
2477
|
// are not delivered twice via the proactive-message path. Cron/LMA runs
|
|
2405
2478
|
// are unaffected because they do not go through chatSend.
|
|
2406
|
-
|
|
2407
|
-
|
|
2479
|
+
// CAS: a superseded send's finally must not delete a newer send's
|
|
2480
|
+
// progress callback (the fence settles the old promise after the new
|
|
2481
|
+
// batch may already have registered its own callback).
|
|
2482
|
+
if (params.onProgress) {
|
|
2483
|
+
if (this.progressCallbacks.get(sk) === params.onProgress)
|
|
2484
|
+
this.progressCallbacks.delete(sk);
|
|
2485
|
+
if (this.progressCallbacks.get(fullSessionKey) === params.onProgress)
|
|
2486
|
+
this.progressCallbacks.delete(fullSessionKey);
|
|
2487
|
+
}
|
|
2408
2488
|
if (waitPaused)
|
|
2409
2489
|
this.releasePausedWait(ownedRunId);
|
|
2410
2490
|
else
|