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.
@@ -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;
@@ -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
- setTimeout(() => { void this.processQueue(chatId).catch(() => { }); }, 0).unref?.();
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
- if (live && !live.running) {
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 run = this.processQueueInner(chatId).finally(() => {
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
- const admittedSteers = this.admittedSteerAcks.get(chatId) || [];
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>;
@@ -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 : terminal?.disposition === "silent" ? "NO_REPLY"
857
- : this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
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
- bucket.splice(i, 1);
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
- bucket.splice(i, 1);
1438
+ // B5: already dequeued above — no second splice.
1414
1439
  continue;
1415
1440
  }
1416
1441
  if (isWaitTimeout(errText)) {
1417
- bucket.splice(i, 1);
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
- this.progressCallbacks.delete(sk);
2411
- this.progressCallbacks.delete(fullSessionKey);
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
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.3",
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": {