openclaw-lark-multi-agent 1.8.0 → 1.8.1

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;
@@ -1286,7 +1286,39 @@ export class FeishuBot {
1286
1286
  stopWatchingConsumption();
1287
1287
  if ("cancelPending" in steer)
1288
1288
  steer.cancelPending?.();
1289
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
1289
+ // Zombie-owner detection (2026-10-15 guangzi incident): steer found no
1290
+ // active run while our in-memory owner still holds the chat. Ask the
1291
+ // gateway for the authoritative session status: if it is idle, the owner
1292
+ // is a zombie (its completion event was routed through the proactive
1293
+ // path and it will never settle). Force-expire it and process this input
1294
+ // NOW instead of queueing behind a dead promise forever.
1295
+ if (steer.status === "no_active_run" && this.queueRuns.has(chatId)) {
1296
+ const live = await this.openclawClient.getSessionRuntimeStatus(sessionKeyForSteer).catch(() => null);
1297
+ if (live && !live.running) {
1298
+ console.warn(`[${this.config.name}] gateway reports session idle while queue owner exists for ${chatId.slice(-8)} — expiring zombie owner (status=${live.status})`);
1299
+ this.forceExpireQueueOwner(chatId, `gateway idle (steer=${steer.status}, session=${live.status})`);
1300
+ // Fall through to the normal send path below; the durable trigger
1301
+ // for this input is still set, so the fresh processQueue drains it.
1302
+ }
1303
+ else {
1304
+ console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}, session running=${live?.running}), queuing: "${cleanText.substring(0, 50)}..."`);
1305
+ }
1306
+ }
1307
+ else {
1308
+ console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
1309
+ }
1310
+ if (!this.queueRuns.has(chatId)) {
1311
+ // Owner gone (force-expired or settled during the steer RPC): send now.
1312
+ await this.removeReaction(messageId, "Typing").catch(() => { });
1313
+ await this.addReaction(messageId, "Get").catch(() => { });
1314
+ const acksNow = this.pendingAckMessages.get(chatId) || [];
1315
+ for (const a of acksNow)
1316
+ if (a.messageId === messageId)
1317
+ a.emoji = "Get";
1318
+ this.pendingAckMessages.set(chatId, acksNow);
1319
+ await this.processQueue(chatId);
1320
+ return;
1321
+ }
1290
1322
  // Reconciliation may have finished the old owner during the steer RPC.
1291
1323
  // Ensure this new, still-pending input is drained even when that owner
1292
1324
  // exited through an error path. Already-consumed input is never replayed.
@@ -1437,13 +1469,36 @@ export class FeishuBot {
1437
1469
  return existing;
1438
1470
  }
1439
1471
  const run = this.processQueueInner(chatId).finally(() => {
1440
- this.queueRuns.delete(chatId);
1441
- this.runFailureObservers.delete(chatId);
1442
- this.admittedSteerAcks.delete(chatId);
1472
+ // Ownership token guard: only the CURRENT owner may release the slot. A
1473
+ // zombie owner that settles after forceExpireQueueOwner must not delete
1474
+ // the replacement owner's entry (that would open a concurrent-run window).
1475
+ if (this.queueRuns.get(chatId) === run) {
1476
+ this.queueRuns.delete(chatId);
1477
+ this.runFailureObservers.delete(chatId);
1478
+ this.admittedSteerAcks.delete(chatId);
1479
+ }
1443
1480
  });
1444
1481
  this.queueRuns.set(chatId, run);
1445
1482
  return run;
1446
1483
  }
1484
+ /**
1485
+ * Force-release a stuck queue owner after the gateway authoritatively
1486
+ * reported the session idle (no active run). The zombie owner's deep waits
1487
+ * can otherwise hold chat custody forever: completion events routed through
1488
+ * the proactive path are invisible to them. Steer-fallback triggers are
1489
+ * restored via the failure observers so their input drains normally.
1490
+ */
1491
+ forceExpireQueueOwner(chatId, reason) {
1492
+ const owner = this.queueRuns.get(chatId);
1493
+ if (!owner)
1494
+ return false;
1495
+ console.warn(`[${this.config.name}] force-expiring zombie queue owner for ${chatId.slice(-8)}: ${reason}`);
1496
+ this.queueRuns.delete(chatId);
1497
+ this.fireRunFailureObservers(chatId);
1498
+ this.runFailureObservers.delete(chatId);
1499
+ this.admittedSteerAcks.delete(chatId);
1500
+ return true;
1501
+ }
1447
1502
  async processQueueInner(chatId) {
1448
1503
  while (true) {
1449
1504
  // A fresh batch starts: steered inputs admitted into the PREVIOUS batch
@@ -178,7 +178,7 @@ export declare class OpenClawClient {
178
178
  * the ambiguous public admission as a realtime-insertion success signal.
179
179
  */
180
180
  steer(sessionKey: string, text: string, displayText?: string): Promise<{
181
- status: "steered" | "unavailable";
181
+ status: "steered" | "unavailable" | "no_active_run";
182
182
  runId?: string;
183
183
  /** Cancel provisional transcript correlation when LMA falls back to a normal run. */
184
184
  cancelPending?: () => void;
@@ -1628,6 +1628,10 @@ export class OpenClawClient {
1628
1628
  console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
1629
1629
  }
1630
1630
  cancelPending();
1631
+ // Pass the gateway's authoritative "no active run" verdict through: the
1632
+ // caller uses it to detect a zombie queue owner (2026-10-15 incident).
1633
+ if (res?.status === "no_active_run")
1634
+ return { status: "no_active_run" };
1631
1635
  return { status: "unavailable" };
1632
1636
  }
1633
1637
  return { status: "steered", cancelPending };
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.1",
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": {