openclaw-lark-multi-agent 1.8.13 → 1.8.15

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.
@@ -4,10 +4,11 @@ import * as lark from "@larksuiteoapi/node-sdk";
4
4
  import { createRequire } from "module";
5
5
  import { persistBotModel } from "./config.js";
6
6
  import { getI18n, normalizeLocale } from "./i18n.js";
7
- import { InactiveRunObservation, AdmissionUnconfirmed } from "./openclaw-client.js";
7
+ import { InactiveRunObservation, AdmissionUnconfirmed, CollectorStalledError } from "./openclaw-client.js";
8
8
  import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
9
9
  import { SupersededObservation } from "./openclaw-client.js";
10
- import { LiveStatusController } from "./live-status.js";
10
+ import { LiveStatusController, LiveStatusEffectRejectedError, LiveStatusEffectUnknownError } from "./live-status.js";
11
+ import { QueuedPresentations } from "./queued-presentation.js";
11
12
  import { CompactProgressController } from "./compact-progress.js";
12
13
  import { GoController } from "./go-controller.js";
13
14
  import { errorIdentity } from "./terminal-errors.js";
@@ -17,6 +18,17 @@ import { basename, extname, join, resolve } from "path";
17
18
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
18
19
  import { buildFeishuCardElements } from "./markdown.js";
19
20
  import { discussionManager } from "./discussion-manager.js";
21
+ /** Observation deadline, never a claim that the underlying SDK was cancelled. */
22
+ export class SendDeadlineError extends Error {
23
+ chatId;
24
+ limit;
25
+ constructor(chatId, limit) {
26
+ super(`send deadline ${limit}ms exceeded for ${chatId.slice(-8)}`);
27
+ this.chatId = chatId;
28
+ this.limit = limit;
29
+ this.name = 'SendDeadlineError';
30
+ }
31
+ }
20
32
  const nonemptyReceipt = (value) => typeof value === 'string' && !!value.trim();
21
33
  const require = createRequire(import.meta.url);
22
34
  const LMA_VERSION = require("../package.json").version;
@@ -151,18 +163,24 @@ export class FeishuBot {
151
163
  * fails restore their durable fallback. Fired in processQueueInner's
152
164
  * failure arms; dropped at each new batch top and on queue exit. */
153
165
  runFailureObservers = new Map();
154
- /** Steered messages admitted into the CURRENT batch (per chat). Flipped to
155
- * DONE when that batch completes successfully; cleared (unflipped) when the
156
- * batch scope ends any other way. */
166
+ /** Ephemeral batch-scoped acceptance bookkeeping, not commitment evidence.
167
+ * Ending the batch only drops these handles; durable insertion custody stays
168
+ * accepted and must not inherit the root's DONE. */
157
169
  admittedSteerAcks = new Map();
158
- /** Identity token of the CURRENT queue owner per chat. Steer acks carry
159
- * the token of the batch that admitted them; a completion path only flips
160
- * acks of its OWN batch, so a superseded inner can never DONE a newer
161
- * batch's steered inputs (2026-10-16 audit B9). */
170
+ /** Identity token of the CURRENT queue owner per chat. Acceptance bookkeeping
171
+ * carries its batch token so late old cleanup never takes a newer batch's
172
+ * handles (2026-10-16 audit B9); neither token proves input commitment. */
162
173
  queueRunTokens = new Map();
163
174
  /** Go / Go-Checker supervision controller (per-bot instance). */
164
175
  goDeliveryReceipts = new AsyncLocalStorage();
165
176
  goReceiptApis = new WeakSet();
177
+ sendEffectLeases = new AsyncLocalStorage();
178
+ /** A deadline releases the chat queue, NOT this exact operation's ownership. */
179
+ inFlightDeliveryWork = new Set();
180
+ assertSendEffectLease(chatId) {
181
+ if (this.sendEffectLeases.getStore()?.expired)
182
+ throw new SendDeadlineError(chatId, SEND_DEADLINE_MS);
183
+ }
166
184
  installGoReceiptCapture() {
167
185
  const docx = this.client.docx;
168
186
  const candidates = [
@@ -220,6 +238,7 @@ export class FeishuBot {
220
238
  }
221
239
  }
222
240
  else {
241
+ this.assertSendEffectLease(context?.chatId || '');
223
242
  context?.effectGuard?.();
224
243
  if (context)
225
244
  save('attempted');
@@ -302,6 +321,7 @@ export class FeishuBot {
302
321
  adminOpenId;
303
322
  locale;
304
323
  configPath;
324
+ queuedPresentations;
305
325
  static allBots = new Map();
306
326
  /** Chats where this bot app has actually received an event in this process. */
307
327
  static seenBotChats = new Map();
@@ -309,6 +329,8 @@ export class FeishuBot {
309
329
  this.config = config;
310
330
  this.openclawClient = openclawClient;
311
331
  this.store = store;
332
+ this.queuedPresentations = new QueuedPresentations(() => this.store, config.name, config.appId, (chat, replyTo) => ({ ...this.liveStatusCallbacks(chat, replyTo), warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)) }), chat => this.shouldUseLiveStatus(chat), chat => this.isEn(chat));
333
+ this.store.onClose(() => this.queuedPresentations.dispose());
312
334
  this.openclawClient.bindRecoveryStore?.(store);
313
335
  this.adminOpenId = adminOpenId || null;
314
336
  this.locale = normalizeLocale(config.locale);
@@ -504,22 +526,24 @@ export class FeishuBot {
504
526
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
505
527
  this.cancelDelayedFailure(chatId);
506
528
  const persistedTarget = meta?.runId ? this.store.getDeliveryRunTarget(this.config.name, chatId, meta.runId) : null;
507
- const runTarget = meta?.runId ? this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId)) || (persistedTarget ? { ...persistedTarget, token: Symbol(meta.runId) } : undefined) : undefined;
529
+ const runTarget = meta?.runId ? this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId)) || (persistedTarget && persistedTarget.stopGeneration === this.stopGeneration(chatId) ? { ...persistedTarget, liveStatus: this.presentation(() => this.queuedPresentations.forRun(chatId, meta.runId)), token: Symbol(meta.runId) } : undefined) : undefined;
508
530
  const candidateTarget = this.activeDeliveryTargets.get(chatId);
509
- const activeTarget = runTarget || (candidateTarget && (!candidateTarget.runId || !meta?.runId || candidateTarget.runId === meta.runId)
531
+ const activeTarget = runTarget || (candidateTarget && !!candidateTarget.runId && !!meta?.runId && candidateTarget.runId === meta.runId
510
532
  ? candidateTarget
511
533
  : undefined);
512
534
  try {
513
- await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model, meta);
514
- if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
515
- await activeTarget?.liveStatus?.complete().catch(() => { });
535
+ const outcome = await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model, meta);
536
+ if (outcome.state === "delivered" && sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
537
+ await (meta?.runId ? this.presentation(() => this.queuedPresentations.forTarget(chatId, meta.runId, activeTarget?.liveStatus)) : undefined)?.complete().catch(() => { });
538
+ if (meta?.runId)
539
+ await this.queuedPresentations.settleRun(chatId, meta.runId).catch(() => { });
516
540
  if (meta?.runId && runTarget)
517
541
  this.releaseRunDeliveryTargetAfter(chatId, meta.runId, 30_000);
518
542
  }
519
543
  }
520
544
  catch (err) {
521
- if (sourceType !== "verbose_transcript")
522
- await activeTarget?.liveStatus?.fail().catch(() => { });
545
+ if (sourceType !== "verbose_transcript" && meta?.runId)
546
+ await this.presentation(() => this.queuedPresentations.forTarget(chatId, meta.runId, activeTarget?.liveStatus))?.fail().catch(() => { });
523
547
  throw err;
524
548
  }
525
549
  }
@@ -529,10 +553,13 @@ export class FeishuBot {
529
553
  }, async (meta) => {
530
554
  if (!meta.runId || this.store.isStoppedObservation(`agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}`, meta.runId))
531
555
  return;
556
+ if (this.store.hasUnconfirmedRunDelivery(this.config.name, chatId, meta.runId))
557
+ return;
532
558
  this.store.settleRunReactionIntents(this.config.name, chatId, meta.runId);
533
559
  void this.reconcileReactions(chatId).catch(() => { });
534
560
  const target = this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId));
535
- await target?.liveStatus?.complete().catch(() => { });
561
+ await (target?.liveStatus || this.presentation(() => this.queuedPresentations.forRun(chatId, meta.runId)))?.complete().catch(() => { });
562
+ await this.queuedPresentations.settleRun(chatId, meta.runId).catch(() => { });
536
563
  this.releaseRunDeliveryTargetAfter(chatId, meta.runId, 30_000);
537
564
  });
538
565
  // Subscribe to tool events for verbose mode
@@ -1225,6 +1252,10 @@ export class FeishuBot {
1225
1252
  if (!isBot && insertedId > 0) {
1226
1253
  this.goController?.onHumanInput(chatId);
1227
1254
  this.store.markPendingTrigger(this.config.name, chatId, insertedId);
1255
+ // No health/setup/model/history await precedes this honest local receipt.
1256
+ // UI custody cannot reserve dispatch or claim Gateway/model acceptance.
1257
+ const presentationRoot = this.queueOwners.get(chatId)?.runId || this.store.getDispatchCustody(this.config.name, chatId)?.runId;
1258
+ this.presentation(() => this.queuedPresentations.receive(chatId, insertedId, messageId, isNativeOpenClawCommand, presentationRoot));
1228
1259
  // Reserve BEFORE the first health/reaction await. Otherwise an ending
1229
1260
  // owner can ordinary-drain this row while its handler is still waiting,
1230
1261
  // and the later steer would submit the same message a second time.
@@ -1364,21 +1395,34 @@ export class FeishuBot {
1364
1395
  return;
1365
1396
  }
1366
1397
  const identified = compatible && !!activity.activeRunId && !!activity.sessionId && !activity.archived && !status.archived;
1398
+ // A4 (2026-10-17 protocol audit): positive blockers are gateway assertions,
1399
+ // not unknown states. An archived session, an incompatible session
1400
+ // generation, or confirmed busy-without-injectable-owner each positively
1401
+ // forbids fresh ordinary admission, so the bounded fallback below must
1402
+ // never hand such an input to the ordinary queue; it stays retained.
1403
+ const archived = !!status.archived || !!activity.archived;
1404
+ const generationConflict = !!activity.sessionId && !!status.sessionId && activity.sessionId !== status.sessionId;
1405
+ const positivelyBusy = activity.busyConfirmed === true;
1367
1406
  // Bounded retention (2026-10-17 deadlock): an input that has stayed
1368
1407
  // activity-unbound for over 60s despite re-checks must not be held forever.
1369
1408
  // Hand it to the ordinary queue — the gateway's own chat.send admission is
1370
- // the authoritative answer and admission custody already dedups.
1409
+ // the authoritative answer and admission custody already dedups. This
1410
+ // rescue is reserved for GENUINELY unknown states; positive blockers are
1411
+ // excluded above and keep the never-attempted input retained.
1371
1412
  const unboundSince = current?.activityUnbound ? (current.unboundSince || 0) : 0;
1372
- if (!identified && unboundSince && Date.now() - unboundSince > 60_000) {
1413
+ if (!identified && !archived && !generationConflict && !positivelyBusy && unboundSince && Date.now() - unboundSince > 60_000) {
1373
1414
  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
1415
  return;
1375
1416
  }
1417
+ const blockerReason = archived ? 'retained: archived (gateway positively reported archived session; 60s ordinary fallback suppressed)'
1418
+ : generationConflict ? 'retained: generation-conflict (activity session generation differs from runtime status; 60s ordinary fallback suppressed)'
1419
+ : positivelyBusy ? 'retained: positively-busy (gateway confirmed busy without an injectable owner; 60s ordinary fallback suppressed)' : '';
1376
1420
  this.store.saveInsertion(this.config.name, chatId, rowId, current?.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1377
1421
  messageId, text, stopEpoch: epoch, sessionKey: this.getSessionKey(chatId), sessionId: identified ? activity.sessionId : status.sessionId,
1378
1422
  expectedRunId: identified ? activity.activeRunId : '', ownerId: identified ? `external:${activity.activeRunId}` : undefined,
1379
1423
  batchId: identified ? activity.activeRunId : undefined, activityUnbound: !identified, attempted: false,
1380
1424
  ...(identified ? {} : { unboundSince: unboundSince || Date.now() }),
1381
- reason: identified ? 'current Gateway run observed; insertion custody only' : 'current Gateway activity/run identity unavailable; retained without admission',
1425
+ reason: identified ? 'current Gateway run observed; insertion custody only' : (blockerReason || 'current Gateway activity/run identity unavailable; retained without admission'),
1382
1426
  }, Date.now() + (identified ? 0 : 5000));
1383
1427
  }
1384
1428
  /** CAS includes the actual batch, not merely the queue Promise, and excludes
@@ -1410,11 +1454,22 @@ export class FeishuBot {
1410
1454
  this.forceExpireQueueOwner(chatId, 'current session confirmed schedulable; exact result observation retained');
1411
1455
  return true;
1412
1456
  }
1457
+ presentation(work) {
1458
+ try {
1459
+ return work();
1460
+ }
1461
+ catch (err) {
1462
+ console.warn(`[${this.config.name}] presentation unavailable; business custody unchanged:`, this.errorSummary(err));
1463
+ return undefined;
1464
+ }
1465
+ }
1413
1466
  stopGeneration(chatId) {
1414
1467
  return this.stopEpoch.get(chatId) ?? this.store.getStopGeneration(this.config.name, chatId);
1415
1468
  }
1416
1469
  reactionChecks = new Set();
1417
1470
  async receiptReaction(chatId, rowId, messageId, desired, runId = '') {
1471
+ if (desired === 'DONE' && !this.store.isOrdinaryInput(this.config.name, chatId, rowId))
1472
+ return;
1418
1473
  const previous = this.store.getReactionIntent(this.config.name, chatId, rowId);
1419
1474
  const record = { ...previous, messageId, desired, shown: previous?.shown || '', runId: runId || previous?.runId || '', stopEpoch: this.stopGeneration(chatId) };
1420
1475
  this.store.setReactionIntent(this.config.name, chatId, rowId, record);
@@ -1533,20 +1588,20 @@ export class FeishuBot {
1533
1588
  item.sessionId = sessionId;
1534
1589
  if (item.state === 'accepted') {
1535
1590
  this.store.clearPendingTrigger(this.config.name, chatId, item.rowId);
1591
+ void this.queuedPresentations.insertionAccepted(chatId, item.rowId, item.expectedRunId).catch(() => { });
1536
1592
  const reaction = this.store.getReactionIntent(this.config.name, chatId, item.rowId);
1537
1593
  if (!reaction || reaction.desired === 'Typing')
1538
1594
  await this.receiptReaction(chatId, item.rowId, item.messageId, 'Get', item.expectedRunId);
1539
1595
  if (!stillCurrent())
1540
1596
  continue;
1541
- const settled = await this.openclawClient.exactRunSettled?.(item.expectedRunId);
1597
+ await this.openclawClient.exactRunSettled?.(item.expectedRunId);
1542
1598
  if (!stillCurrent())
1543
1599
  continue;
1544
- if (settled) {
1545
- this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'committed', item);
1546
- await this.receiptReaction(chatId, item.rowId, item.messageId, 'DONE', item.expectedRunId);
1547
- }
1548
- else
1549
- this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'accepted', item, Date.now() + 60_000);
1600
+ // Boolean queue acceptance + expected root settlement (including
1601
+ // error/timeout) cannot establish this exact input's commitment,
1602
+ // actual receiver, consumption, or result. Keep Get and no-replay
1603
+ // custody, with paced observation. Do not invent a host input ID.
1604
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'accepted', item, Date.now() + 60_000);
1550
1605
  continue;
1551
1606
  }
1552
1607
  const batchAtCheck = owner?.batch, runAtCheck = owner?.runId;
@@ -1589,6 +1644,21 @@ export class FeishuBot {
1589
1644
  }
1590
1645
  else if (state === 'target_ended')
1591
1646
  state = 'pending';
1647
+ else if (state === 'pending') {
1648
+ // Safe recovery for a NON-INSERTABLE target (audit P1 #2
1649
+ // 2026-10-17): this branch only runs for EXPLICIT no-effect
1650
+ // receipts (attempted===false, definitively not inserted — the
1651
+ // outer guard). If the exact target run has settled, or the
1652
+ // target has refused insertion for over 60s (e.g. a long-lived
1653
+ // maintenance run), hand the input to the ordinary queue: the
1654
+ // gateway queues it behind whatever currently holds the session
1655
+ // instead of parking it in bridge-side limbo forever. Unknown
1656
+ // receipts never enter this branch and are never replayed.
1657
+ const boundSince = Number.isFinite(item.boundSince) ? item.boundSince : Date.now();
1658
+ item.boundSince = boundSince;
1659
+ if (originalRunEnded || Date.now() - boundSince > 60_000)
1660
+ state = 'ordinary';
1661
+ }
1592
1662
  }
1593
1663
  if (state === 'target_ended')
1594
1664
  state = 'rejected'; // prior explicit backend refusal; never silently move it to a successor
@@ -1613,6 +1683,7 @@ export class FeishuBot {
1613
1683
  // Durable acceptance drives Get, including after bridge restart when
1614
1684
  // its ephemeral pendingAckMessages array no longer exists.
1615
1685
  await this.receiptReaction(chatId, item.rowId, item.messageId, 'Get', item.expectedRunId);
1686
+ void this.queuedPresentations.insertionAccepted(chatId, item.rowId, item.expectedRunId).catch(err => console.warn('insertion presentation checkpoint failed', this.errorSummary(err)));
1616
1687
  if (!stillCurrent())
1617
1688
  continue;
1618
1689
  const ack = (this.pendingAckMessages.get(chatId) || []).find(a => a.rowId === item.rowId);
@@ -1868,11 +1939,12 @@ export class FeishuBot {
1868
1939
  owner.runId = undefined;
1869
1940
  owner.admitted = false;
1870
1941
  owner.collecting = false;
1942
+ owner.observationDetached = false;
1871
1943
  owner.yielded = false;
1872
1944
  owner.sessionId = undefined;
1873
1945
  this.queueRunTokens.set(chatId, batchToken);
1874
- // A fresh batch starts: steered inputs admitted into the PREVIOUS batch
1875
- // completed with it (consumed or not); their failure observers are stale.
1946
+ // A fresh batch starts: previous ephemeral handles are stale. Accepted
1947
+ // insertions remain durable/unconfirmed, NOT completed with that batch.
1876
1948
  this.runFailureObservers.delete(chatId);
1877
1949
  this.admittedSteerAcks.delete(chatId);
1878
1950
  const pendingMessages = this.store.getPendingTriggerMessages(this.config.name, chatId);
@@ -2023,6 +2095,11 @@ export class FeishuBot {
2023
2095
  }
2024
2096
  const processedMessages = [...contextMsgs, ...mergedTriggers].filter((m) => m.id);
2025
2097
  const queueStartedAt = Date.now();
2098
+ // Recovery/drain has no ingress handler: establish the same local-only
2099
+ // queued presentation before ensureSession or metadata RPCs as well.
2100
+ if (!isNativeCommandTrigger)
2101
+ this.presentation(() => this.queuedPresentations.receive(chatId, triggerId, lastHuman.messageId || "", false));
2102
+ const liveStatus = this.presentation(() => this.queuedPresentations.claim(chatId, mergedTriggerIds, owner.id, owner.batchId, isNativeCommandTrigger));
2026
2103
  let sessionKey;
2027
2104
  try {
2028
2105
  sessionKey = await this.ensureSession(chatId);
@@ -2034,6 +2111,7 @@ export class FeishuBot {
2034
2111
  catch (err) {
2035
2112
  if (!current())
2036
2113
  break;
2114
+ await liveStatus?.pauseForUnconfirmedResult(this.isEn(chatId) ? "Session setup failed before admission" : "会话准备失败,尚未提交 Gateway", this.isEn(chatId) ? `${this.config.name} setup unavailable` : `${this.config.name} 会话准备未完成`).catch(() => { });
2037
2115
  // Model policy/capability failures happen before chat.send. Clear the
2038
2116
  // busy marker, report the actionable error, and retire only this batch
2039
2117
  // so the chat cannot remain wedged indefinitely.
@@ -2054,9 +2132,9 @@ export class FeishuBot {
2054
2132
  }
2055
2133
  console.log(`[${this.config.name}] Sending ${mergedTriggers.length} trigger(s) as 1 run to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length}, dropped=${droppedTriggerIds.length})`);
2056
2134
  let foregroundRunId;
2135
+ let initialPresentationRunId;
2057
2136
  const foregroundSessionId = this.store.getActiveGoJob(this.config.name, chatId)?.executorSessionId;
2058
2137
  let runAcceptedByOpenClaw = false;
2059
- let liveStatus;
2060
2138
  try {
2061
2139
  const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
2062
2140
  let reply;
@@ -2075,20 +2153,13 @@ export class FeishuBot {
2075
2153
  currentMarkedSubmitted = true;
2076
2154
  }
2077
2155
  };
2078
- liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
2079
- ? new LiveStatusController({
2080
- create: (view) => this.sendOrdered(chatId, () => this.replyLiveStatusCard(lastHuman.messageId || "", view, chatId)),
2081
- edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
2082
- warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
2083
- }, {
2084
- botName: this.config.name,
2085
- model: this.config.model,
2086
- thinkingLevel: await this.fetchThinkingLabel(sessionKey),
2087
- locale: this.isEn(chatId) ? "en" : "zh",
2088
- })
2089
- : undefined;
2090
2156
  guard();
2091
- liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
2157
+ // Metadata is decoration, never a reason to postpone receipt/admission.
2158
+ if (liveStatus)
2159
+ void this.fetchThinkingLabel(sessionKey).then(thinking => {
2160
+ if (current())
2161
+ liveStatus?.setMetadata(this.config.model, thinking);
2162
+ }).catch(() => { });
2092
2163
  const activeTargetForStatus = this.activeDeliveryTargets.get(chatId);
2093
2164
  if (activeTargetForStatus?.triggerId === triggerId && liveStatus)
2094
2165
  activeTargetForStatus.liveStatus = liveStatus;
@@ -2106,6 +2177,7 @@ export class FeishuBot {
2106
2177
  includeBridgeAttachmentHint: !isNativeCommandTrigger,
2107
2178
  onSendAttempt: (submissionId) => {
2108
2179
  guard();
2180
+ this.presentation(() => this.queuedPresentations.attempted(liveStatus, submissionId, owner.id, owner.batchId));
2109
2181
  if (submissionId && lastHuman.messageId) {
2110
2182
  this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, triggerIds: mergedTriggerIds, messageId: lastHuman.messageId, runId: submissionId, stopGeneration: this.stopGeneration(chatId) });
2111
2183
  for (const ack of this.pendingAckMessages.get(chatId) || [])
@@ -2122,6 +2194,8 @@ export class FeishuBot {
2122
2194
  guard();
2123
2195
  owner.runId = runId;
2124
2196
  owner.admitted = true;
2197
+ initialPresentationRunId = runId;
2198
+ void this.queuedPresentations.admit(liveStatus, runId, owner.id, owner.batchId).catch(err => console.warn("queued presentation ACK checkpoint failed", this.errorSummary(err)));
2125
2199
  this.store.saveDispatchCustody(this.config.name, chatId, { id: owner.id, batchId: owner.batchId, runId, sessionId: owner.sessionId, admitted: true, stopEpoch: owner.stopEpoch, sessionKey });
2126
2200
  void this.reconcileInsertions(chatId).catch(() => { });
2127
2201
  this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || '', liveStatus });
@@ -2146,7 +2220,7 @@ export class FeishuBot {
2146
2220
  // INSERT OR IGNORE in message_sync.
2147
2221
  markSubmittedBatch(`submitted:${runId}`);
2148
2222
  },
2149
- onProgress: (event) => current() ? liveStatus?.progress(event) : undefined,
2223
+ onProgress: (event) => current() && runAcceptedByOpenClaw && !!initialPresentationRunId && owner.runId === initialPresentationRunId ? liveStatus?.progress(event) : undefined,
2150
2224
  onCollectionStarted: runId => { guard(); if (owner.runId === runId)
2151
2225
  owner.collecting = true; },
2152
2226
  onYieldHandoff: runId => {
@@ -2205,7 +2279,7 @@ export class FeishuBot {
2205
2279
  guard();
2206
2280
  this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey: `agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}` });
2207
2281
  },
2208
- onSubmitted: async (runId) => { guard(); owner.runId = runId; foregroundRunId = runId; this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || "", liveStatus }); },
2282
+ onSubmitted: async (runId) => { guard(); const previousRoot = owner.runId; owner.runId = runId; foregroundRunId = runId; void this.queuedPresentations.advance(liveStatus, previousRoot, runId, owner.id, owner.batchId).catch(() => { }); this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || "", liveStatus }); },
2209
2283
  });
2210
2284
  guard();
2211
2285
  const parsedReply = this.extractBridgeAttachments(reply);
@@ -2231,21 +2305,12 @@ export class FeishuBot {
2231
2305
  console.log(`[${this.config.name}] Empty-final cleanup skipped; trigger ${triggerId} already has an answer delivery`);
2232
2306
  }
2233
2307
  else {
2234
- await liveStatus?.noReply().catch(() => { });
2308
+ await liveStatus?.pauseForUnconfirmedResult("尚未确认最终回复,保留原运行结果观察").catch(() => { });
2235
2309
  }
2236
2310
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
2237
- const pendingAcks = this.pendingAckMessages.get(chatId) || [];
2238
- const remainingAcks = [];
2239
- for (const ack of pendingAcks) {
2240
- if (completedTriggerIdSet.has(ack.rowId)) {
2241
- await this.receiptReaction(chatId, ack.rowId, ack.messageId, 'DONE', foregroundRunId || owner.runId);
2242
- }
2243
- else {
2244
- remainingAcks.push(ack);
2245
- }
2246
- }
2247
- guard();
2248
- this.pendingAckMessages.set(chatId, remainingAcks);
2311
+ // Bare empty text carries no attributable terminal disposition. Exact
2312
+ // silent/empty outcomes use NO_REPLY or the exact settlement callback.
2313
+ // Retain acceptance reactions; neither a timeout nor empty string is DONE.
2249
2314
  break;
2250
2315
  }
2251
2316
  // Mark current triggers only after the run produced a non-empty result.
@@ -2267,6 +2332,9 @@ export class FeishuBot {
2267
2332
  this.store.clearPendingTrigger(this.config.name, chatId, id);
2268
2333
  const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
2269
2334
  const isRuntimeFailure = shouldReply && this.isRuntimeFailureText(trimmedReply);
2335
+ const ownedVisibleDelivery = this.store.getDeliveryByKey(this.config.name, chatId, `trigger:${triggerId}`);
2336
+ let visibleOutcomeConfirmed = explicitNoReply && !hasAttachments && (!ownedVisibleDelivery ||
2337
+ this.store.getDeliveryDispatchOutcome(this.config.name, chatId, `trigger:${triggerId}`).state === 'delivered');
2270
2338
  // Receipt-aware dispatcher owns all shared assistant context rows.
2271
2339
  // Wait for all pending tool event messages to be delivered first
2272
2340
  const toolSends = this.pendingToolSends.get(chatId) || [];
@@ -2280,6 +2348,7 @@ export class FeishuBot {
2280
2348
  if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
2281
2349
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
2282
2350
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
2351
+ visibleOutcomeConfirmed = true;
2283
2352
  await liveStatus?.complete().catch(() => { });
2284
2353
  }
2285
2354
  else {
@@ -2287,14 +2356,18 @@ export class FeishuBot {
2287
2356
  // Keep live status and final answer as separate messages. The final
2288
2357
  // answer is created at completion time so it remains at the bottom
2289
2358
  // even when the user inserted more messages during the run.
2290
- await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, this.config.model, foregroundRunId ? { runId: foregroundRunId, sessionKey, ...(foregroundSessionId ? { sessionId: foregroundSessionId } : {}), final: true } : undefined);
2291
- await liveStatus?.complete();
2292
- // Mark every merged trigger as delivered so retries/restarts do
2293
- // not re-process any of them.
2294
- for (const id of mergedTriggerIds)
2295
- this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
2296
- // After a successful reply, warn the user once if context is high.
2297
- void this.maybeAlertHighContext(chatId);
2359
+ const outcome = await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, this.config.model, foregroundRunId ? { runId: foregroundRunId, sessionKey, ...(foregroundSessionId ? { sessionId: foregroundSessionId } : {}), final: true } : undefined, undefined, mergedTriggerIds);
2360
+ visibleOutcomeConfirmed = outcome.state === 'delivered';
2361
+ if (!visibleOutcomeConfirmed) {
2362
+ await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;保留原投递保管,不重复发送").catch(() => { });
2363
+ }
2364
+ else {
2365
+ await liveStatus?.complete();
2366
+ // Mark every merged trigger only after receipt confirmation.
2367
+ for (const id of mergedTriggerIds)
2368
+ this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
2369
+ void this.maybeAlertHighContext(chatId);
2370
+ }
2298
2371
  // Go supervision: record the delivered final reply as evidence and
2299
2372
  // arm a debounced checker round (busy gates apply inside).
2300
2373
  // Actual body/attachment receipts are recorded by dispatchPendingDeliveries.
@@ -2365,7 +2438,7 @@ export class FeishuBot {
2365
2438
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
2366
2439
  const remainingAcks = [];
2367
2440
  for (const ack of pendingAcks) {
2368
- if (completedTriggerIdSet.has(ack.rowId)) {
2441
+ if (visibleOutcomeConfirmed && completedTriggerIdSet.has(ack.rowId)) {
2369
2442
  guard();
2370
2443
  await this.receiptReaction(chatId, ack.rowId, ack.messageId, 'DONE', foregroundRunId || owner.runId);
2371
2444
  }
@@ -2375,29 +2448,15 @@ export class FeishuBot {
2375
2448
  }
2376
2449
  guard();
2377
2450
  this.pendingAckMessages.set(chatId, remainingAcks);
2378
- // Steered messages admitted into THIS batch were consumed with it:
2379
- // flip their Get → DONE alongside the batch's own triggers.
2380
- // B9 (2026-10-16 audit): flip only THIS batch's admitted steer acks. A
2381
- // superseded inner completing late must never DONE a newer batch's
2382
- // steered inputs — foreign acks stay for their owning batch.
2451
+ // Root success and Feishu receipts cover ordinary triggers only. The
2452
+ // legacy SDK acknowledged enqueue, not per-input commitment. B9 still
2453
+ // fences cleanup by batch; durable accepted/Get custody remains held.
2383
2454
  const allSteers = this.admittedSteerAcks.get(chatId) || [];
2384
- const admittedSteers = allSteers.filter((s) => s.batch === batchToken);
2385
2455
  if (allSteers.length > 0) {
2386
2456
  this.admittedSteerAcks.set(chatId, allSteers.filter((s) => s.batch !== batchToken));
2387
2457
  }
2388
- if (admittedSteers.length > 0) {
2389
- for (const s of admittedSteers) {
2390
- guard();
2391
- const custody = this.store.getInsertion(this.config.name, chatId, s.insertedId);
2392
- if (custody?.state !== 'accepted' || custody.expectedRunId !== owner.runId)
2393
- continue;
2394
- this.store.saveInsertion(this.config.name, chatId, s.insertedId, custody.insertionId, 'committed', custody);
2395
- await this.receiptReaction(chatId, s.insertedId, s.messageId, 'DONE', owner.runId);
2396
- }
2397
- const acksAfter = this.pendingAckMessages.get(chatId) || [];
2398
- guard();
2399
- this.pendingAckMessages.set(chatId, acksAfter.filter((a) => !admittedSteers.some((s) => a.messageId === s.messageId)));
2400
- }
2458
+ if (visibleOutcomeConfirmed && owner.runId)
2459
+ await this.queuedPresentations.settleRun(chatId, owner.runId).catch(() => { });
2401
2460
  }
2402
2461
  catch (err) {
2403
2462
  if (!current() || err instanceof SupersededObservation) {
@@ -2442,6 +2501,17 @@ export class FeishuBot {
2442
2501
  this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
2443
2502
  break;
2444
2503
  }
2504
+ if (err instanceof CollectorStalledError) {
2505
+ // Not a task failure: do not restore/replay accepted insertions or
2506
+ // mark DONE. Retain exact dispatch/result custody; release the local
2507
+ // owner independently of potentially hung card/reaction effects.
2508
+ owner.observationDetached = true;
2509
+ const epoch = owner.stopEpoch;
2510
+ if (this.stopGeneration(chatId) === epoch && !this.store.hasDeliveredReply(this.config.name, chatId, triggerId))
2511
+ void liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
2512
+ console.warn(`[${this.config.name}] local observer stalled for ${chatId.slice(-8)}; durable exact-result custody retained`);
2513
+ break;
2514
+ }
2445
2515
  if (err instanceof InactiveRunObservation) {
2446
2516
  this.fireRunFailureObservers(chatId);
2447
2517
  await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
@@ -2469,12 +2539,14 @@ export class FeishuBot {
2469
2539
  console.error(`[${this.config.name}] processQueue error:`, safeErrorSummary(err));
2470
2540
  this.fireRunFailureObservers(chatId);
2471
2541
  const errorText = this.formatUserVisibleError(err, chatId);
2542
+ let errorOutcomeConfirmed = false;
2472
2543
  if (lastHuman.messageId) {
2473
2544
  // Error message goes through the normal delivery path; the live status
2474
2545
  // is a separate message that we mark interrupted afterwards.
2475
2546
  await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
2476
- .then(() => {
2477
- if (current() && triggerId)
2547
+ .then(outcome => {
2548
+ errorOutcomeConfirmed = outcome.state === 'delivered';
2549
+ if (errorOutcomeConfirmed && current() && triggerId)
2478
2550
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
2479
2551
  })
2480
2552
  .catch(() => { });
@@ -2498,7 +2570,7 @@ export class FeishuBot {
2498
2570
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
2499
2571
  const remainingAcks = [];
2500
2572
  for (const ack of pendingAcks) {
2501
- if (completedTriggerIdSet.has(ack.rowId) && runAcceptedByOpenClaw) {
2573
+ if (errorOutcomeConfirmed && completedTriggerIdSet.has(ack.rowId) && runAcceptedByOpenClaw) {
2502
2574
  guard();
2503
2575
  await this.receiptReaction(chatId, ack.rowId, ack.messageId, 'DONE', foregroundRunId || owner.runId);
2504
2576
  }
@@ -2513,7 +2585,7 @@ export class FeishuBot {
2513
2585
  finally {
2514
2586
  if (current()) {
2515
2587
  this.busyChats.set(chatId, 0);
2516
- if (owner.batchId && !owner.yielded)
2588
+ if (owner.batchId && !owner.yielded && !owner.observationDetached)
2517
2589
  this.store.clearDispatchCustody(this.config.name, chatId, owner.id, owner.batchId);
2518
2590
  }
2519
2591
  }
@@ -3069,8 +3141,8 @@ export class FeishuBot {
3069
3141
  return;
3070
3142
  }
3071
3143
  }
3072
- await this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`);
3073
- if (triggerId)
3144
+ const outcome = await this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`);
3145
+ if (outcome.state === "delivered" && triggerId)
3074
3146
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, replyToMessageId);
3075
3147
  })()
3076
3148
  .catch((err) => {
@@ -3097,21 +3169,22 @@ export class FeishuBot {
3097
3169
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
3098
3170
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
3099
3171
  }
3100
- async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId) {
3172
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId, completionTriggerIds) {
3101
3173
  if (provenance?.sessionKey && !/^agent:/.test(provenance.sessionKey))
3102
3174
  provenance = { ...provenance, sessionKey: `agent:main:${provenance.sessionKey}` };
3103
3175
  if (provenance?.sessionKey && provenance.runId && this.store.isStoppedObservation(provenance.sessionKey, provenance.runId))
3104
- return;
3176
+ return { state: "suppressed", outboxIds: [] };
3105
3177
  if (text.trim().toUpperCase() === 'NO_REPLY')
3106
3178
  text = '';
3107
3179
  if (!text.trim() && attachments.length === 0)
3108
- return;
3180
+ return { state: "suppressed", outboxIds: [] };
3109
3181
  const attachmentsJson = JSON.stringify(attachments);
3110
3182
  const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
3111
3183
  const contentHash = this.stableHash(normalizedPayload);
3112
3184
  const finalDeliveryKey = deliveryKey || sourceId;
3113
3185
  const deliveryMetaJson = JSON.stringify({
3114
3186
  botIdentity: this.config.appId,
3187
+ ...(completionTriggerIds?.length ? { completionTriggerIds } : {}),
3115
3188
  deliveryStopEpoch: this.stopGeneration(chatId),
3116
3189
  ...(finalReplyModel?.trim() ? { model: finalReplyModel.trim() } : {}),
3117
3190
  ...(provenance ? { provenance } : {}), ...(errorNoticeId ? { errorNoticeId } : {}),
@@ -3122,14 +3195,14 @@ export class FeishuBot {
3122
3195
  this.store.promoteExactRunFinal(this.config.name, chatId, identity.sessionKey, identity.runId, contentHash, text, attachmentsJson);
3123
3196
  if (!deliveryKey && this.store.hasExactRunFinalPayload(this.config.name, chatId, identity, text, attachmentsJson)) {
3124
3197
  await this.dispatchPendingDeliveries(chatId);
3125
- return;
3198
+ return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
3126
3199
  }
3127
3200
  }
3128
3201
  if (sourceType === "verbose_transcript") {
3129
3202
  if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
3130
- return;
3203
+ return { state: "suppressed", outboxIds: [] };
3131
3204
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
3132
- return;
3205
+ return { state: "suppressed", outboxIds: [] };
3133
3206
  }
3134
3207
  const deliveryId = this.store.enqueueDelivery({
3135
3208
  sessionKey: this.deliverySessionKey(chatId),
@@ -3146,6 +3219,8 @@ export class FeishuBot {
3146
3219
  targetMessageId: "",
3147
3220
  deliveryMetaJson,
3148
3221
  });
3222
+ if (completionTriggerIds?.length)
3223
+ this.store.bindDeliveryCompletionTriggers(this.config.name, chatId, finalDeliveryKey, completionTriggerIds);
3149
3224
  if (deliveryId === null) {
3150
3225
  // Another path already owns this stable logical delivery (commonly the
3151
3226
  // proactive callback racing the chat-final path). Never show the full
@@ -3159,7 +3234,7 @@ export class FeishuBot {
3159
3234
  this.store.renewUnsentErrorDelivery(existing.id, text, contentHash, deliveryMetaJson);
3160
3235
  }
3161
3236
  await this.dispatchPendingDeliveries(chatId);
3162
- return;
3237
+ return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
3163
3238
  }
3164
3239
  // A failed owner must not permanently poison a logical trigger. Re-enqueue
3165
3240
  // the payload under a deterministic recovery key so a later authoritative
@@ -3256,12 +3331,14 @@ export class FeishuBot {
3256
3331
  }
3257
3332
  // Dispatch recovery/correction/supplement rows together in creation order.
3258
3333
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
3259
- return;
3334
+ return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
3260
3335
  }
3261
3336
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
3262
3337
  if (sourceType === "assistant_visible" && (text.trim() || attachments.length > 0) && text.trim().toUpperCase() !== "NO_REPLY" && !this.isRuntimeFailureText(text.trim())) {
3263
- this.lastRealDeliveryAt.set(chatId, Date.now());
3338
+ if (this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey).state === "delivered")
3339
+ this.lastRealDeliveryAt.set(chatId, Date.now());
3264
3340
  }
3341
+ return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
3265
3342
  }
3266
3343
  async inspectErrorNotice(record) {
3267
3344
  if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
@@ -3429,6 +3506,61 @@ export class FeishuBot {
3429
3506
  this.stopGeneration(chatId) === notice.stopEpoch && current.sessionId === notice.sessionId &&
3430
3507
  Number.isFinite(current.unknownSince) && Date.now() - current.unknownSince >= 45_000;
3431
3508
  }
3509
+ deliveryDeadlineReason(id) {
3510
+ try {
3511
+ const effect = this.store.getDeliveryEffectState(id);
3512
+ return effect === 'unknown' ? 'deadline_attempted_unknown' : effect === 'accepted' ? 'deadline_after_acceptance' :
3513
+ effect === 'rejected' ? 'deadline_rejected' : 'deadline_before_attempt';
3514
+ }
3515
+ catch {
3516
+ return 'deadline_local_checkpoint';
3517
+ }
3518
+ }
3519
+ /** Durable local obligation, separate from the platform retry budget. */
3520
+ retainLocalDelivery(chatId, id, reason) {
3521
+ let custody = this.localDeliveryRetryCustody.get(chatId);
3522
+ if (!custody) {
3523
+ custody = new Set();
3524
+ this.localDeliveryRetryCustody.set(chatId, custody);
3525
+ }
3526
+ custody.add(id);
3527
+ let failures = 10;
3528
+ try {
3529
+ failures = this.store.deferLocalDelivery(id, reason);
3530
+ custody.delete(id);
3531
+ }
3532
+ catch { /* Process-local custody remains until SQLite is writable again. */ }
3533
+ this.scheduleDeliveryRetry(chatId, failures, true);
3534
+ }
3535
+ isDeliverySettlementRevoked(chatId, metadata) {
3536
+ const meta = JSON.parse(metadata || '{}'), p = meta.provenance;
3537
+ return typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
3538
+ !!(p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId));
3539
+ }
3540
+ /** Also runs for a late accepted SDK receipt, without replaying the final. */
3541
+ settleConfirmedDelivery(chatId, item) {
3542
+ const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
3543
+ if (this.isDeliverySettlementRevoked(chatId, item.deliveryMetaJson))
3544
+ return;
3545
+ const triggerKey = /^trigger:\d+(?=$|:authoritative:|:attachments:|:recovery:)/.exec(item.deliveryKey)?.[0];
3546
+ if (!triggerKey && !meta.completionTriggerIds?.length)
3547
+ return;
3548
+ const key = triggerKey || item.deliveryKey;
3549
+ if (this.store.getDeliveryDispatchOutcome(this.config.name, chatId, key).state !== 'delivered')
3550
+ return;
3551
+ const root = this.store.getDeliveryByKey(this.config.name, chatId, key);
3552
+ const rootMeta = JSON.parse(root?.deliveryMetaJson || '{}');
3553
+ const triggerIds = rootMeta.completionTriggerIds || (triggerKey ? [Number(triggerKey.slice(8))] : []);
3554
+ for (const id of triggerIds)
3555
+ this.store.markDeliveredReply(this.config.name, chatId, id, item.replyToMessageId);
3556
+ if (p?.runId) {
3557
+ this.store.settleRunReactionIntents(this.config.name, chatId, p.runId);
3558
+ void this.reconcileReactions(chatId).catch(() => { });
3559
+ const target = this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, p.runId));
3560
+ // Never await the card's own sendOrdered link from inside this send link.
3561
+ void target?.liveStatus?.complete().catch(() => { });
3562
+ }
3563
+ }
3432
3564
  async dispatchPendingDeliveries(chatId, replyToMessageId) {
3433
3565
  this.installGoReceiptCapture();
3434
3566
  for (const id of this.localDeliveryRetryCustody.get(chatId) || []) {
@@ -3447,6 +3579,11 @@ export class FeishuBot {
3447
3579
  if (pending.length === 0)
3448
3580
  break;
3449
3581
  for (const item of pending) {
3582
+ if (this.inFlightDeliveryWork.has(item.id)) {
3583
+ retryScheduled = true;
3584
+ this.scheduleDeliveryRetry(chatId, 10, true);
3585
+ continue;
3586
+ }
3450
3587
  const notice = JSON.parse(item.deliveryMetaJson || '{}').insertionNotice;
3451
3588
  if (notice) {
3452
3589
  const info = await this.openclawClient.getSessionInfo(this.getSessionKey(chatId), 1500).catch(() => null);
@@ -3467,233 +3604,246 @@ export class FeishuBot {
3467
3604
  ...this.store.listDeliveryReceipts(item.id),
3468
3605
  ...(deliveryJob ? this.store.listGoVisibleEvidence(deliveryJob.id).find(e => e.sourceKey === `outbox:${item.id}`)?.receipt?.messages ?? [] : []),
3469
3606
  ];
3470
- await this.sendOrdered(chatId, () => this.goDeliveryReceipts.run({ receipts, goId: deliveryJob?.id, outboxId: item.id, chatId, stage: "body", ordinals: new Map(), effectGuard: () => {
3471
- const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
3472
- if (!this.insertionNoticeCurrent(chatId, meta.insertionNotice))
3473
- throw Object.assign(new Error('Insertion notice no longer current'), { deliveryStopped: true });
3474
- if ((p?.final || item.sourceType === 'run_error') &&
3475
- (typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
3476
- p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
3477
- throw Object.assign(new Error('Delivery revoked by explicit stop before platform attempt'), { deliveryStopped: true });
3478
- }
3479
- }, onReceipt: () => {
3480
- if (deliveryJob)
3481
- this.store.recordGoDeliveryReceipt(deliveryJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
3482
- } }, async () => {
3483
- if (!item.id || !this.store.claimDelivery(item.id))
3484
- return;
3485
- let failureStage = "text";
3486
- try {
3487
- this.store.flushKnownDeliveryReceipts(item.id);
3488
- this.store.registerPartialAttachmentContext(item.id);
3489
- const job = deliveryJob;
3490
- const provenance = JSON.parse(item.deliveryMetaJson || "{}").provenance;
3491
- const goSource = `outbox:${item.id}`;
3492
- const eligible = job && ["assistant_visible", "verbose_transcript", "run_error", "provider_error", "delayed_error", "proactive", "go_executor_error"].some(k => item.sourceType.startsWith(k));
3493
- if (eligible)
3494
- this.store.recordGoVisibleEvidence(job.id, goSource, { sessionId: provenance?.sessionId ?? "",
3495
- runId: provenance?.runId ?? "", kind: item.sourceType, text: item.content, attachments: JSON.parse(item.attachmentsJson || "[]") });
3496
- const attachments = JSON.parse(item.attachmentsJson || "[]");
3497
- // Final text is always sent as a NEW message. Never patch it into the
3498
- // older live-status card: user messages inserted during the run must
3499
- // stay above the eventual final answer, not below it.
3500
- if (item.content.trim() && !item.textDelivered) {
3501
- const replyTarget = item.replyToMessageId || replyToMessageId;
3502
- const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
3503
- || item.sourceType === "verbose_transcript"
3504
- || item.sourceType === "provider_error"
3505
- || item.sourceType === "delayed_error"
3506
- || item.sourceType === "wait_paused");
3507
- let finalModel;
3607
+ this.inFlightDeliveryWork.add(item.id);
3608
+ try {
3609
+ await this.sendOrdered(chatId, () => {
3610
+ const work = this.goDeliveryReceipts.run({ receipts, goId: deliveryJob?.id, outboxId: item.id, chatId, stage: "body", ordinals: new Map(), effectGuard: () => {
3611
+ const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
3612
+ if (!this.insertionNoticeCurrent(chatId, meta.insertionNotice))
3613
+ throw Object.assign(new Error('Insertion notice no longer current'), { deliveryStopped: true });
3614
+ if ((p?.final || item.sourceType === 'run_error') &&
3615
+ (typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
3616
+ p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
3617
+ throw Object.assign(new Error('Delivery revoked by explicit stop before platform attempt'), { deliveryStopped: true });
3618
+ }
3619
+ }, onReceipt: () => {
3620
+ if (deliveryJob)
3621
+ this.store.recordGoDeliveryReceipt(deliveryJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
3622
+ } }, async () => {
3623
+ if (!item.id || !this.store.claimDelivery(item.id))
3624
+ return;
3625
+ let failureStage = "text";
3508
3626
  try {
3509
- const meta = JSON.parse(item.deliveryMetaJson || "{}");
3510
- finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
3511
- }
3512
- catch { /* legacy/malformed metadata: send without model footer */ }
3513
- const needsStatus = Boolean(finalModel) || item.sourceType.startsWith("assistant_visible") || ["provider_error", "delayed_error", "wait_paused"].includes(item.sourceType);
3514
- const finalStatus = needsStatus ? formatSessionFooter(await this.readSessionStatus(chatId)) : undefined;
3515
- if (needsStatus && !finalModel)
3516
- finalModel = this.config.model;
3517
- if (item.sourceType === "run_error") {
3518
- const meta = JSON.parse(item.deliveryMetaJson || "{}");
3519
- const record = this.store.getErrorNotice(this.config.name, chatId, meta.errorNoticeId || "");
3520
- const verdict = record ? await this.inspectErrorNotice(record) : { state: "pending", reason: "missing_persisted_provenance" };
3521
- if (verdict.state !== "terminal" || verdict.terminalKey !== item.deliveryKey || verdict.text !== item.content) {
3522
- this.store.withdrawErrorDelivery(item.id);
3523
- if (record) {
3524
- if (verdict.state === "pending" || verdict.state === "terminal")
3525
- this.deferErrorNotice(record, { ...verdict, state: "pending" });
3526
- else {
3527
- record.status = verdict.state;
3528
- record.verdict = verdict;
3529
- this.store.saveErrorNotice(record);
3627
+ this.store.flushKnownDeliveryReceipts(item.id);
3628
+ this.store.registerPartialAttachmentContext(item.id);
3629
+ const job = deliveryJob;
3630
+ const provenance = JSON.parse(item.deliveryMetaJson || "{}").provenance;
3631
+ const goSource = `outbox:${item.id}`;
3632
+ const eligible = job && ["assistant_visible", "verbose_transcript", "run_error", "provider_error", "delayed_error", "proactive", "go_executor_error"].some(k => item.sourceType.startsWith(k));
3633
+ if (eligible)
3634
+ this.store.recordGoVisibleEvidence(job.id, goSource, { sessionId: provenance?.sessionId ?? "",
3635
+ runId: provenance?.runId ?? "", kind: item.sourceType, text: item.content, attachments: JSON.parse(item.attachmentsJson || "[]") });
3636
+ const attachments = JSON.parse(item.attachmentsJson || "[]");
3637
+ // Final text is always sent as a NEW message. Never patch it into the
3638
+ // older live-status card: user messages inserted during the run must
3639
+ // stay above the eventual final answer, not below it.
3640
+ if (item.content.trim() && !item.textDelivered) {
3641
+ const replyTarget = item.replyToMessageId || replyToMessageId;
3642
+ const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
3643
+ || item.sourceType === "verbose_transcript"
3644
+ || item.sourceType === "provider_error"
3645
+ || item.sourceType === "delayed_error"
3646
+ || item.sourceType === "wait_paused");
3647
+ let finalModel;
3648
+ try {
3649
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
3650
+ finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
3651
+ }
3652
+ catch { /* legacy/malformed metadata: send without model footer */ }
3653
+ const needsStatus = Boolean(finalModel) || item.sourceType.startsWith("assistant_visible") || ["provider_error", "delayed_error", "wait_paused"].includes(item.sourceType);
3654
+ const finalStatus = needsStatus ? formatSessionFooter(await this.readSessionStatus(chatId)) : undefined;
3655
+ if (needsStatus && !finalModel)
3656
+ finalModel = this.config.model;
3657
+ if (item.sourceType === "run_error") {
3658
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
3659
+ const record = this.store.getErrorNotice(this.config.name, chatId, meta.errorNoticeId || "");
3660
+ const verdict = record ? await this.inspectErrorNotice(record) : { state: "pending", reason: "missing_persisted_provenance" };
3661
+ if (verdict.state !== "terminal" || verdict.terminalKey !== item.deliveryKey || verdict.text !== item.content) {
3662
+ this.store.withdrawErrorDelivery(item.id);
3663
+ if (record) {
3664
+ if (verdict.state === "pending" || verdict.state === "terminal")
3665
+ this.deferErrorNotice(record, { ...verdict, state: "pending" });
3666
+ else {
3667
+ record.status = verdict.state;
3668
+ record.verdict = verdict;
3669
+ this.store.saveErrorNotice(record);
3670
+ }
3671
+ }
3672
+ return;
3530
3673
  }
3674
+ record.verdict = verdict;
3675
+ this.store.saveErrorNotice(record);
3531
3676
  }
3532
- return;
3677
+ if (item.sourceType === "run_error") {
3678
+ // Exactly one platform operation per guarded attempt. A card/text
3679
+ // fallback must not bypass a fresh finality check.
3680
+ await this.sendErrorNotice(chatId, item.content);
3681
+ }
3682
+ else if (shouldReplyToSource) {
3683
+ try {
3684
+ await this.replyFinalMessage(replyTarget, item.content, finalModel, finalStatus);
3685
+ }
3686
+ catch {
3687
+ await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
3688
+ }
3689
+ }
3690
+ else {
3691
+ await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
3692
+ }
3693
+ const bodyReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === 'body');
3694
+ if (!bodyReceipts.length)
3695
+ throw new Error('No confirmed body receipt');
3696
+ this.store.registerPartialBodyContext(item.id, true);
3697
+ this.store.confirmDeliveryStage(item.id, 'body', item.content, bodyReceipts, 'text');
3698
+ if (item.deliveryMode === 'patch_live_status' && item.targetMessageId)
3699
+ this.store.markDeliveryCleanupPending(item.id, true);
3700
+ }
3701
+ // Repair a checkpoint/context gap without resending. Legacy rows
3702
+ // lacking actual receipts are exposed through the explicit audit API.
3703
+ if (item.textDelivered) {
3704
+ const bodyReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === 'body');
3705
+ if (bodyReceipts.length)
3706
+ this.store.confirmDeliveryStage(item.id, 'body', item.content, bodyReceipts, 'text');
3707
+ }
3708
+ // Attachments are independent from status-card cleanup. An unpatchable
3709
+ // or deleted status card must never block otherwise-sendable files.
3710
+ failureStage = "attachment";
3711
+ const cursor = Math.max(0, item.attachmentCursor || 0);
3712
+ for (let i = cursor; i < attachments.length; i++) {
3713
+ const context = this.goDeliveryReceipts.getStore();
3714
+ if (context)
3715
+ context.stage = `attachment:${i}`;
3716
+ await this.sendBridgeAttachment(chatId, attachments[i]);
3717
+ const stage = `attachment:${i}`;
3718
+ const attachmentReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === stage);
3719
+ if (!attachmentReceipts.length)
3720
+ throw new Error('No confirmed attachment receipt');
3721
+ const visiblePayload = attachmentReceipts.map(r => {
3722
+ try {
3723
+ const payload = JSON.parse(r.payload?.content || '{}');
3724
+ return payload.text || JSON.stringify(payload);
3725
+ }
3726
+ catch {
3727
+ return '';
3728
+ }
3729
+ }).filter(Boolean).join('\n');
3730
+ const description = `[Attachment: ${attachments[i].type || 'file'} ${attachments[i].path}]${attachments[i].caption ? ' ' + attachments[i].caption : ''}\n${visiblePayload}`;
3731
+ this.store.confirmDeliveryStage(item.id, stage, description, attachmentReceipts, i + 1);
3732
+ }
3733
+ // Cleanup last, after all payload is visible. Cleanup-only rows and
3734
+ // attachment-only finals enter here as a separately retryable stage.
3735
+ const refreshedBeforeCleanup = this.store.getDeliveryByKey(item.botName, item.chatId, item.deliveryKey);
3736
+ const needsCleanup = Boolean(refreshedBeforeCleanup?.cleanupPending)
3737
+ || (!item.content.trim() && item.deliveryMode === "patch_live_status" && Boolean(item.targetMessageId));
3738
+ if (needsCleanup && item.targetMessageId) {
3739
+ failureStage = "cleanup";
3740
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
3741
+ this.assertSendEffectLease(chatId);
3742
+ this.goDeliveryReceipts.getStore()?.effectGuard?.();
3743
+ await this.patchLiveStatusDoneSummary(item.targetMessageId, meta, chatId);
3744
+ this.store.markDeliveryCleanupPending(item.id, false);
3745
+ }
3746
+ if (eligible && receipts.length) {
3747
+ this.store.recordGoDeliveryReceipt(job.id, goSource, { messages: receipts, outboxId: item.id });
3748
+ if (provenance?.runId && provenance?.sessionId === job.executorSessionId) {
3749
+ const kind = ["run_error", "provider_error", "delayed_error", "go_executor_error"].some(k => item.sourceType.startsWith(k)) ? "run_error" : "assistant_reply";
3750
+ const evidence = JSON.stringify({ text: item.content, attachments, receipt: receipts });
3751
+ if (ownerId)
3752
+ this.goController?.notifyExactRunOutcome(job.id, kind, goSource, evidence, provenance.sessionId, provenance.runId);
3753
+ else
3754
+ this.goController?.notifyRunOutcome(chatId, kind, goSource, evidence);
3755
+ }
3756
+ }
3757
+ this.store.markDeliveryDelivered(item.id);
3758
+ this.settleConfirmedDelivery(chatId, item);
3759
+ const settlementCurrent = !this.isDeliverySettlementRevoked(chatId, item.deliveryMetaJson);
3760
+ if (settlementCurrent && item.sourceType === 'run_error' && provenance?.terminalEvidence?.state === 'terminal' && provenance.runId && !provenance.runId.startsWith('announce:')) {
3761
+ this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3762
+ if (provenance.sessionKey)
3763
+ this.store.finishRunObservation(provenance.sessionKey, provenance.runId);
3764
+ void this.reconcileReactions(chatId).catch(() => { });
3765
+ }
3766
+ if (settlementCurrent && provenance?.final && provenance.runId) {
3767
+ this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3768
+ void this.reconcileReactions(chatId).catch(() => { });
3533
3769
  }
3534
- record.verdict = verdict;
3535
- this.store.saveErrorNotice(record);
3536
- }
3537
- if (item.sourceType === "run_error") {
3538
- // Exactly one platform operation per guarded attempt. A card/text
3539
- // fallback must not bypass a fresh finality check.
3540
- await this.sendErrorNotice(chatId, item.content);
3541
3770
  }
3542
- else if (shouldReplyToSource) {
3771
+ catch (err) {
3772
+ if (failureStage === 'text') {
3773
+ try {
3774
+ this.store.registerPartialBodyContext(item.id);
3775
+ }
3776
+ catch { /* Durable operation receipts retain recoverable context custody. */ }
3777
+ }
3543
3778
  try {
3544
- await this.replyFinalMessage(replyTarget, item.content, finalModel, finalStatus);
3779
+ this.store.registerPartialAttachmentContext(item.id);
3545
3780
  }
3546
- catch {
3547
- await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
3781
+ catch { /* Confirmed payload remains in receipt custody. */ }
3782
+ const activeJob = deliveryJob;
3783
+ if (activeJob && receipts.length)
3784
+ this.store.recordGoDeliveryReceipt(activeJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
3785
+ if (this.isOutOfChatError(err))
3786
+ this.markCurrentBotUnavailable(chatId, err);
3787
+ if (err?.deliveryStopped) {
3788
+ this.store.markDeliveryFailed(item.id);
3789
+ return;
3548
3790
  }
3549
- }
3550
- else {
3551
- await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
3552
- }
3553
- const bodyReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === 'body');
3554
- if (!bodyReceipts.length)
3555
- throw new Error('No confirmed body receipt');
3556
- this.store.registerPartialBodyContext(item.id, true);
3557
- this.store.confirmDeliveryStage(item.id, 'body', item.content, bodyReceipts, 'text');
3558
- if (item.deliveryMode === 'patch_live_status' && item.targetMessageId)
3559
- this.store.markDeliveryCleanupPending(item.id, true);
3560
- }
3561
- // Repair a checkpoint/context gap without resending. Legacy rows
3562
- // lacking actual receipts are exposed through the explicit audit API.
3563
- if (item.textDelivered) {
3564
- const bodyReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === 'body');
3565
- if (bodyReceipts.length)
3566
- this.store.confirmDeliveryStage(item.id, 'body', item.content, bodyReceipts, 'text');
3567
- }
3568
- // Attachments are independent from status-card cleanup. An unpatchable
3569
- // or deleted status card must never block otherwise-sendable files.
3570
- failureStage = "attachment";
3571
- const cursor = Math.max(0, item.attachmentCursor || 0);
3572
- for (let i = cursor; i < attachments.length; i++) {
3573
- const context = this.goDeliveryReceipts.getStore();
3574
- if (context)
3575
- context.stage = `attachment:${i}`;
3576
- await this.sendBridgeAttachment(chatId, attachments[i]);
3577
- const stage = `attachment:${i}`;
3578
- const attachmentReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === stage);
3579
- if (!attachmentReceipts.length)
3580
- throw new Error('No confirmed attachment receipt');
3581
- const visiblePayload = attachmentReceipts.map(r => {
3791
+ const localFailure = String(err?.code || '').startsWith('SQLITE_') || this.goDeliveryReceipts.getStore()?.persistenceError === err;
3792
+ if (err instanceof SendDeadlineError) {
3793
+ this.retainLocalDelivery(chatId, item.id, this.deliveryDeadlineReason(item.id));
3794
+ retryScheduled = true;
3795
+ return;
3796
+ }
3797
+ if (localFailure || this.store.hasUnknownDeliveryOperation(item.id)) {
3798
+ this.retainLocalDelivery(chatId, item.id, localFailure ? 'local_checkpoint' : 'unknown_requires_audit');
3799
+ retryScheduled = true;
3800
+ return;
3801
+ }
3802
+ const willRetry = this.store.retryDelivery(item.id, DELIVERY_MAX_ATTEMPTS);
3803
+ if (willRetry) {
3804
+ retryScheduled = true;
3805
+ console.warn(`[${this.config.name}] delivery ${item.id} ${failureStage} failed (attempt ${item.attempts + 1}/${DELIVERY_MAX_ATTEMPTS}); retry scheduled:`, this.errorSummary(err));
3806
+ this.scheduleDeliveryRetry(chatId, item.attempts + 1);
3807
+ return;
3808
+ }
3809
+ // Cleanup is cosmetic once text/attachments are visible. Exhausting
3810
+ // cleanup retries must not turn a successful answer into a scary
3811
+ // user-facing failure.
3812
+ if (failureStage === "cleanup") {
3813
+ this.store.markDeliveryDelivered(item.id);
3814
+ console.warn(`[${this.config.name}] status-card cleanup abandoned after ${DELIVERY_MAX_ATTEMPTS} attempts:`, this.errorSummary(err));
3815
+ return;
3816
+ }
3817
+ this.store.markDeliveryFailed(item.id);
3818
+ if (item.sourceType === "run_error")
3819
+ return;
3820
+ const label = failureStage === "attachment" ? "附件发送失败" : "最终回复发送失败";
3821
+ const errorText = `⚠️ ${label}(已重试 ${DELIVERY_MAX_ATTEMPTS} 次):${this.errorSummary(err)}`;
3822
+ const replyTarget = item.replyToMessageId || replyToMessageId;
3582
3823
  try {
3583
- const payload = JSON.parse(r.payload?.content || '{}');
3584
- return payload.text || JSON.stringify(payload);
3824
+ if (replyTarget)
3825
+ await this.replyMessage(replyTarget, errorText);
3826
+ else
3827
+ await this.sendMessage(chatId, errorText);
3585
3828
  }
3586
- catch {
3587
- return '';
3829
+ catch (notifyErr) {
3830
+ console.warn(`[${this.config.name}] Failed to notify terminal delivery error:`, this.errorSummary(notifyErr));
3588
3831
  }
3589
- }).filter(Boolean).join('\n');
3590
- const description = `[Attachment: ${attachments[i].type || 'file'} ${attachments[i].path}]${attachments[i].caption ? ' ' + attachments[i].caption : ''}\n${visiblePayload}`;
3591
- this.store.confirmDeliveryStage(item.id, stage, description, attachmentReceipts, i + 1);
3592
- }
3593
- // Cleanup last, after all payload is visible. Cleanup-only rows and
3594
- // attachment-only finals enter here as a separately retryable stage.
3595
- const refreshedBeforeCleanup = this.store.getDeliveryByKey(item.botName, item.chatId, item.deliveryKey);
3596
- const needsCleanup = Boolean(refreshedBeforeCleanup?.cleanupPending)
3597
- || (!item.content.trim() && item.deliveryMode === "patch_live_status" && Boolean(item.targetMessageId));
3598
- if (needsCleanup && item.targetMessageId) {
3599
- failureStage = "cleanup";
3600
- const meta = JSON.parse(item.deliveryMetaJson || "{}");
3601
- await this.patchLiveStatusDoneSummary(item.targetMessageId, meta, chatId);
3602
- this.store.markDeliveryCleanupPending(item.id, false);
3603
- }
3604
- if (eligible && receipts.length) {
3605
- this.store.recordGoDeliveryReceipt(job.id, goSource, { messages: receipts, outboxId: item.id });
3606
- if (provenance?.runId && provenance?.sessionId === job.executorSessionId) {
3607
- const kind = ["run_error", "provider_error", "delayed_error", "go_executor_error"].some(k => item.sourceType.startsWith(k)) ? "run_error" : "assistant_reply";
3608
- const evidence = JSON.stringify({ text: item.content, attachments, receipt: receipts });
3609
- if (ownerId)
3610
- this.goController?.notifyExactRunOutcome(job.id, kind, goSource, evidence, provenance.sessionId, provenance.runId);
3611
- else
3612
- this.goController?.notifyRunOutcome(chatId, kind, goSource, evidence);
3613
- }
3614
- }
3615
- this.store.markDeliveryDelivered(item.id);
3616
- if (item.sourceType === 'run_error' && provenance?.terminalEvidence?.state === 'terminal' && provenance.runId && !provenance.runId.startsWith('announce:')) {
3617
- this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3618
- if (provenance.sessionKey)
3619
- this.store.finishRunObservation(provenance.sessionKey, provenance.runId);
3620
- void this.reconcileReactions(chatId).catch(() => { });
3621
- }
3622
- if (provenance?.final && provenance.runId) {
3623
- this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3624
- void this.reconcileReactions(chatId).catch(() => { });
3625
- }
3626
- }
3627
- catch (err) {
3628
- if (failureStage === 'text') {
3629
- try {
3630
- this.store.registerPartialBodyContext(item.id);
3631
- }
3632
- catch { /* Durable operation receipts retain recoverable context custody. */ }
3633
- }
3634
- try {
3635
- this.store.registerPartialAttachmentContext(item.id);
3636
- }
3637
- catch { /* Confirmed payload remains in receipt custody. */ }
3638
- const activeJob = deliveryJob;
3639
- if (activeJob && receipts.length)
3640
- this.store.recordGoDeliveryReceipt(activeJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
3641
- if (this.isOutOfChatError(err))
3642
- this.markCurrentBotUnavailable(chatId, err);
3643
- if (err?.deliveryStopped) {
3644
- this.store.markDeliveryFailed(item.id);
3645
- return;
3646
- }
3647
- const localFailure = String(err?.code || '').startsWith('SQLITE_');
3648
- if (localFailure || this.store.hasUnknownDeliveryOperation(item.id)) {
3649
- let custody = this.localDeliveryRetryCustody.get(chatId);
3650
- if (!custody) {
3651
- custody = new Set();
3652
- this.localDeliveryRetryCustody.set(chatId, custody);
3653
3832
  }
3654
- custody.add(item.id);
3655
- let failures = 10;
3656
- try {
3657
- failures = this.store.deferLocalDelivery(item.id, localFailure ? 'local_checkpoint' : 'unknown_requires_audit');
3658
- custody.delete(item.id);
3659
- }
3660
- catch { /* Retry custody remains live even if SQLite cannot record it yet. */ }
3661
- retryScheduled = true;
3662
- this.scheduleDeliveryRetry(chatId, failures, true);
3663
- return;
3664
- }
3665
- const willRetry = this.store.retryDelivery(item.id, DELIVERY_MAX_ATTEMPTS);
3666
- if (willRetry) {
3667
- retryScheduled = true;
3668
- console.warn(`[${this.config.name}] delivery ${item.id} ${failureStage} failed (attempt ${item.attempts + 1}/${DELIVERY_MAX_ATTEMPTS}); retry scheduled:`, this.errorSummary(err));
3669
- this.scheduleDeliveryRetry(chatId, item.attempts + 1);
3670
- return;
3671
- }
3672
- // Cleanup is cosmetic once text/attachments are visible. Exhausting
3673
- // cleanup retries must not turn a successful answer into a scary
3674
- // user-facing failure.
3675
- if (failureStage === "cleanup") {
3676
- this.store.markDeliveryDelivered(item.id);
3677
- console.warn(`[${this.config.name}] status-card cleanup abandoned after ${DELIVERY_MAX_ATTEMPTS} attempts:`, this.errorSummary(err));
3678
- return;
3679
- }
3680
- this.store.markDeliveryFailed(item.id);
3681
- if (item.sourceType === "run_error")
3682
- return;
3683
- const label = failureStage === "attachment" ? "附件发送失败" : "最终回复发送失败";
3684
- const errorText = `⚠️ ${label}(已重试 ${DELIVERY_MAX_ATTEMPTS} 次):${this.errorSummary(err)}`;
3685
- const replyTarget = item.replyToMessageId || replyToMessageId;
3686
- try {
3687
- if (replyTarget)
3688
- await this.replyMessage(replyTarget, errorText);
3689
- else
3690
- await this.sendMessage(chatId, errorText);
3691
- }
3692
- catch (notifyErr) {
3693
- console.warn(`[${this.config.name}] Failed to notify terminal delivery error:`, this.errorSummary(notifyErr));
3694
- }
3833
+ });
3834
+ return work.finally(() => { this.inFlightDeliveryWork.delete(item.id); });
3835
+ });
3836
+ }
3837
+ catch (err) {
3838
+ if (!(err instanceof SendDeadlineError)) {
3839
+ this.inFlightDeliveryWork.delete(item.id);
3840
+ throw err;
3695
3841
  }
3696
- }));
3842
+ // The old promise is still alive. Reconciliation must not claim this
3843
+ // outbox again until it settles; attempted unknown stays no-resend.
3844
+ this.retainLocalDelivery(chatId, item.id, this.deliveryDeadlineReason(item.id));
3845
+ retryScheduled = true;
3846
+ }
3697
3847
  }
3698
3848
  // Drain subsequent pages immediately after successful batches. If any row
3699
3849
  // was returned to pending, honor its backoff timer instead of hot-looping.
@@ -3797,8 +3947,7 @@ export class FeishuBot {
3797
3947
  const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
3798
3948
  const liveStatus = liveStatusEnabled
3799
3949
  ? new LiveStatusController({
3800
- create: (view) => this.sendOrdered(chatId, () => this.sendLiveStatusCard(chatId, view)),
3801
- edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
3950
+ ...this.liveStatusCallbacks(chatId),
3802
3951
  warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
3803
3952
  }, {
3804
3953
  botName: this.config.name,
@@ -3862,12 +4011,16 @@ export class FeishuBot {
3862
4011
  const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
3863
4012
  displayReply = `${displayReply}\n\n${roundMarker}`;
3864
4013
  }
4014
+ let discussionDeliveryConfirmed = false;
3865
4015
  if (isVisible || parsedReply.attachments.length > 0) {
3866
4016
  const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
3867
4017
  .filter(Boolean)
3868
4018
  .join("\n");
3869
4019
  try {
3870
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
4020
+ const outcome = await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
4021
+ discussionDeliveryConfirmed = outcome.state === 'delivered';
4022
+ if (!discussionDeliveryConfirmed)
4023
+ await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;原投递仍受保管").catch(() => { });
3871
4024
  }
3872
4025
  catch (err) {
3873
4026
  await liveStatus?.fail().catch(() => { });
@@ -3877,12 +4030,15 @@ export class FeishuBot {
3877
4030
  // Mirror the normal path: a NO_REPLY/empty discussion turn should finish the
3878
4031
  // status card with a "no content" summary, not a plain done summary.
3879
4032
  if (!isVisible && parsedReply.attachments.length === 0) {
3880
- await liveStatus?.noReply().catch(() => { });
4033
+ if (rawVisibleReply.toUpperCase() === 'NO_REPLY')
4034
+ await liveStatus?.noReply().catch(() => { });
4035
+ else
4036
+ await liveStatus?.pauseForUnconfirmedResult("尚未确认最终回复").catch(() => { });
3881
4037
  }
3882
- else {
4038
+ else if (discussionDeliveryConfirmed) {
3883
4039
  await liveStatus?.complete().catch(() => { });
3884
4040
  }
3885
- return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
4041
+ return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible && discussionDeliveryConfirmed };
3886
4042
  }
3887
4043
  buildLiveStatusCard(view, chatId) {
3888
4044
  const en = this.isEn(chatId);
@@ -3925,7 +4081,7 @@ export class FeishuBot {
3925
4081
  elements.push({ tag: "markdown", content });
3926
4082
  }
3927
4083
  else {
3928
- elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
4084
+ elements.push({ tag: "markdown", content: view.state === "queued" ? (en ? "_Received locally; waiting for admission_" : "_本地已收件,等待提交确认_") : view.state === "admitted" ? (en ? "_Accepted; waiting for activity_" : "_已接收,等待活动_") : (en ? "_working…_" : "_正在启动…_") });
3929
4085
  }
3930
4086
  elements.push({ tag: "hr" });
3931
4087
  // Keep the active model visible while the run is in progress or failed.
@@ -3955,6 +4111,31 @@ export class FeishuBot {
3955
4111
  escapeCardText(text) {
3956
4112
  return String(text || "").replace(/</g, "\uff1c").replace(/>/g, "\uff1e");
3957
4113
  }
4114
+ /** Keep receipt ownership below sendOrdered's Promise.race. Nothing here
4115
+ * changes business-message/outbox ordering or retries. */
4116
+ liveStatusCallbacks(chatId, replyTo) {
4117
+ const send = async (fn) => {
4118
+ try {
4119
+ return await fn();
4120
+ }
4121
+ catch (err) {
4122
+ if (err instanceof LiveStatusEffectRejectedError)
4123
+ throw err;
4124
+ const code = err?.response?.data?.code ?? err?.data?.code;
4125
+ if (typeof code === "number" && code !== 0)
4126
+ throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${code}`);
4127
+ // Network/SDK failures may follow an accepted effect. They are NOT
4128
+ // eligible for automatic retry, unlike a definitive platform rejection.
4129
+ throw new LiveStatusEffectUnknownError(err);
4130
+ }
4131
+ };
4132
+ return {
4133
+ create: (view, effect) => this.sendOrdered(chatId, () => effect.run(() => send(() => replyTo !== undefined
4134
+ ? this.replyLiveStatusCard(replyTo, view, chatId)
4135
+ : this.sendLiveStatusCard(chatId, view)))),
4136
+ edit: (id, view, effect) => this.sendOrdered(chatId, () => effect.run(() => send(() => this.patchLiveStatusCard(id, view, chatId)))),
4137
+ };
4138
+ }
3958
4139
  async sendLiveStatusCard(chatId, view) {
3959
4140
  try {
3960
4141
  const res = await this.client.im.message.create({
@@ -3965,6 +4146,10 @@ export class FeishuBot {
3965
4146
  msg_type: "interactive",
3966
4147
  },
3967
4148
  });
4149
+ if (typeof res.code === "number" && res.code !== 0)
4150
+ throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4151
+ if (res.code !== 0)
4152
+ throw new LiveStatusEffectUnknownError(new Error("Feishu card receipt missing code"));
3968
4153
  this.store.clearBotUnavailableInChat(this.config.name, chatId);
3969
4154
  return res?.data?.message_id || res?.message_id;
3970
4155
  }
@@ -3982,13 +4167,21 @@ export class FeishuBot {
3982
4167
  msg_type: "interactive",
3983
4168
  },
3984
4169
  });
4170
+ if (typeof res.code === "number" && res.code !== 0)
4171
+ throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4172
+ if (res.code !== 0)
4173
+ throw new LiveStatusEffectUnknownError(new Error("Feishu card receipt missing code"));
3985
4174
  return res?.data?.message_id || res?.message_id;
3986
4175
  }
3987
4176
  async patchLiveStatusCard(messageId, view, chatId) {
3988
- await this.client.im.message.patch({
4177
+ const res = await this.client.im.message.patch({
3989
4178
  path: { message_id: messageId },
3990
4179
  data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
3991
4180
  });
4181
+ if (typeof res.code === "number" && res.code !== 0)
4182
+ throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4183
+ if (res.code !== 0)
4184
+ throw new LiveStatusEffectUnknownError(new Error("Feishu card receipt missing code"));
3992
4185
  }
3993
4186
  /**
3994
4187
  * Render the /compact progress card. While running it shows a phase line plus a
@@ -4645,8 +4838,10 @@ export class FeishuBot {
4645
4838
  * The chain must never wedge: a single hung Feishu call used to block every
4646
4839
  * later send in that chat forever (live-status patches fire per tool event),
4647
4840
  * which froze cards and stranded /stop confirmations. Each link is therefore
4648
- * raced against a deadline, so a stuck send is abandoned and ordering resumes.
4649
- * Prefer a larger OPENCLAW_LARK_MULTI_AGENT_SEND_TIMEOUT_MS on slow links.
4841
+ * raced against an observation deadline. The queue advances, but an already
4842
+ * attempted SDK request is NOT cancelled and can settle late. Its outbox keeps
4843
+ * custody; the expired lease forbids any subsequent new SDK effect. This is
4844
+ * not a strict platform-order guarantee for uncancellable in-flight requests.
4650
4845
  */
4651
4846
  sendOrdered(chatId, fn) {
4652
4847
  const prev = this.sendQueue.get(chatId) || Promise.resolve();
@@ -4658,9 +4853,10 @@ export class FeishuBot {
4658
4853
  /** One send link: resolve with fn(), or reject at the deadline so the chain advances. */
4659
4854
  async withSendDeadline(fn, chatId) {
4660
4855
  const limit = SEND_DEADLINE_MS;
4856
+ const lease = { expired: false };
4661
4857
  let work;
4662
4858
  try {
4663
- work = Promise.resolve(fn());
4859
+ work = this.sendEffectLeases.run(lease, () => Promise.resolve(fn()));
4664
4860
  }
4665
4861
  catch (err) {
4666
4862
  return Promise.reject(err);
@@ -4670,7 +4866,7 @@ export class FeishuBot {
4670
4866
  work.catch(() => { });
4671
4867
  let timer;
4672
4868
  const deadline = new Promise((_, reject) => {
4673
- timer = setTimeout(() => reject(new Error(`send deadline ${limit}ms exceeded for ${chatId.slice(-8)}`)), limit);
4869
+ timer = setTimeout(() => { lease.expired = true; reject(new SendDeadlineError(chatId, limit)); }, limit);
4674
4870
  timer.unref?.();
4675
4871
  });
4676
4872
  try {
@@ -5156,6 +5352,7 @@ export class FeishuBot {
5156
5352
  // supervision instead of letting the checker judge a wiped transcript.
5157
5353
  void this.goController?.onExecutorSessionChanged(chatId, "执行会话被 /reset");
5158
5354
  this.stopEpoch.set(chatId, this.store.bumpStopGeneration(this.config.name, chatId));
5355
+ this.presentation(() => this.queuedPresentations.retire(chatId));
5159
5356
  this.store.stopInsertions(this.config.name, chatId);
5160
5357
  this.store.cancelReactionIntents(this.config.name, chatId, this.stopGeneration(chatId));
5161
5358
  void this.openclawClient.cancelSteerSession?.(sessionKey, this.stopGeneration(chatId)).catch(() => { });
@@ -5189,6 +5386,7 @@ export class FeishuBot {
5189
5386
  // Bump the stop generation FIRST so any in-flight auto-retry loop sees the
5190
5387
  // cancellation on its next iteration and stops retrying immediately.
5191
5388
  this.stopEpoch.set(chatId, this.store.bumpStopGeneration(this.config.name, chatId));
5389
+ this.presentation(() => this.queuedPresentations.retire(chatId));
5192
5390
  this.store.cancelReactionIntents(this.config.name, chatId, this.stopGeneration(chatId));
5193
5391
  void this.reconcileReactions(chatId).catch(() => { });
5194
5392
  this.store.stopInsertions(this.config.name, chatId);