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.
@@ -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;
@@ -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
- setTimeout(() => { void this.processQueue(chatId).catch(() => { }); }, 0).unref?.();
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
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
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
- this.queueRuns.delete(chatId);
1441
- this.runFailureObservers.delete(chatId);
1442
- this.admittedSteerAcks.delete(chatId);
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>;
@@ -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
- this.progressCallbacks.delete(sk);
2407
- this.progressCallbacks.delete(fullSessionKey);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {