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.
- package/dist/feishu-bot.d.ts +31 -9
- package/dist/feishu-bot.js +525 -327
- package/dist/live-status.d.ts +85 -15
- package/dist/live-status.js +395 -89
- package/dist/message-store.d.ts +20 -0
- package/dist/message-store.js +88 -10
- package/dist/openclaw-client.d.ts +39 -11
- package/dist/openclaw-client.js +520 -407
- package/dist/queued-presentation.d.ts +52 -0
- package/dist/queued-presentation.js +471 -0
- package/dist/session-status.d.ts +1 -0
- package/dist/session-status.js +4 -0
- package/docs/releases/1.8.15.md +28 -0
- package/package.json +4 -3
package/dist/feishu-bot.js
CHANGED
|
@@ -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
|
-
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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.
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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 &&
|
|
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
|
-
|
|
1597
|
+
await this.openclawClient.exactRunSettled?.(item.expectedRunId);
|
|
1542
1598
|
if (!stillCurrent())
|
|
1543
1599
|
continue;
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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:
|
|
1875
|
-
//
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
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
|
-
//
|
|
2379
|
-
//
|
|
2380
|
-
//
|
|
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 (
|
|
2389
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
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
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
const
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3779
|
+
this.store.registerPartialAttachmentContext(item.id);
|
|
3545
3780
|
}
|
|
3546
|
-
catch {
|
|
3547
|
-
|
|
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
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
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
|
-
|
|
3584
|
-
|
|
3824
|
+
if (replyTarget)
|
|
3825
|
+
await this.replyMessage(replyTarget, errorText);
|
|
3826
|
+
else
|
|
3827
|
+
await this.sendMessage(chatId, errorText);
|
|
3585
3828
|
}
|
|
3586
|
-
catch {
|
|
3587
|
-
|
|
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
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4649
|
-
*
|
|
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
|
|
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);
|