openclaw-lark-multi-agent 1.8.1 → 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.
@@ -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);
@@ -1493,6 +1503,13 @@ export class FeishuBot {
1493
1503
  if (!owner)
1494
1504
  return false;
1495
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 */ }
1496
1513
  this.queueRuns.delete(chatId);
1497
1514
  this.fireRunFailureObservers(chatId);
1498
1515
  this.runFailureObservers.delete(chatId);
@@ -1993,6 +2010,14 @@ export class FeishuBot {
1993
2010
  }
1994
2011
  }
1995
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
+ }
1996
2021
  let pause = err instanceof SessionWaitPaused ? err : undefined;
1997
2022
  if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
1998
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. */
@@ -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
  /**
@@ -1787,6 +1833,25 @@ export class OpenClawClient {
1787
1833
  this.ownedDeliveryRunTimers.delete(runId);
1788
1834
  this.ownedDeliveryRuns.delete(runId);
1789
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
+ }
1790
1855
  async getSessionRuntimeStatus(sessionKey) {
1791
1856
  let timer;
1792
1857
  try {
@@ -2360,6 +2425,10 @@ export class OpenClawClient {
2360
2425
  let result;
2361
2426
  const sendStartedAt = Date.now();
2362
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");
2363
2432
  await params.onSendAttempt?.();
2364
2433
  const sendChat = () => this.rpc("chat.send", {
2365
2434
  sessionKey: this.canonicalSessionKey(sk),
@@ -2407,8 +2476,15 @@ export class OpenClawClient {
2407
2476
  // collectReply returns. Keep a short grace window so normal chat replies
2408
2477
  // are not delivered twice via the proactive-message path. Cron/LMA runs
2409
2478
  // are unaffected because they do not go through chatSend.
2410
- this.progressCallbacks.delete(sk);
2411
- 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
+ }
2412
2488
  if (waitPaused)
2413
2489
  this.releasePausedWait(ownedRunId);
2414
2490
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.1",
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": {