openclaw-lark-multi-agent 1.8.12 → 1.8.14

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.
@@ -354,8 +354,16 @@ export declare class FeishuBot {
354
354
  /**
355
355
  * Enqueue a message send to guarantee ordering per chat.
356
356
  * All sends for a chat are serialized through this.
357
+ *
358
+ * The chain must never wedge: a single hung Feishu call used to block every
359
+ * later send in that chat forever (live-status patches fire per tool event),
360
+ * which froze cards and stranded /stop confirmations. Each link is therefore
361
+ * raced against a deadline, so a stuck send is abandoned and ordering resumes.
362
+ * Prefer a larger OPENCLAW_LARK_MULTI_AGENT_SEND_TIMEOUT_MS on slow links.
357
363
  */
358
364
  private sendOrdered;
365
+ /** One send link: resolve with fn(), or reject at the deadline so the chain advances. */
366
+ private withSendDeadline;
359
367
  /**
360
368
  * Add a reaction (emoji) to a message.
361
369
  */
@@ -29,6 +29,9 @@ const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESS
29
29
  const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
30
30
  const DELIVERY_MAX_ATTEMPTS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_DELIVERY_MAX_ATTEMPTS || 5);
31
31
  const DELIVERY_RETRY_BASE_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_DELIVERY_RETRY_BASE_MS || 1_000);
32
+ // Per-chat send serialization deadline. A hung Feishu call must not block the
33
+ // chain forever, otherwise live-status patches and /stop confirmations strand.
34
+ const SEND_DEADLINE_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SEND_TIMEOUT_MS || 30_000);
32
35
  // Auto-retry: when a reply looks truncated/incomplete, ask the same session
33
36
  // whether it finished. If it did not, let it continue; loop until it confirms
34
37
  // completion or the retry budget is spent. Off by setting AUTO_RETRY=0.
@@ -1361,10 +1364,20 @@ export class FeishuBot {
1361
1364
  return;
1362
1365
  }
1363
1366
  const identified = compatible && !!activity.activeRunId && !!activity.sessionId && !activity.archived && !status.archived;
1367
+ // Bounded retention (2026-10-17 deadlock): an input that has stayed
1368
+ // activity-unbound for over 60s despite re-checks must not be held forever.
1369
+ // Hand it to the ordinary queue — the gateway's own chat.send admission is
1370
+ // the authoritative answer and admission custody already dedups.
1371
+ const unboundSince = current?.activityUnbound ? (current.unboundSince || 0) : 0;
1372
+ if (!identified && unboundSince && Date.now() - unboundSince > 60_000) {
1373
+ this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId, 'ordinary', { ...current, activityUnbound: false, reason: 'gateway state unconfirmable over 60s; ordinary admission is authoritative' });
1374
+ return;
1375
+ }
1364
1376
  this.store.saveInsertion(this.config.name, chatId, rowId, current?.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1365
1377
  messageId, text, stopEpoch: epoch, sessionKey: this.getSessionKey(chatId), sessionId: identified ? activity.sessionId : status.sessionId,
1366
1378
  expectedRunId: identified ? activity.activeRunId : '', ownerId: identified ? `external:${activity.activeRunId}` : undefined,
1367
1379
  batchId: identified ? activity.activeRunId : undefined, activityUnbound: !identified, attempted: false,
1380
+ ...(identified ? {} : { unboundSince: unboundSince || Date.now() }),
1368
1381
  reason: identified ? 'current Gateway run observed; insertion custody only' : 'current Gateway activity/run identity unavailable; retained without admission',
1369
1382
  }, Date.now() + (identified ? 0 : 5000));
1370
1383
  }
@@ -1576,6 +1589,21 @@ export class FeishuBot {
1576
1589
  }
1577
1590
  else if (state === 'target_ended')
1578
1591
  state = 'pending';
1592
+ else if (state === 'pending') {
1593
+ // Safe recovery for a NON-INSERTABLE target (audit P1 #2
1594
+ // 2026-10-17): this branch only runs for EXPLICIT no-effect
1595
+ // receipts (attempted===false, definitively not inserted — the
1596
+ // outer guard). If the exact target run has settled, or the
1597
+ // target has refused insertion for over 60s (e.g. a long-lived
1598
+ // maintenance run), hand the input to the ordinary queue: the
1599
+ // gateway queues it behind whatever currently holds the session
1600
+ // instead of parking it in bridge-side limbo forever. Unknown
1601
+ // receipts never enter this branch and are never replayed.
1602
+ const boundSince = Number.isFinite(item.boundSince) ? item.boundSince : Date.now();
1603
+ item.boundSince = boundSince;
1604
+ if (originalRunEnded || Date.now() - boundSince > 60_000)
1605
+ state = 'ordinary';
1606
+ }
1579
1607
  }
1580
1608
  if (state === 'target_ended')
1581
1609
  state = 'rejected'; // prior explicit backend refusal; never silently move it to a successor
@@ -4628,13 +4656,46 @@ export class FeishuBot {
4628
4656
  /**
4629
4657
  * Enqueue a message send to guarantee ordering per chat.
4630
4658
  * All sends for a chat are serialized through this.
4659
+ *
4660
+ * The chain must never wedge: a single hung Feishu call used to block every
4661
+ * later send in that chat forever (live-status patches fire per tool event),
4662
+ * which froze cards and stranded /stop confirmations. Each link is therefore
4663
+ * raced against a deadline, so a stuck send is abandoned and ordering resumes.
4664
+ * Prefer a larger OPENCLAW_LARK_MULTI_AGENT_SEND_TIMEOUT_MS on slow links.
4631
4665
  */
4632
4666
  sendOrdered(chatId, fn) {
4633
4667
  const prev = this.sendQueue.get(chatId) || Promise.resolve();
4634
- const next = prev.then(fn, fn); // run even if previous failed
4668
+ const run = () => this.withSendDeadline(fn, chatId);
4669
+ const next = prev.then(run, run); // run even if previous failed
4635
4670
  this.sendQueue.set(chatId, next.then(() => undefined, () => undefined));
4636
4671
  return next;
4637
4672
  }
4673
+ /** One send link: resolve with fn(), or reject at the deadline so the chain advances. */
4674
+ async withSendDeadline(fn, chatId) {
4675
+ const limit = SEND_DEADLINE_MS;
4676
+ let work;
4677
+ try {
4678
+ work = Promise.resolve(fn());
4679
+ }
4680
+ catch (err) {
4681
+ return Promise.reject(err);
4682
+ }
4683
+ // A late rejection from an abandoned send must never become an unhandled
4684
+ // rejection; the caller already moved on with the timeout error.
4685
+ work.catch(() => { });
4686
+ let timer;
4687
+ const deadline = new Promise((_, reject) => {
4688
+ timer = setTimeout(() => reject(new Error(`send deadline ${limit}ms exceeded for ${chatId.slice(-8)}`)), limit);
4689
+ timer.unref?.();
4690
+ });
4691
+ try {
4692
+ return await Promise.race([work, deadline]);
4693
+ }
4694
+ finally {
4695
+ if (timer)
4696
+ clearTimeout(timer);
4697
+ }
4698
+ }
4638
4699
  /**
4639
4700
  * Add a reaction (emoji) to a message.
4640
4701
  */
@@ -2177,8 +2177,8 @@ export class OpenClawClient {
2177
2177
  let timer;
2178
2178
  try {
2179
2179
  const value = await Promise.race([
2180
- this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, 1500),
2181
- new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("status lookup timeout")), 1500); }),
2180
+ this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, 5000),
2181
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("status lookup timeout")), 5000); }),
2182
2182
  ]);
2183
2183
  return normalizeSessionRuntimeStatus(value);
2184
2184
  }
@@ -2194,7 +2194,7 @@ export class OpenClawClient {
2194
2194
  * terminal caches. Unknown/missing fields never authorize scheduling. */
2195
2195
  async getSessionDispatchStatus(sessionKey) {
2196
2196
  let timer;
2197
- const key = this.canonicalSessionKey(sessionKey), deadline = Date.now() + 1500;
2197
+ const key = this.canonicalSessionKey(sessionKey), deadline = Date.now() + 8000;
2198
2198
  // One budget covers the entire public pending scan, not 1500ms per page.
2199
2199
  // Retained interrupted/cancelled inputs are recovery records, NOT runnable
2200
2200
  // queue entries. Never delete, mutate or replay them to prove schedulability.
@@ -2286,8 +2286,11 @@ export class OpenClawClient {
2286
2286
  return unknown();
2287
2287
  };
2288
2288
  try {
2289
+ // The outer race must honor the SAME total budget as the scan itself
2290
+ // (audit P1 2026-10-17: an 8000ms scan raced against a 1500ms timer was
2291
+ // still killed at 1500ms — three real 650ms pages never got to finish).
2289
2292
  return await Promise.race([scan(), new Promise((_, reject) => {
2290
- timer = setTimeout(() => reject(new Error('activity lookup timeout')), 1500);
2293
+ timer = setTimeout(() => reject(new Error('activity lookup timeout')), 8000);
2291
2294
  })]);
2292
2295
  }
2293
2296
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.12",
3
+ "version": "1.8.14",
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": {
@@ -83,4 +83,4 @@
83
83
  "publishConfig": {
84
84
  "access": "public"
85
85
  }
86
- }
86
+ }