openclaw-lark-multi-agent 1.8.2 → 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;
@@ -149,6 +149,11 @@ export class FeishuBot {
149
149
  * DONE when that batch completes successfully; cleared (unflipped) when the
150
150
  * batch scope ends any other way. */
151
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();
152
157
  /** Go / Go-Checker supervision controller (per-bot instance). */
153
158
  goDeliveryReceipts = new AsyncLocalStorage();
154
159
  goReceiptApis = new WeakSet();
@@ -1227,7 +1232,7 @@ export class FeishuBot {
1227
1232
  // never (2026.9.9 removed mirrors); either way the message belongs
1228
1233
  // to this batch for the Get -> DONE flip.
1229
1234
  const steerAckList = this.admittedSteerAcks.get(chatId) || [];
1230
- steerAckList.push({ messageId, insertedId });
1235
+ steerAckList.push({ messageId, insertedId, batch: this.queueRunTokens.get(chatId) });
1231
1236
  this.admittedSteerAcks.set(chatId, steerAckList);
1232
1237
  }
1233
1238
  if (insertedId > 0 && !consumedBeforeAck) {
@@ -1303,8 +1308,14 @@ export class FeishuBot {
1303
1308
  // path and it will never settle). Force-expire it and process this input
1304
1309
  // NOW instead of queueing behind a dead promise forever.
1305
1310
  if (steer.status === "no_active_run" && this.queueRuns.has(chatId)) {
1311
+ const ownerAtCheck = this.queueRuns.get(chatId);
1306
1312
  const live = await this.openclawClient.getSessionRuntimeStatus(sessionKeyForSteer).catch(() => null);
1307
- 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) {
1308
1319
  console.warn(`[${this.config.name}] gateway reports session idle while queue owner exists for ${chatId.slice(-8)} — expiring zombie owner (status=${live.status})`);
1309
1320
  this.forceExpireQueueOwner(chatId, `gateway idle (steer=${steer.status}, session=${live.status})`);
1310
1321
  // Fall through to the normal send path below; the durable trigger
@@ -1478,7 +1489,9 @@ export class FeishuBot {
1478
1489
  console.log(`[${this.config.name}] processQueue already running for ${chatId.slice(-8)}, joining existing run`);
1479
1490
  return existing;
1480
1491
  }
1481
- const run = this.processQueueInner(chatId).finally(() => {
1492
+ const batchToken = {};
1493
+ this.queueRunTokens.set(chatId, batchToken);
1494
+ const run = this.processQueueInner(chatId, batchToken).finally(() => {
1482
1495
  // Ownership token guard: only the CURRENT owner may release the slot. A
1483
1496
  // zombie owner that settles after forceExpireQueueOwner must not delete
1484
1497
  // the replacement owner's entry (that would open a concurrent-run window).
@@ -1486,6 +1499,7 @@ export class FeishuBot {
1486
1499
  this.queueRuns.delete(chatId);
1487
1500
  this.runFailureObservers.delete(chatId);
1488
1501
  this.admittedSteerAcks.delete(chatId);
1502
+ this.queueRunTokens.delete(chatId);
1489
1503
  }
1490
1504
  });
1491
1505
  this.queueRuns.set(chatId, run);
@@ -1514,9 +1528,10 @@ export class FeishuBot {
1514
1528
  this.fireRunFailureObservers(chatId);
1515
1529
  this.runFailureObservers.delete(chatId);
1516
1530
  this.admittedSteerAcks.delete(chatId);
1531
+ this.queueRunTokens.delete(chatId);
1517
1532
  return true;
1518
1533
  }
1519
- async processQueueInner(chatId) {
1534
+ async processQueueInner(chatId, batchToken) {
1520
1535
  while (true) {
1521
1536
  // A fresh batch starts: steered inputs admitted into the PREVIOUS batch
1522
1537
  // completed with it (consumed or not); their failure observers are stale.
@@ -1998,9 +2013,15 @@ export class FeishuBot {
1998
2013
  this.pendingAckMessages.set(chatId, remainingAcks);
1999
2014
  // Steered messages admitted into THIS batch were consumed with it:
2000
2015
  // flip their Get → DONE alongside the batch's own triggers.
2001
- 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
+ }
2002
2024
  if (admittedSteers.length > 0) {
2003
- this.admittedSteerAcks.delete(chatId);
2004
2025
  for (const s of admittedSteers) {
2005
2026
  await this.removeReaction(s.messageId, "Get").catch(() => { });
2006
2027
  await this.addReaction(s.messageId, "DONE").catch(() => { });
@@ -870,9 +870,14 @@ export class OpenClawClient {
870
870
  return;
871
871
  if (snapshot.status === "ok" && snapshot.yielded !== true) {
872
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.
873
876
  const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
874
- ? terminal.text : terminal?.disposition === "silent" ? "NO_REPLY"
875
- : 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);
876
881
  resolve(visible || "NO_REPLY");
877
882
  }
878
883
  else if (snapshot.status === "error" || snapshot.status === "timeout") {
@@ -1374,7 +1379,9 @@ export class OpenClawClient {
1374
1379
  return;
1375
1380
  }
1376
1381
  if (isWaitTimeout(errText)) {
1377
- 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).
1378
1385
  if (!decidingTimeout) {
1379
1386
  decidingTimeout = true;
1380
1387
  void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
@@ -1428,11 +1435,13 @@ export class OpenClawClient {
1428
1435
  if (this.isRecoverableAgentError(errText)) {
1429
1436
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${safeErrorSummary(errText)}`;
1430
1437
  console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${safeErrorSummary(errText)}`);
1431
- bucket.splice(i, 1);
1438
+ // B5: already dequeued above — no second splice.
1432
1439
  continue;
1433
1440
  }
1434
1441
  if (isWaitTimeout(errText)) {
1435
- 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).
1436
1445
  if (!decidingTimeout) {
1437
1446
  decidingTimeout = true;
1438
1447
  void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.2",
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": {