openclaw-lark-multi-agent 1.8.4 → 1.8.6

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.
@@ -1,9 +1,10 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import * as lark from "@larksuiteoapi/node-sdk";
3
4
  import { createRequire } from "module";
4
5
  import { persistBotModel } from "./config.js";
5
6
  import { getI18n, normalizeLocale } from "./i18n.js";
6
- import { InactiveRunObservation } from "./openclaw-client.js";
7
+ import { InactiveRunObservation, AdmissionUnconfirmed } from "./openclaw-client.js";
7
8
  import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
8
9
  import { SupersededObservation } from "./openclaw-client.js";
9
10
  import { LiveStatusController } from "./live-status.js";
@@ -16,6 +17,7 @@ import { basename, extname, join, resolve } from "path";
16
17
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
17
18
  import { buildFeishuCardElements } from "./markdown.js";
18
19
  import { discussionManager } from "./discussion-manager.js";
20
+ const nonemptyReceipt = (value) => typeof value === 'string' && !!value.trim();
19
21
  const require = createRequire(import.meta.url);
20
22
  const LMA_VERSION = require("../package.json").version;
21
23
  const MAX_BOT_STREAK = 10;
@@ -130,6 +132,7 @@ export class FeishuBot {
130
132
  recentVerboseToolMessages = new Map();
131
133
  /** Per-chat processQueue lock to avoid duplicate concurrent chat.send runs */
132
134
  queueRuns = new Map();
135
+ queueOwners = new Map();
133
136
  /** Per-chat serial send queue to guarantee message order */
134
137
  sendQueue = new Map();
135
138
  /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
@@ -167,77 +170,116 @@ export class FeishuBot {
167
170
  { api: docx?.documentBlockDescendant, operations: ["create"], visible: false },
168
171
  ];
169
172
  for (const { api, operations, visible } of candidates) {
170
- if (!api || this.goReceiptApis.has(api))
173
+ if (!api)
171
174
  continue;
172
- this.goReceiptApis.add(api);
173
175
  for (const operation of operations) {
174
- const original = api[operation]?.bind(api);
175
- if (!original)
176
+ const method = api[operation];
177
+ if (typeof method !== 'function' || this.goReceiptApis.has(method))
176
178
  continue;
177
- api[operation] = async (params) => {
178
- const context = this.goDeliveryReceipts.getStore();
179
- const ordinal = context?.ordinals.get(context.stage) ?? 0;
180
- context?.ordinals.set(context.stage, ordinal + 1);
181
- const key = `outbox:${context?.outboxId}:${context?.stage}:${ordinal}`;
182
- const previous = context?.goId ? this.store.getGoDeliveryOperation(context.goId, key) : null;
183
- if (previous?.state === "rejected") {
184
- const failure = JSON.parse(previous.response_json);
185
- throw Object.assign(new Error(failure.message), { code: failure.code, response: { data: { code: failure.code, msg: failure.message } } });
186
- }
187
- if (context?.blocked || (previous && previous.state !== "accepted")) {
188
- // Carry a durable unknown fence across *all* fallback operations after
189
- // restart too, not merely the original failed card/attachment ordinal.
190
- if (context)
191
- context.blocked = true;
192
- throw new Error("Go delivery outcome unknown; original operation cannot be replayed");
193
- }
194
- let result;
195
- if (previous?.state === "accepted")
196
- result = JSON.parse(previous.response_json).result;
197
- else {
198
- if (context?.goId)
199
- this.store.recordGoDeliveryOperation(context.goId, key, "attempted");
200
- try {
201
- result = await original(params);
179
+ const original = method.bind(api);
180
+ api[operation] = new Proxy(method, { apply: async (_target, _receiver, [params]) => {
181
+ const context = this.goDeliveryReceipts.getStore();
182
+ const ordinal = context?.ordinals.get(context.stage) ?? 0;
183
+ context?.ordinals.set(context.stage, ordinal + 1);
184
+ const key = `outbox:${context?.outboxId}:${context?.stage}:${ordinal}`;
185
+ const previous = context ? this.store.getDeliveryOperation(context.outboxId, key) : null;
186
+ const attemptRecord = { payload: params.data, operation, visible };
187
+ const save = (state, record = {}) => {
188
+ record = { ...attemptRecord, ...record };
189
+ if (context)
190
+ this.store.saveDeliveryOperation(context.outboxId, key, state, record);
191
+ if (context?.goId)
192
+ this.store.recordGoDeliveryOperation(context.goId, key, state, record);
193
+ };
194
+ if (previous?.state === 'rejected' && !previous.retryable) {
195
+ throw Object.assign(new Error(previous.message), { code: previous.code, response: { data: { code: previous.code, msg: previous.message } } });
202
196
  }
203
- catch (err) {
204
- if (context?.goId) {
205
- const error = err;
206
- const code = error?.response?.data?.code ?? error?.data?.code ?? error?.code;
207
- const status = error?.response?.status;
208
- // A documented API rejection is not a lost response. Cache it so
209
- // deterministic card/text fallback can proceed without repeating it.
210
- if (Number.isFinite(Number(code)) && Number(code) > 0 || Number.isInteger(status) && status >= 400 && status < 500) {
211
- this.store.recordGoDeliveryOperation(context.goId, key, "rejected", { code, message: error?.response?.data?.msg ?? error?.message ?? "platform rejected" });
197
+ if (previous?.state !== 'accepted' && (context?.blocked || context && this.store.hasUnknownDeliveryOperation(context.outboxId))) {
198
+ if (context?.persistenceError)
199
+ throw context.persistenceError;
200
+ if (context)
201
+ context.blocked = true;
202
+ throw new Error('Platform outcome unknown; retained without blind resend or fallback');
203
+ }
204
+ let result;
205
+ if (previous?.state === 'accepted') {
206
+ result = previous.result;
207
+ if (context) {
208
+ try {
209
+ this.store.flushKnownDeliveryReceipts(context.outboxId);
210
+ save('accepted', { ...previous, state: undefined });
212
211
  }
213
- else {
214
- this.store.recordGoDeliveryOperation(context.goId, key, "unknown");
212
+ catch (error) {
215
213
  context.blocked = true;
214
+ context.persistenceError = error;
215
+ throw error;
216
216
  }
217
217
  }
218
- throw err;
219
218
  }
220
- }
221
- const id = result?.data?.message_id ?? result?.message_id;
222
- if (visible && context?.goId && !id) {
223
- this.store.recordGoDeliveryOperation(context.goId, key, "unknown");
224
- context.blocked = true;
225
- throw new Error("Missing actual Go platform message receipt");
226
- }
227
- if (context?.goId)
228
- this.store.recordGoDeliveryOperation(context.goId, key, "accepted", { result, payload: visible ? params.data : undefined, operation });
229
- if (visible && context && typeof id === "string" && id) {
230
- if (!context.receipts.some(r => r.messageId === id))
231
- context.receipts.push({ messageId: id, operation: previous?.state === "accepted" ? JSON.parse(previous.response_json).operation : operation, payload: previous?.state === "accepted" ? JSON.parse(previous.response_json).payload : params.data });
232
- context.onReceipt(); // Persist before the next chunk or attachment operation.
233
- }
234
- return result;
235
- };
219
+ else {
220
+ context?.effectGuard?.();
221
+ if (context)
222
+ save('attempted');
223
+ try {
224
+ result = await original(params);
225
+ const code = result?.code;
226
+ if (code !== undefined && code !== 0)
227
+ throw Object.assign(new Error(result?.msg || 'Platform rejected'), { code, response: { data: result } });
228
+ const id = result?.data?.message_id ?? result?.message_id;
229
+ const target = result?.data?.chat_id;
230
+ const sender = result?.data?.sender;
231
+ if (visible && (code !== 0 || !nonemptyReceipt(id) || context?.chatId && target && target !== context.chatId ||
232
+ sender?.app_id && sender.app_id !== this.config.appId || sender?.sender_type === 'app' && sender?.id_type === 'app_id' && sender?.id && sender.id !== this.config.appId || sender?.id_type === 'open_id' && this.botOpenId && sender?.id && sender.id !== this.botOpenId)) {
233
+ if (context) {
234
+ save('unknown', { reason: 'Missing/invalid platform code, message_id or target identity' });
235
+ context.blocked = true;
236
+ }
237
+ throw new Error('Unconfirmed platform receipt; no replay');
238
+ }
239
+ }
240
+ catch (error) {
241
+ const err = error;
242
+ const code = err?.response?.data?.code ?? err?.data?.code ?? err?.code;
243
+ const status = err?.response?.status;
244
+ if (context && !context.blocked) {
245
+ if (Number.isFinite(Number(code)) && Number(code) > 0 || Number.isInteger(status) && status >= 400 && status < 500) {
246
+ save('rejected', { code, retryable: Number(code) === 99991400 || status === 429, message: err?.response?.data?.msg ?? err?.message ?? 'Platform rejected' });
247
+ }
248
+ else {
249
+ save('unknown');
250
+ context.blocked = true;
251
+ }
252
+ }
253
+ throw error;
254
+ }
255
+ if (context) {
256
+ const record = { result, ...attemptRecord };
257
+ try {
258
+ this.store.rememberKnownDeliveryReceipt(context.outboxId, key, record);
259
+ save('accepted', record);
260
+ }
261
+ catch (error) {
262
+ context.blocked = true;
263
+ context.persistenceError = error;
264
+ throw error;
265
+ }
266
+ }
267
+ }
268
+ const id = result?.data?.message_id ?? result?.message_id;
269
+ if (visible && context && nonemptyReceipt(id)) {
270
+ if (!context.receipts.some(r => r.messageId === id))
271
+ context.receipts.push({ messageId: id, operation, payload: previous?.state === 'accepted' ? previous.payload : params.data });
272
+ context.onReceipt();
273
+ }
274
+ return result;
275
+ } });
276
+ this.goReceiptApis.add(api[operation]);
236
277
  }
237
278
  }
238
279
  }
239
280
  goController = null;
240
281
  /** Per-chat durable outbox retry timer. */
282
+ localDeliveryRetryCustody = new Map();
241
283
  deliveryRetryTimers = new Map();
242
284
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
243
285
  lastRealDeliveryAt = new Map();
@@ -264,6 +306,7 @@ export class FeishuBot {
264
306
  this.config = config;
265
307
  this.openclawClient = openclawClient;
266
308
  this.store = store;
309
+ this.openclawClient.bindRecoveryStore?.(store);
267
310
  this.adminOpenId = adminOpenId || null;
268
311
  this.locale = normalizeLocale(config.locale);
269
312
  this.configPath = configPath;
@@ -457,7 +500,8 @@ export class FeishuBot {
457
500
  const parsed = this.extractBridgeAttachments(text);
458
501
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
459
502
  this.cancelDelayedFailure(chatId);
460
- const runTarget = meta?.runId ? this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId)) : undefined;
503
+ const persistedTarget = meta?.runId ? this.store.getDeliveryRunTarget(this.config.name, chatId, meta.runId) : null;
504
+ const runTarget = meta?.runId ? this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId)) || (persistedTarget ? { ...persistedTarget, token: Symbol(meta.runId) } : undefined) : undefined;
461
505
  const candidateTarget = this.activeDeliveryTargets.get(chatId);
462
506
  const activeTarget = runTarget || (candidateTarget && (!candidateTarget.runId || !meta?.runId || candidateTarget.runId === meta.runId)
463
507
  ? candidateTarget
@@ -479,6 +523,14 @@ export class FeishuBot {
479
523
  catch (err) {
480
524
  console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
481
525
  }
526
+ }, async (meta) => {
527
+ if (!meta.runId || this.store.isStoppedObservation(`agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}`, meta.runId))
528
+ return;
529
+ this.store.settleRunReactionIntents(this.config.name, chatId, meta.runId);
530
+ void this.reconcileReactions(chatId).catch(() => { });
531
+ const target = this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId));
532
+ await target?.liveStatus?.complete().catch(() => { });
533
+ this.releaseRunDeliveryTargetAfter(chatId, meta.runId, 30_000);
482
534
  });
483
535
  // Subscribe to tool events for verbose mode
484
536
  this.openclawClient.onToolEvent(sessionKey, async (toolName, toolInput, toolOutput) => {
@@ -553,6 +605,7 @@ export class FeishuBot {
553
605
  */
554
606
  async drainOnStartup() {
555
607
  try {
608
+ this.store.recoverUnsentReservations(this.config.name);
556
609
  const restoredDeliveries = this.store.resetDeliveringOnStartup(this.config.name);
557
610
  if (restoredDeliveries > 0) {
558
611
  console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries}`);
@@ -567,6 +620,8 @@ export class FeishuBot {
567
620
  // Re-subscribe to existing sessions
568
621
  await this.ensureSession(chat.chatId);
569
622
  drainTasks.push(this.reconcileErrorNotices(chat.chatId));
623
+ drainTasks.push(this.reconcileInsertions(chat.chatId));
624
+ this.scheduleInsertionRecovery();
570
625
  // Drain only messages that were explicitly marked as reply triggers.
571
626
  // Context-only messages should not start an OpenClaw run after restart.
572
627
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
@@ -1163,206 +1218,271 @@ export class FeishuBot {
1163
1218
  // --- Should this bot respond? ---
1164
1219
  if (!this.shouldRespond(chatType, message, isBot, chatId, routing))
1165
1220
  return;
1221
+ let reservedAtIngress = false;
1166
1222
  if (!isBot && insertedId > 0) {
1167
1223
  this.goController?.onHumanInput(chatId);
1168
1224
  this.store.markPendingTrigger(this.config.name, chatId, insertedId);
1225
+ // Reserve BEFORE the first health/reaction await. Otherwise an ending
1226
+ // owner can ordinary-drain this row while its handler is still waiting,
1227
+ // and the later steer would submit the same message a second time.
1228
+ const ingressOwner = this.queueOwners.get(chatId);
1229
+ if (this.queueRuns.has(chatId) && !ingressOwner?.triggerIds?.has(insertedId)) {
1230
+ this.store.saveInsertion(this.config.name, chatId, insertedId, JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1231
+ messageId, text: cleanText, stopEpoch: this.stopGeneration(chatId), expectedRunId: ingressOwner?.runId || '',
1232
+ ownerId: ingressOwner?.id, batchId: ingressOwner?.batchId, sessionKey: this.getSessionKey(chatId)
1233
+ });
1234
+ reservedAtIngress = true;
1235
+ }
1236
+ // Received/queued is not accepted by Gateway. Keep Typing until an
1237
+ // exact admission receipt, independently of reaction API availability.
1238
+ const acks = this.pendingAckMessages.get(chatId) || [];
1239
+ acks.push({ messageId, emoji: 'Typing', rowId: insertedId });
1240
+ this.pendingAckMessages.set(chatId, acks);
1241
+ void this.receiptReaction(chatId, insertedId, messageId, 'Typing').catch(() => { });
1169
1242
  }
1170
1243
  // Track this message for reaction status updates
1171
1244
  const pending = this.pendingAckMessages.get(chatId) || [];
1245
+ const inputStopEpoch = this.stopGeneration(chatId);
1172
1246
  const unhealthy = await this.getUnhealthySessionStatus(chatId);
1247
+ if (this.stopGeneration(chatId) !== inputStopEpoch)
1248
+ return;
1173
1249
  if (unhealthy) {
1174
1250
  await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
1175
1251
  }
1252
+ // Another ingress handler may already have selected/admitted this row
1253
+ // as an ordinary batch while this handler awaited health. Do not re-steer
1254
+ // a message that already belongs to that batch.
1255
+ if (!isBot && insertedId > 0 && !reservedAtIngress &&
1256
+ (this.queueOwners.get(chatId)?.triggerIds?.has(insertedId) ||
1257
+ !this.store.getPendingTriggerIds(this.config.name, chatId).has(insertedId)))
1258
+ return;
1176
1259
  // Anti-loop
1177
1260
  const streak = this.store.getBotStreak(chatId, this.config.name);
1178
- if (streak >= MAX_BOT_STREAK) {
1261
+ if (isBot && streak >= MAX_BOT_STREAK) {
1179
1262
  console.log(`[${this.config.name}] Anti-loop: ${streak} consecutive bot msgs`);
1180
1263
  return;
1181
1264
  }
1182
1265
  // --- Queue-based sending: if agent is busy, just accumulate ---
1183
1266
  // The actual queue owner, not elapsed wall time, decides whether input
1184
1267
  // should steer/queue. Long healthy runs must not lose their busy ownership.
1185
- const isBusy = this.queueRuns.has(chatId);
1268
+ const isBusy = reservedAtIngress || this.queueRuns.has(chatId);
1186
1269
  if (isBusy) {
1187
- // The agent is mid-run. Instead of only queuing to wait for the current
1188
- // run to finish, try to STEER this message into the active run so it is
1189
- // seen at the next tool-call boundary (lets the user nudge/correct a long
1190
- // run in real time). The lma-steer plugin reports whether it landed.
1191
- const sessionKeyForSteer = this.getSessionKey(chatId);
1192
- // Wrap the message so the agent knows it is a NEW user message inserted
1193
- // mid-task. The live-status line and reactions still key off the original.
1194
- const steerWrapped = `${steerInjectionPrefix(this.isEn(chatId))}\n${cleanText}`;
1195
- // Register the consumption observer before submitting so a fast
1196
- // session.message cannot race past the Feishu reaction transition.
1197
- await this.addReaction(messageId, "Typing").catch(() => { });
1198
- pending.push({ messageId, emoji: "Typing", rowId: insertedId });
1199
- this.pendingAckMessages.set(chatId, pending);
1200
- const norm = cleanText.replace(/\s+/g, " ").trim();
1201
- let consumedBeforeAck = false;
1202
- const stopWatchingConsumption = this.openclawClient.onSteerConsumed(sessionKeyForSteer, steerWrapped, (consumedText) => {
1203
- const cn = (consumedText || "").replace(/\s+/g, " ").trim();
1204
- if (cn !== norm && !cn.includes(norm))
1205
- return false;
1206
- consumedBeforeAck = true;
1207
- // Only transcript commitment/consumption transfers durable custody.
1208
- // Until this point the local pending trigger remains the fallback for
1209
- // detached dispatch rejection or an admission that never consumed.
1210
- if (insertedId > 0) {
1211
- this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
1212
- this.pendingSteerCleanups.delete(insertedId);
1270
+ const owner = this.queueOwners.get(chatId);
1271
+ const record = { messageId, text: cleanText, stopEpoch: this.stopGeneration(chatId),
1272
+ expectedRunId: owner?.runId || '', ownerId: owner?.id, batchId: owner?.batchId, sessionKey: this.getSessionKey(chatId) };
1273
+ if (insertedId > 0 && !this.store.getInsertion(this.config.name, chatId, insertedId))
1274
+ this.store.saveInsertion(this.config.name, chatId, insertedId, JSON.stringify([this.config.appId, chatId, messageId]), 'pending', record);
1275
+ // Reservation is durable BEFORE any RPC. Ordinary drain cannot race a
1276
+ // late ACK and execute this same input a second time.
1277
+ void this.reconcileInsertions(chatId).catch(err => console.warn('Insertion retained:', this.errorSummary(err)));
1278
+ this.scheduleInsertionRecovery();
1279
+ return;
1280
+ }
1281
+ // Not busy: admission callback, not local queueing, flips Typing -> Get.
1282
+ await this.processQueue(chatId);
1283
+ }
1284
+ catch (err) {
1285
+ console.error(`[${this.config.name}] Error:`, safeErrorSummary(err));
1286
+ }
1287
+ }
1288
+ stopGeneration(chatId) {
1289
+ return this.stopEpoch.get(chatId) ?? this.store.getStopGeneration(this.config.name, chatId);
1290
+ }
1291
+ reactionChecks = new Set();
1292
+ async receiptReaction(chatId, rowId, messageId, desired, runId = '') {
1293
+ const previous = this.store.getReactionIntent(this.config.name, chatId, rowId);
1294
+ const record = { ...previous, messageId, desired, shown: previous?.shown || '', runId: runId || previous?.runId || '', stopEpoch: this.stopGeneration(chatId) };
1295
+ this.store.setReactionIntent(this.config.name, chatId, rowId, record);
1296
+ void this.reconcileReactions(chatId).catch(() => { }).finally(() => this.scheduleInsertionRecovery());
1297
+ this.scheduleInsertionRecovery();
1298
+ }
1299
+ async reconcileReactions(chatId) {
1300
+ if (this.reactionChecks.has(chatId))
1301
+ return;
1302
+ this.reactionChecks.add(chatId);
1303
+ try {
1304
+ for (const item of this.store.listReactionIntents(this.config.name, chatId)) {
1305
+ if (item.stopEpoch !== this.stopGeneration(chatId))
1306
+ continue;
1307
+ try {
1308
+ // Orphaned late successes retain their exact own receipt until deletion.
1309
+ for (const receipt of item.cleanupReceipts || []) {
1310
+ await this.removeReaction(item.messageId, receipt.emoji, receipt.id);
1311
+ const current = this.store.getReactionIntent(this.config.name, chatId, item.rowId);
1312
+ if (current)
1313
+ this.store.setReactionIntent(this.config.name, chatId, item.rowId, { ...current, cleanupReceipts: (current.cleanupReceipts || []).filter((r) => r.id !== receipt.id) });
1213
1314
  }
1214
- void this.removeReaction(messageId, "Typing").catch(() => { });
1215
- void this.addReaction(messageId, "Get").catch(() => { });
1216
- const acks = this.pendingAckMessages.get(chatId) || [];
1217
- for (const a of acks)
1218
- if (a.messageId === messageId)
1219
- a.emoji = "Get";
1220
- this.pendingAckMessages.set(chatId, acks);
1221
- return true;
1222
- });
1223
- const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
1224
- if (steer.status === "steered") {
1225
- // OpenClaw 2026.9.9 no longer mirrors plugin-injected user messages as
1226
- // session.message events, so the transcript-consumption observer may
1227
- // never fire. Custody transfers to the active run at admission: clear
1228
- // the durable trigger now (prevents the duplicate re-send after this
1229
- // run ends) and arm an abnormal-end fallback that restores it.
1230
- if (insertedId > 0) {
1231
- // Consumption can land before admission returns (fast mirror) or
1232
- // never (2026.9.9 removed mirrors); either way the message belongs
1233
- // to this batch for the Get -> DONE flip.
1234
- const steerAckList = this.admittedSteerAcks.get(chatId) || [];
1235
- steerAckList.push({ messageId, insertedId, batch: this.queueRunTokens.get(chatId) });
1236
- this.admittedSteerAcks.set(chatId, steerAckList);
1315
+ if (item.shown) {
1316
+ await this.removeReaction(item.messageId, item.shown, item.reactionId);
1317
+ const latest = this.store.getReactionIntent(this.config.name, chatId, item.rowId);
1318
+ if (latest && latest.shown === item.shown && latest.reactionId === item.reactionId)
1319
+ this.store.setReactionIntent(this.config.name, chatId, item.rowId, { ...latest, shown: '', reactionId: undefined });
1237
1320
  }
1238
- if (insertedId > 0 && !consumedBeforeAck) {
1239
- const steerStopEpoch = this.stopEpoch.get(chatId) || 0;
1240
- this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
1241
- this.pendingSteerCleanups.set(insertedId, () => {
1242
- stopWatchingConsumption();
1243
- if ("cancelPending" in steer)
1244
- steer.cancelPending?.();
1245
- });
1246
- // The owning batch's failure restores the durable fallback. The
1247
- // queue promise itself never rejects (processQueueInner isolates
1248
- // errors), so failures are observed via runFailureObservers.
1249
- const observers = this.runFailureObservers.get(chatId) || [];
1250
- observers.push(() => {
1251
- if (consumedBeforeAck)
1252
- return;
1253
- // A /stop during the run must not resurrect stopped input.
1254
- if ((this.stopEpoch.get(chatId) || 0) !== steerStopEpoch)
1255
- return;
1256
- this.store.markPendingTrigger(this.config.name, chatId, insertedId);
1257
- // The restored input takes the ordinary queue path: drop its
1258
- // admitted-steer ack so only the new batch's DONE applies.
1259
- this.admittedSteerAcks.set(chatId, (this.admittedSteerAcks.get(chatId) || []).filter((a) => a.insertedId !== insertedId));
1260
- console.log(`[${this.config.name}] steer fallback restored after failed run for ${chatId.slice(-8)}: "${cleanText.substring(0, 40)}..."`);
1261
- // Deferred: the current queue run is still active here; a fresh
1262
- // drain must start after its finally releases the chat. A 0ms
1263
- // timer is NOT enough — the owner's catch block still has
1264
- // in-flight awaits (error delivery), so processQueue would join
1265
- // the dying owner and return, stranding the restored trigger
1266
- // until the next user message (2026-10-16 audit finding).
1267
- const ownerNow = this.queueRuns.get(chatId);
1268
- const drainRestored = () => { void this.processQueue(chatId).catch(() => { }); };
1269
- if (ownerNow)
1270
- void ownerNow.then(drainRestored, drainRestored).catch(() => { });
1271
- else
1272
- setTimeout(drainRestored, 0).unref?.();
1273
- });
1274
- this.runFailureObservers.set(chatId, observers);
1275
- const owner = this.queueRuns.get(chatId);
1276
- if (owner) {
1277
- void owner.then(() => {
1278
- // Queue ended without a batch failure: the steered input was
1279
- // admitted into its batch; release the provisional correlation.
1280
- stopWatchingConsumption();
1281
- this.pendingSteerCleanups.delete(insertedId);
1282
- }).catch(() => { });
1283
- }
1321
+ const latest = this.store.getReactionIntent(this.config.name, chatId, item.rowId);
1322
+ if (!latest || latest.stopEpoch !== this.stopGeneration(chatId) || latest.desired !== item.desired)
1323
+ continue;
1324
+ const reactionId = item.desired ? await this.addReaction(item.messageId, item.desired) : undefined;
1325
+ const after = this.store.getReactionIntent(this.config.name, chatId, item.rowId);
1326
+ if (after && after.stopEpoch === item.stopEpoch && after.stopEpoch === this.stopGeneration(chatId) && after.desired === item.desired) {
1327
+ this.store.setReactionIntent(this.config.name, chatId, item.rowId, { ...after, shown: item.desired, reactionId });
1328
+ }
1329
+ else if (after && reactionId) {
1330
+ this.store.setReactionIntent(this.config.name, chatId, item.rowId, { ...after, cleanupReceipts: [...(after.cleanupReceipts || []), { id: reactionId, emoji: item.desired }] });
1331
+ await this.removeReaction(item.messageId, item.desired, reactionId);
1332
+ const current = this.store.getReactionIntent(this.config.name, chatId, item.rowId);
1333
+ if (current)
1334
+ this.store.setReactionIntent(this.config.name, chatId, item.rowId, { ...current, cleanupReceipts: (current.cleanupReceipts || []).filter((r) => r.id !== reactionId) });
1284
1335
  }
1285
- // Custody transferred: acknowledge on Feishu immediately.
1286
- void this.removeReaction(messageId, "Typing").catch(() => { });
1287
- void this.addReaction(messageId, "Get").catch(() => { });
1288
- const acks = this.pendingAckMessages.get(chatId) || [];
1289
- for (const a of acks)
1290
- if (a.messageId === messageId)
1291
- a.emoji = "Get";
1292
- this.pendingAckMessages.set(chatId, acks);
1293
- // The plugin confirmed queueing into the existing embedded run. Keep
1294
- // that run's original delivery target.
1295
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, plugin steer accepted (custody transferred to active run): "${cleanText.substring(0, 50)}..."`);
1296
- return;
1297
1336
  }
1298
- // Plugin missing/no active run/rejected: keep the durable trigger for
1299
- // normal queue drain. Never claim that ambiguous native admission was an
1300
- // in-run insertion.
1301
- stopWatchingConsumption();
1302
- if ("cancelPending" in steer)
1303
- steer.cancelPending?.();
1304
- // Zombie-owner detection (2026-10-15 guangzi incident): steer found no
1305
- // active run while our in-memory owner still holds the chat. Ask the
1306
- // gateway for the authoritative session status: if it is idle, the owner
1307
- // is a zombie (its completion event was routed through the proactive
1308
- // path and it will never settle). Force-expire it and process this input
1309
- // NOW instead of queueing behind a dead promise forever.
1310
- if (steer.status === "no_active_run" && this.queueRuns.has(chatId)) {
1311
- const ownerAtCheck = this.queueRuns.get(chatId);
1312
- const live = await this.openclawClient.getSessionRuntimeStatus(sessionKeyForSteer).catch(() => null);
1313
- // B1 (2026-10-16 audit): require POSITIVE idle evidence. An RPC
1314
- // failure normalizes to {status:"unknown", running:false} and must
1315
- // never authorize expiring a legitimately running owner. The owner
1316
- // identity is also CAS-checked across the status RPC: a newer batch
1317
- // that took over meanwhile must not be expired by stale evidence.
1318
- if (live && !live.running && live.status !== "unknown" && this.queueRuns.get(chatId) === ownerAtCheck) {
1319
- console.warn(`[${this.config.name}] gateway reports session idle while queue owner exists for ${chatId.slice(-8)} — expiring zombie owner (status=${live.status})`);
1320
- this.forceExpireQueueOwner(chatId, `gateway idle (steer=${steer.status}, session=${live.status})`);
1321
- // Fall through to the normal send path below; the durable trigger
1322
- // for this input is still set, so the fresh processQueue drains it.
1337
+ catch { /* UI-only retry; never roll back business acceptance. */ }
1338
+ }
1339
+ }
1340
+ finally {
1341
+ this.reactionChecks.delete(chatId);
1342
+ this.scheduleInsertionRecovery();
1343
+ }
1344
+ }
1345
+ insertionTimer;
1346
+ insertionChecks = new Set();
1347
+ scheduleInsertionRecovery() {
1348
+ if (this.insertionTimer || (!this.store.listInsertions(this.config.name).length && !this.store.listReactionIntents(this.config.name).length))
1349
+ return;
1350
+ this.insertionTimer = setTimeout(() => {
1351
+ this.insertionTimer = undefined;
1352
+ const chats = [...new Set([...this.store.listInsertions(this.config.name), ...this.store.listReactionIntents(this.config.name)].map(x => x.chatId))];
1353
+ void Promise.allSettled(chats.map(async (chat) => { await this.reconcileReactions(chat); await this.reconcileInsertions(chat); })).finally(() => this.scheduleInsertionRecovery());
1354
+ }, 5000);
1355
+ this.insertionTimer.unref?.();
1356
+ }
1357
+ async reconcileInsertions(chatId) {
1358
+ if (this.insertionChecks.has(chatId))
1359
+ return;
1360
+ this.insertionChecks.add(chatId);
1361
+ try {
1362
+ for (const item of this.store.listInsertions(this.config.name, chatId)) {
1363
+ if (item.nextCheckAt > Date.now())
1364
+ continue;
1365
+ const stillCurrent = () => this.stopGeneration(chatId) === item.stopEpoch && this.store.getInsertion(this.config.name, chatId, item.rowId)?.state !== 'stopped';
1366
+ if (!stillCurrent()) {
1367
+ this.store.stopInsertions(this.config.name, chatId);
1368
+ continue;
1369
+ }
1370
+ const owner = this.queueOwners.get(chatId);
1371
+ if (!item.expectedRunId && owner?.id === item.ownerId && owner?.batchId === item.batchId && owner?.runId)
1372
+ item.expectedRunId = owner.runId;
1373
+ if (!item.expectedRunId) {
1374
+ const stillOriginal = owner?.id === item.ownerId && owner?.batchId === item.batchId;
1375
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, stillOriginal ? 'pending' : 'target_changed', { ...item, reason: stillOriginal ? 'original admission not identified yet' : 'original admission target unavailable; no retargeting' }, Date.now() + 5000);
1376
+ continue;
1377
+ }
1378
+ // Pin the real session generation BEFORE the dispatching journal. A
1379
+ // crash/lost response can then query using precisely the same identity.
1380
+ const info = await this.openclawClient.getSessionInfo(item.sessionKey).catch(() => null);
1381
+ if (!stillCurrent())
1382
+ continue;
1383
+ const sessionId = info?.session?.sessionId;
1384
+ if (info?.session?.archived === true || (item.sessionId && sessionId && item.sessionId !== sessionId)) {
1385
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'target_changed', { ...item, reason: 'session archived or generation changed; retained without migration' });
1386
+ continue;
1387
+ }
1388
+ if (!sessionId) {
1389
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, item.state, item, Date.now() + 5000);
1390
+ continue;
1391
+ }
1392
+ item.sessionId = sessionId;
1393
+ if (item.state === 'accepted') {
1394
+ this.store.clearPendingTrigger(this.config.name, chatId, item.rowId);
1395
+ const reaction = this.store.getReactionIntent(this.config.name, chatId, item.rowId);
1396
+ if (!reaction || reaction.desired === 'Typing')
1397
+ await this.receiptReaction(chatId, item.rowId, item.messageId, 'Get', item.expectedRunId);
1398
+ if (!stillCurrent())
1399
+ continue;
1400
+ const settled = await this.openclawClient.exactRunSettled?.(item.expectedRunId);
1401
+ if (!stillCurrent())
1402
+ continue;
1403
+ if (settled) {
1404
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'committed', item);
1405
+ await this.receiptReaction(chatId, item.rowId, item.messageId, 'DONE', item.expectedRunId);
1323
1406
  }
1324
- else {
1325
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}, session running=${live?.running}), queuing: "${cleanText.substring(0, 50)}..."`);
1407
+ else
1408
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'accepted', item, Date.now() + 60_000);
1409
+ continue;
1410
+ }
1411
+ const batchAtCheck = owner?.batch, runAtCheck = owner?.runId;
1412
+ const wrapped = `${steerInjectionPrefix(this.isEn(chatId))}\n${item.text}`;
1413
+ const queryOnly = item.attempted === true || item.state === 'dispatching';
1414
+ // Public exact terminal facts survive an absent plugin event map (e.g.
1415
+ // plugin restart). Do not discover old-run end by queueing a successor.
1416
+ const originalRunEnded = !queryOnly && await this.openclawClient.exactRunSettled?.(item.expectedRunId) === true;
1417
+ if (!stillCurrent())
1418
+ continue;
1419
+ // Bridge no-replay fence is durable before network mutation. Plugin
1420
+ // owns the final synchronous attempt fence and asynchronous callback.
1421
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, item.state, item, item.nextCheckAt);
1422
+ if (!queryOnly && !this.store.claimInsertionAttempt(this.config.name, chatId, item.rowId))
1423
+ continue;
1424
+ const receipt = await this.openclawClient.steer(item.sessionKey, wrapped, item.text, { insertionId: item.insertionId,
1425
+ expectedRunId: item.expectedRunId, expectedSessionId: item.sessionId, stopEpoch: item.stopEpoch, queryOnly, originalRunEnded });
1426
+ if (!stillCurrent())
1427
+ continue;
1428
+ const valid = receipt.insertionId === item.insertionId && receipt.expectedRunId === item.expectedRunId && (!receipt.sessionId || receipt.sessionId === item.sessionId);
1429
+ let state = valid ? receipt.status : 'unknown';
1430
+ // Explicit no-effect means a real old-run terminal can hand this SAME
1431
+ // input back to the ordinary queue once. Time elapsed never authorizes
1432
+ // this handoff. An attempted unknown can never take this branch.
1433
+ if (valid && receipt.attempted === false && receipt.ordinaryEligible !== false && ['pending', 'target_ended'].includes(state) &&
1434
+ ['target_not_ready', 'backend_rejected_no_effect', 'original run ended without an insertion attempt'].includes(receipt.reason || '')) {
1435
+ const status = await this.openclawClient.getSessionRuntimeStatus(item.sessionKey).catch(() => null);
1436
+ if (!stillCurrent())
1437
+ continue;
1438
+ const rootSettled = await this.openclawClient.exactRunSettled?.(item.expectedRunId);
1439
+ if (!stillCurrent())
1440
+ continue;
1441
+ const unchanged = this.queueOwners.get(chatId) === owner && owner?.batch === batchAtCheck && owner?.runId === runAtCheck;
1442
+ if (rootSettled === true && status?.idleConfirmed === true && unchanged) {
1443
+ state = 'ordinary';
1444
+ // Never expire a successor merely to schedule the old input.
1445
+ if (runAtCheck === item.expectedRunId)
1446
+ this.forceExpireQueueOwner(chatId, 'confirmed idle exact batch; insertion never accepted/unknown');
1326
1447
  }
1448
+ else if (state === 'target_ended')
1449
+ state = 'pending';
1327
1450
  }
1328
- else {
1329
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
1451
+ if (state === 'target_ended')
1452
+ state = 'rejected'; // prior explicit backend refusal; never silently move it to a successor
1453
+ if (state === 'unavailable' || state === 'capability_blocked')
1454
+ state = 'pending';
1455
+ const record = { ...item, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason };
1456
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, state, record, Date.now() + (state === 'unknown' ? 15_000 : 5000));
1457
+ if (state === 'ordinary') {
1458
+ void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:ordinary`, 'ℹ️ 原 run 已明确结束,且这条消息确定未被插入;现已交回普通队列,只执行一次。', [], item.messageId, `insertion:${item.insertionId}:ordinary`).catch(() => { });
1459
+ void this.processQueue(chatId).catch(() => { });
1330
1460
  }
1331
- if (!this.queueRuns.has(chatId)) {
1332
- // Owner gone (force-expired or settled during the steer RPC): send now.
1333
- await this.removeReaction(messageId, "Typing").catch(() => { });
1334
- await this.addReaction(messageId, "Get").catch(() => { });
1335
- const acksNow = this.pendingAckMessages.get(chatId) || [];
1336
- for (const a of acksNow)
1337
- if (a.messageId === messageId)
1338
- a.emoji = "Get";
1339
- this.pendingAckMessages.set(chatId, acksNow);
1340
- await this.processQueue(chatId);
1341
- return;
1461
+ else if (state === 'accepted') {
1462
+ this.store.clearPendingTrigger(this.config.name, chatId, item.rowId);
1463
+ // Durable acceptance drives Get, including after bridge restart when
1464
+ // its ephemeral pendingAckMessages array no longer exists.
1465
+ await this.receiptReaction(chatId, item.rowId, item.messageId, 'Get', item.expectedRunId);
1466
+ if (!stillCurrent())
1467
+ continue;
1468
+ const ack = (this.pendingAckMessages.get(chatId) || []).find(a => a.rowId === item.rowId);
1469
+ if (ack)
1470
+ ack.emoji = 'Get';
1471
+ if (owner && owner.runId === item.expectedRunId && owner.batch) {
1472
+ const list = this.admittedSteerAcks.get(chatId) || [];
1473
+ if (!list.some(a => a.insertedId === item.rowId))
1474
+ list.push({ messageId: item.messageId, insertedId: item.rowId, batch: owner.batch });
1475
+ this.admittedSteerAcks.set(chatId, list);
1476
+ }
1477
+ }
1478
+ else if (state === 'unknown' || state === 'target_changed' || state === 'rejected') {
1479
+ if (item.state !== state)
1480
+ void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:${state}`, `ℹ️ 这条插入消息的状态为 ${state},仍保留本地记录,未确认接收前不会显示 Get,也不会自动重复执行。${receipt.reason || ''}`, [], item.messageId, `insertion:${item.insertionId}:${state}`).catch(() => { });
1342
1481
  }
1343
- // Reconciliation may have finished the old owner during the steer RPC.
1344
- // Ensure this new, still-pending input is drained even when that owner
1345
- // exited through an error path. Already-consumed input is never replayed.
1346
- const owner = this.queueRuns.get(chatId);
1347
- const drainPending = () => {
1348
- if (insertedId > 0 && this.store.getPendingTriggerIds(this.config.name, chatId).has(insertedId))
1349
- return this.processQueue(chatId);
1350
- };
1351
- if (owner)
1352
- void owner.then(drainPending, drainPending).catch(err => console.warn(`[${this.config.name}] pending drain failed:`, err.message));
1353
- else
1354
- await drainPending();
1355
- return;
1356
1482
  }
1357
- // --- Not busy, send now (with any accumulated messages) ---
1358
- // Acknowledge receipt: sent to OpenClaw (GET/了解)
1359
- await this.addReaction(messageId, "Get").catch(() => { });
1360
- pending.push({ messageId, emoji: "Get", rowId: insertedId });
1361
- this.pendingAckMessages.set(chatId, pending);
1362
- await this.processQueue(chatId);
1363
1483
  }
1364
- catch (err) {
1365
- console.error(`[${this.config.name}] Error:`, safeErrorSummary(err));
1484
+ finally {
1485
+ this.insertionChecks.delete(chatId);
1366
1486
  }
1367
1487
  }
1368
1488
  shouldUseLiveStatus(chatId, isNativeCommandTrigger = false) {
@@ -1436,9 +1556,10 @@ export class FeishuBot {
1436
1556
  async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
1437
1557
  let done = false;
1438
1558
  const monitoredWork = work.finally(() => { done = true; });
1559
+ const monitorStartedAt = Date.now();
1439
1560
  const monitor = (async () => {
1440
1561
  while (!done) {
1441
- await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_POLL_MS));
1562
+ await new Promise((resolve) => setTimeout(resolve, Date.now() - monitorStartedAt < 60_000 ? SESSION_HEALTH_POLL_MS : 60_000));
1442
1563
  if (done)
1443
1564
  break;
1444
1565
  const unhealthy = await this.getUnhealthySessionStatus(chatId);
@@ -1489,9 +1610,9 @@ export class FeishuBot {
1489
1610
  console.log(`[${this.config.name}] processQueue already running for ${chatId.slice(-8)}, joining existing run`);
1490
1611
  return existing;
1491
1612
  }
1492
- const batchToken = {};
1493
- this.queueRunTokens.set(chatId, batchToken);
1494
- const run = this.processQueueInner(chatId, batchToken).finally(() => {
1613
+ const owner = { id: randomUUID(), token: {}, revoked: false, stopEpoch: this.stopGeneration(chatId) };
1614
+ this.queueOwners.set(chatId, owner);
1615
+ const run = this.processQueueInner(chatId, owner).finally(() => {
1495
1616
  // Ownership token guard: only the CURRENT owner may release the slot. A
1496
1617
  // zombie owner that settles after forceExpireQueueOwner must not delete
1497
1618
  // the replacement owner's entry (that would open a concurrent-run window).
@@ -1500,6 +1621,8 @@ export class FeishuBot {
1500
1621
  this.runFailureObservers.delete(chatId);
1501
1622
  this.admittedSteerAcks.delete(chatId);
1502
1623
  this.queueRunTokens.delete(chatId);
1624
+ if (this.queueOwners.get(chatId) === owner)
1625
+ this.queueOwners.delete(chatId);
1503
1626
  }
1504
1627
  });
1505
1628
  this.queueRuns.set(chatId, run);
@@ -1516,6 +1639,9 @@ export class FeishuBot {
1516
1639
  const owner = this.queueRuns.get(chatId);
1517
1640
  if (!owner)
1518
1641
  return false;
1642
+ const context = this.queueOwners.get(chatId);
1643
+ if (context)
1644
+ context.revoked = true;
1519
1645
  console.warn(`[${this.config.name}] force-expiring zombie queue owner for ${chatId.slice(-8)}: ${reason}`);
1520
1646
  // Fence the stale batch's local observation BEFORE releasing custody: an
1521
1647
  // alive old poller would keep destructively consuming the replacement
@@ -1525,14 +1651,25 @@ export class FeishuBot {
1525
1651
  }
1526
1652
  catch { /* isolated */ }
1527
1653
  this.queueRuns.delete(chatId);
1654
+ if (this.queueOwners.get(chatId) === context)
1655
+ this.queueOwners.delete(chatId);
1528
1656
  this.fireRunFailureObservers(chatId);
1529
1657
  this.runFailureObservers.delete(chatId);
1530
1658
  this.admittedSteerAcks.delete(chatId);
1531
1659
  this.queueRunTokens.delete(chatId);
1532
1660
  return true;
1533
1661
  }
1534
- async processQueueInner(chatId, batchToken) {
1535
- while (true) {
1662
+ async processQueueInner(chatId, owner) {
1663
+ const current = () => !owner.revoked && this.queueOwners.get(chatId) === owner && this.stopGeneration(chatId) === owner.stopEpoch;
1664
+ const guard = () => { if (!current())
1665
+ throw new SupersededObservation('queue owner revoked'); };
1666
+ while (current()) {
1667
+ const batchToken = {};
1668
+ owner.batch = batchToken;
1669
+ owner.batchId = randomUUID();
1670
+ owner.triggerIds = undefined;
1671
+ owner.runId = undefined;
1672
+ this.queueRunTokens.set(chatId, batchToken);
1536
1673
  // A fresh batch starts: steered inputs admitted into the PREVIOUS batch
1537
1674
  // completed with it (consumed or not); their failure observers are stale.
1538
1675
  this.runFailureObservers.delete(chatId);
@@ -1541,7 +1678,7 @@ export class FeishuBot {
1541
1678
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
1542
1679
  // Only proceed if there are pending human messages that should actively trigger this bot.
1543
1680
  const pendingHumanTriggers = pendingMessages
1544
- .filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id))
1681
+ .filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id) && !this.store.isInsertionReserved(this.config.name, chatId, m.id))
1545
1682
  .sort((a, b) => a.timestamp - b.timestamp);
1546
1683
  if (pendingHumanTriggers.length === 0) {
1547
1684
  break;
@@ -1623,9 +1760,10 @@ export class FeishuBot {
1623
1760
  // we snapshotted inside auto-retry instead, a /stop that landed during the
1624
1761
  // main run would already be baked into the baseline and go undetected —
1625
1762
  // that was the "stop then auto-retry anyway" bug.
1626
- const runStartStopEpoch = this.stopEpoch.get(chatId) || 0;
1763
+ const runStartStopEpoch = this.stopGeneration(chatId);
1627
1764
  const mergedContent = mergedTriggers.map((m) => m.content).join("\n");
1628
1765
  const mergedTriggerIds = mergedTriggers.map((m) => m.id || 0).filter(Boolean);
1766
+ owner.triggerIds = new Set(mergedTriggerIds);
1629
1767
  // These rows are now taking the ordinary queue path. Remove any stale
1630
1768
  // provisional native-steer correlation before chat.send so a later
1631
1769
  // identical steer cannot acknowledge the wrong Feishu message.
@@ -1687,8 +1825,11 @@ export class FeishuBot {
1687
1825
  let sessionKey;
1688
1826
  try {
1689
1827
  sessionKey = await this.ensureSession(chatId);
1828
+ guard();
1690
1829
  }
1691
1830
  catch (err) {
1831
+ if (!current())
1832
+ break;
1692
1833
  // Model policy/capability failures happen before chat.send. Clear the
1693
1834
  // busy marker, report the actionable error, and retire only this batch
1694
1835
  // so the chat cannot remain wedged indefinitely.
@@ -1703,19 +1844,11 @@ export class FeishuBot {
1703
1844
  if (mergedTriggerIds.length > 0)
1704
1845
  this.store.markSynced(this.config.name, chatId, Math.max(...mergedTriggerIds));
1705
1846
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1847
+ guard();
1706
1848
  this.pendingAckMessages.set(chatId, pendingAcks.filter((ack) => !completedTriggerIdSet.has(ack.rowId)));
1707
1849
  break;
1708
1850
  }
1709
1851
  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})`);
1710
- // Update reactions: queued messages → sent (GET/了解)
1711
- const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1712
- for (const ack of pendingAcks) {
1713
- if (ack.emoji !== "Get") {
1714
- await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
1715
- await this.addReaction(ack.messageId, "Get").catch(() => { });
1716
- ack.emoji = "Get";
1717
- }
1718
- }
1719
1852
  let foregroundRunId;
1720
1853
  const foregroundSessionId = this.store.getActiveGoJob(this.config.name, chatId)?.executorSessionId;
1721
1854
  let runAcceptedByOpenClaw = false;
@@ -1750,6 +1883,7 @@ export class FeishuBot {
1750
1883
  locale: this.isEn(chatId) ? "en" : "zh",
1751
1884
  })
1752
1885
  : undefined;
1886
+ guard();
1753
1887
  liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
1754
1888
  const activeTargetForStatus = this.activeDeliveryTargets.get(chatId);
1755
1889
  if (activeTargetForStatus?.triggerId === triggerId && liveStatus)
@@ -1766,13 +1900,32 @@ export class FeishuBot {
1766
1900
  timeoutMs: FOREGROUND_WAIT_MS,
1767
1901
  includeContext: !isNativeCommandTrigger,
1768
1902
  includeBridgeAttachmentHint: !isNativeCommandTrigger,
1769
- onSendAttempt: () => {
1903
+ onSendAttempt: (submissionId) => {
1904
+ guard();
1905
+ if (submissionId && lastHuman.messageId) {
1906
+ this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, triggerIds: mergedTriggerIds, messageId: lastHuman.messageId, runId: submissionId, stopGeneration: this.stopGeneration(chatId) });
1907
+ for (const ack of this.pendingAckMessages.get(chatId) || [])
1908
+ if (mergedTriggerIdSet.has(ack.rowId))
1909
+ void this.receiptReaction(chatId, ack.rowId, ack.messageId, 'Typing', submissionId).catch(() => { });
1910
+ }
1770
1911
  // At-most-once: as soon as we begin issuing chat.send, do not let
1771
1912
  // this batch auto-replay. If the RPC response is lost after
1772
1913
  // OpenClaw accepts it, retrying would duplicate the same message.
1773
1914
  markSubmittedBatch("send-attempt");
1774
1915
  },
1775
- onSubmitted: (runId) => {
1916
+ onRunIdentified: (runId) => { guard(); owner.runId = runId; foregroundRunId = runId; },
1917
+ onSubmitted: async (runId) => {
1918
+ guard();
1919
+ owner.runId = runId;
1920
+ void this.reconcileInsertions(chatId).catch(() => { });
1921
+ for (const ack of this.pendingAckMessages.get(chatId) || []) {
1922
+ if (!mergedTriggerIdSet.has(ack.rowId))
1923
+ continue;
1924
+ guard();
1925
+ await this.receiptReaction(chatId, ack.rowId, ack.messageId, 'Get', runId);
1926
+ guard();
1927
+ ack.emoji = 'Get';
1928
+ }
1776
1929
  foregroundRunId = runId;
1777
1930
  runAcceptedByOpenClaw = true;
1778
1931
  const target = this.activeDeliveryTargets.get(chatId);
@@ -1786,9 +1939,11 @@ export class FeishuBot {
1786
1939
  // INSERT OR IGNORE in message_sync.
1787
1940
  markSubmittedBatch(`submitted:${runId}`);
1788
1941
  },
1789
- onProgress: (event) => liveStatus?.progress(event),
1942
+ onProgress: (event) => current() ? liveStatus?.progress(event) : undefined,
1790
1943
  onWaitPaused: async (notice) => {
1944
+ guard();
1791
1945
  await liveStatus?.showWaitingForResult(notice.message);
1946
+ guard();
1792
1947
  // Yield is a confirmed handoff, not the timeout heuristic. A
1793
1948
  // recent unrelated reply must not suppress this turn's notice.
1794
1949
  if (notice.reason === "yield" ? !this.store.hasDeliveredReply(this.config.name, chatId, triggerId)
@@ -1808,6 +1963,7 @@ export class FeishuBot {
1808
1963
  // and chat-final paths share the same delivery key.
1809
1964
  releaseActiveDeliveryTarget();
1810
1965
  }
1966
+ guard();
1811
1967
  console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
1812
1968
  // Auto-retry: if the reply looks truncated/incomplete, confirm with the
1813
1969
  // session and loop until it reports done (or the budget is spent). This
@@ -1822,7 +1978,14 @@ export class FeishuBot {
1822
1978
  initialReply: reply,
1823
1979
  liveStatus,
1824
1980
  runStartStopEpoch,
1981
+ guard,
1982
+ onSendAttempt: submissionId => {
1983
+ guard();
1984
+ this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey: `agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}` });
1985
+ },
1986
+ onSubmitted: async (runId) => { guard(); owner.runId = runId; foregroundRunId = runId; this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || "", liveStatus }); },
1825
1987
  });
1988
+ guard();
1826
1989
  const parsedReply = this.extractBridgeAttachments(reply);
1827
1990
  const visibleReply = parsedReply.text;
1828
1991
  const trimmedReply = visibleReply.trim();
@@ -1853,13 +2016,13 @@ export class FeishuBot {
1853
2016
  const remainingAcks = [];
1854
2017
  for (const ack of pendingAcks) {
1855
2018
  if (completedTriggerIdSet.has(ack.rowId)) {
1856
- await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
1857
- await this.addReaction(ack.messageId, "DONE").catch(() => { });
2019
+ await this.receiptReaction(chatId, ack.rowId, ack.messageId, 'DONE', foregroundRunId || owner.runId);
1858
2020
  }
1859
2021
  else {
1860
2022
  remainingAcks.push(ack);
1861
2023
  }
1862
2024
  }
2025
+ guard();
1863
2026
  this.pendingAckMessages.set(chatId, remainingAcks);
1864
2027
  break;
1865
2028
  }
@@ -1882,37 +2045,14 @@ export class FeishuBot {
1882
2045
  this.store.clearPendingTrigger(this.config.name, chatId, id);
1883
2046
  const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
1884
2047
  const isRuntimeFailure = shouldReply && this.isRuntimeFailureText(trimmedReply);
1885
- // Record bot reply only if it is user-visible/context-worthy.
1886
- if (shouldReply || hasAttachments) {
1887
- const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
1888
- .filter(Boolean)
1889
- .join("\n");
1890
- const replyId = this.store.insert({
1891
- chatId,
1892
- messageId: `self-${this.config.name}-${Date.now()}`,
1893
- senderType: "bot",
1894
- senderName: this.config.name,
1895
- content: storedContent,
1896
- timestamp: Date.now(),
1897
- });
1898
- if (replyId > 0) {
1899
- const remainingPending = this.store.getPendingTriggerIds(this.config.name, chatId);
1900
- const hasEarlierPending = Array.from(remainingPending).some((id) => id <= replyId);
1901
- // Do not advance sync past a human message that arrived while this
1902
- // run was busy. Otherwise the pending trigger remains in the table
1903
- // but getUnsyncedMessages() can no longer see it.
1904
- if (!hasEarlierPending) {
1905
- this.store.markMessagesSynced(this.config.name, chatId, [replyId], `${this.config.name}:${chatId}:reply:${replyId}`);
1906
- this.store.markSynced(this.config.name, chatId, replyId);
1907
- }
1908
- }
1909
- }
2048
+ // Receipt-aware dispatcher owns all shared assistant context rows.
1910
2049
  // Wait for all pending tool event messages to be delivered first
1911
2050
  const toolSends = this.pendingToolSends.get(chatId) || [];
1912
2051
  if (toolSends.length > 0) {
1913
2052
  await Promise.allSettled(toolSends);
1914
2053
  this.pendingToolSends.set(chatId, []);
1915
2054
  }
2055
+ guard();
1916
2056
  // Reply to the last human message on Feishu (ordered after tool msgs)
1917
2057
  // Skip empty replies and explicit NO_REPLY responses
1918
2058
  if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
@@ -1925,7 +2065,7 @@ export class FeishuBot {
1925
2065
  // Keep live status and final answer as separate messages. The final
1926
2066
  // answer is created at completion time so it remains at the bottom
1927
2067
  // even when the user inserted more messages during the run.
1928
- 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 && foregroundSessionId ? { runId: foregroundRunId, sessionKey, sessionId: foregroundSessionId, final: true } : undefined);
2068
+ 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);
1929
2069
  await liveStatus?.complete();
1930
2070
  // Mark every merged trigger as delivered so retries/restarts do
1931
2071
  // not re-process any of them.
@@ -1996,6 +2136,7 @@ export class FeishuBot {
1996
2136
  this.goController?.notifyRunOutcome(chatId, "no_reply", `trigger:${triggerId}`, "(本轮无用户可见回复 NO_REPLY)");
1997
2137
  }
1998
2138
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
2139
+ guard();
1999
2140
  // Replace ack reactions with DONE only for trigger messages actually
2000
2141
  // processed in this run. Queued messages that arrived mid-run keep their
2001
2142
  // Typing/Get reaction and will be acknowledged by the next loop.
@@ -2003,13 +2144,14 @@ export class FeishuBot {
2003
2144
  const remainingAcks = [];
2004
2145
  for (const ack of pendingAcks) {
2005
2146
  if (completedTriggerIdSet.has(ack.rowId)) {
2006
- await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
2007
- await this.addReaction(ack.messageId, "DONE").catch(() => { });
2147
+ guard();
2148
+ await this.receiptReaction(chatId, ack.rowId, ack.messageId, 'DONE', foregroundRunId || owner.runId);
2008
2149
  }
2009
2150
  else {
2010
2151
  remainingAcks.push(ack);
2011
2152
  }
2012
2153
  }
2154
+ guard();
2013
2155
  this.pendingAckMessages.set(chatId, remainingAcks);
2014
2156
  // Steered messages admitted into THIS batch were consumed with it:
2015
2157
  // flip their Get → DONE alongside the batch's own triggers.
@@ -2023,15 +2165,20 @@ export class FeishuBot {
2023
2165
  }
2024
2166
  if (admittedSteers.length > 0) {
2025
2167
  for (const s of admittedSteers) {
2026
- await this.removeReaction(s.messageId, "Get").catch(() => { });
2027
- await this.addReaction(s.messageId, "DONE").catch(() => { });
2168
+ guard();
2169
+ const custody = this.store.getInsertion(this.config.name, chatId, s.insertedId);
2170
+ if (custody?.state !== 'accepted' || custody.expectedRunId !== owner.runId)
2171
+ continue;
2172
+ this.store.saveInsertion(this.config.name, chatId, s.insertedId, custody.insertionId, 'committed', custody);
2173
+ await this.receiptReaction(chatId, s.insertedId, s.messageId, 'DONE', owner.runId);
2028
2174
  }
2029
2175
  const acksAfter = this.pendingAckMessages.get(chatId) || [];
2176
+ guard();
2030
2177
  this.pendingAckMessages.set(chatId, acksAfter.filter((a) => !admittedSteers.some((s) => a.messageId === s.messageId)));
2031
2178
  }
2032
2179
  }
2033
2180
  catch (err) {
2034
- if (err instanceof SupersededObservation) {
2181
+ if (!current() || err instanceof SupersededObservation) {
2035
2182
  // This batch's local observation was fenced by a newer batch (or a
2036
2183
  // custody revocation). The new owner handles delivery from here; end
2037
2184
  // quietly — no error notice, no replay, no Gateway abort. The old
@@ -2039,6 +2186,11 @@ export class FeishuBot {
2039
2186
  console.log(`[${this.config.name}] batch observation superseded for ${chatId.slice(-8)}; ending quietly`);
2040
2187
  break;
2041
2188
  }
2189
+ if (err instanceof AdmissionUnconfirmed) {
2190
+ await this.enqueueAndDispatchDelivery(chatId, 'admission_status', `admission:${err.submissionId}:unknown`, err.message, [], lastHuman.messageId, `admission:${err.submissionId}:unknown`).catch(() => { });
2191
+ await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
2192
+ break;
2193
+ }
2042
2194
  let pause = err instanceof SessionWaitPaused ? err : undefined;
2043
2195
  if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
2044
2196
  const state = await this.readSessionStatus(chatId);
@@ -2060,6 +2212,7 @@ export class FeishuBot {
2060
2212
  for (const ack of acks)
2061
2213
  if (completedTriggerIdSet.has(ack.rowId))
2062
2214
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
2215
+ guard();
2063
2216
  this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
2064
2217
  break;
2065
2218
  }
@@ -2072,6 +2225,7 @@ export class FeishuBot {
2072
2225
  for (const ack of acks)
2073
2226
  if (completedTriggerIdSet.has(ack.rowId))
2074
2227
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
2228
+ guard();
2075
2229
  this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
2076
2230
  // No DONE/delivered mark and no replay of the old input. The new
2077
2231
  // pending trigger resumes after this queue owner's finally releases.
@@ -2094,7 +2248,7 @@ export class FeishuBot {
2094
2248
  // is a separate message that we mark interrupted afterwards.
2095
2249
  await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
2096
2250
  .then(() => {
2097
- if (triggerId)
2251
+ if (current() && triggerId)
2098
2252
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
2099
2253
  })
2100
2254
  .catch(() => { });
@@ -2103,6 +2257,8 @@ export class FeishuBot {
2103
2257
  else {
2104
2258
  await liveStatus?.fail().catch(() => { });
2105
2259
  }
2260
+ if (!current())
2261
+ break;
2106
2262
  if (triggerId) {
2107
2263
  // At-most-once: if anything after send attempt failed, this batch has
2108
2264
  // already been marked/cleared by onSendAttempt. If failure happened
@@ -2116,19 +2272,21 @@ export class FeishuBot {
2116
2272
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
2117
2273
  const remainingAcks = [];
2118
2274
  for (const ack of pendingAcks) {
2119
- if (completedTriggerIdSet.has(ack.rowId)) {
2120
- await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
2121
- await this.addReaction(ack.messageId, "DONE").catch(() => { });
2275
+ if (completedTriggerIdSet.has(ack.rowId) && runAcceptedByOpenClaw) {
2276
+ guard();
2277
+ await this.receiptReaction(chatId, ack.rowId, ack.messageId, 'DONE', foregroundRunId || owner.runId);
2122
2278
  }
2123
2279
  else {
2124
2280
  remainingAcks.push(ack);
2125
2281
  }
2126
2282
  }
2283
+ guard();
2127
2284
  this.pendingAckMessages.set(chatId, remainingAcks);
2128
2285
  break;
2129
2286
  }
2130
2287
  finally {
2131
- this.busyChats.set(chatId, 0);
2288
+ if (current())
2289
+ this.busyChats.set(chatId, 0);
2132
2290
  }
2133
2291
  // Check if more messages arrived while we were busy
2134
2292
  // Small delay to let any in-flight messages settle
@@ -2518,10 +2676,11 @@ export class FeishuBot {
2518
2676
  // seen here; fall back to the current epoch only if the caller did not pass
2519
2677
  // one. If the user hit /stop at any point from the main run onward, the
2520
2678
  // epoch is now past the baseline and we must not auto-retry at all.
2521
- const startStopEpoch = params.runStartStopEpoch ?? (this.stopEpoch.get(chatId) || 0);
2522
- const wasStopped = () => (this.stopEpoch.get(chatId) || 0) !== startStopEpoch;
2679
+ const startStopEpoch = params.runStartStopEpoch ?? this.stopGeneration(chatId);
2680
+ const wasStopped = () => this.stopGeneration(chatId) !== startStopEpoch;
2523
2681
  for (let attempt = 1; attempt <= maxRetry; attempt++) {
2524
2682
  // /stop while we were looping: abandon retries and deliver what we have.
2683
+ params.guard?.();
2525
2684
  if (wasStopped()) {
2526
2685
  console.log(`[${this.config.name}] auto-retry: aborted by /stop for ${chatId.slice(-8)}`);
2527
2686
  return reply;
@@ -2543,6 +2702,9 @@ export class FeishuBot {
2543
2702
  await liveStatus?.progress(en
2544
2703
  ? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
2545
2704
  : `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`).catch(() => { });
2705
+ params.guard?.();
2706
+ if (wasStopped())
2707
+ return reply;
2546
2708
  // One short line, in the user's own voice (we intentionally do not change
2547
2709
  // senderName). Each locale uses its own done-phrase so the English probe
2548
2710
  // stays fully English; detection accepts both phrases.
@@ -2560,14 +2722,21 @@ export class FeishuBot {
2560
2722
  timeoutMs: FOREGROUND_WAIT_MS,
2561
2723
  includeContext: false,
2562
2724
  includeBridgeAttachmentHint: false,
2563
- onProgress: (event) => liveStatus?.progress(event),
2725
+ onSendAttempt: submissionId => { params.guard?.(); params.onSendAttempt?.(submissionId); params.guard?.(); if (wasStopped())
2726
+ throw new SupersededObservation("stopped probe"); },
2727
+ onSubmitted: async (runId) => { params.guard?.(); await params.onSubmitted?.(runId); params.guard?.(); },
2728
+ onProgress: (event) => { params.guard?.(); return liveStatus?.progress(event); },
2564
2729
  onWaitPaused: async (notice) => {
2730
+ params.guard?.();
2565
2731
  await liveStatus?.showWaitingForResult(notice.message);
2732
+ params.guard?.();
2566
2733
  await this.sendWaitPaused(chatId, triggerId, lastHumanMessageId, notice);
2734
+ params.guard?.();
2567
2735
  },
2568
2736
  }));
2569
2737
  }
2570
2738
  catch (err) {
2739
+ params.guard?.();
2571
2740
  if (err instanceof SessionWaitPaused)
2572
2741
  throw err;
2573
2742
  // The confirmation round itself failed (timeout/unhealthy). Do not spin:
@@ -2575,9 +2744,11 @@ export class FeishuBot {
2575
2744
  console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
2576
2745
  return reply;
2577
2746
  }
2747
+ params.guard?.();
2578
2748
  const probeText = this.extractBridgeAttachments(probeReply).text.trim();
2579
2749
  // /stop landed while the probe was in flight: stop here rather than loop
2580
2750
  // again. Deliver the latest meaningful output we have.
2751
+ params.guard?.();
2581
2752
  if (wasStopped()) {
2582
2753
  console.log(`[${this.config.name}] auto-retry: aborted by /stop mid-probe for ${chatId.slice(-8)}`);
2583
2754
  return probeText && probeText.toUpperCase() !== "NO_REPLY" ? probeReply : reply;
@@ -2612,6 +2783,7 @@ export class FeishuBot {
2612
2783
  const previous = this.deliveryTargetsByRun.get(key);
2613
2784
  if (previous?.timer)
2614
2785
  clearTimeout(previous.timer);
2786
+ this.store.bindDeliveryRun(this.config.name, chatId, runId, { triggerId: target.triggerId, messageId: target.messageId, runId, sessionKey: `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}` });
2615
2787
  this.deliveryTargetsByRun.set(key, {
2616
2788
  triggerId: target.triggerId,
2617
2789
  messageId: target.messageId,
@@ -2690,13 +2862,19 @@ export class FeishuBot {
2690
2862
  return (hash >>> 0).toString(36);
2691
2863
  }
2692
2864
  deliverySourceId(kind, text) {
2693
- return `${kind}:${this.stableHash(text)}`;
2865
+ return `${kind}:${createHash('sha256').update(text).digest('hex')}`;
2694
2866
  }
2695
2867
  async sendSystemDelivery(chatId, text) {
2696
2868
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
2697
2869
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
2698
2870
  }
2699
2871
  async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId) {
2872
+ if (provenance?.sessionKey && !/^agent:/.test(provenance.sessionKey))
2873
+ provenance = { ...provenance, sessionKey: `agent:main:${provenance.sessionKey}` };
2874
+ if (provenance?.sessionKey && provenance.runId && this.store.isStoppedObservation(provenance.sessionKey, provenance.runId))
2875
+ return;
2876
+ if (text.trim().toUpperCase() === 'NO_REPLY')
2877
+ text = '';
2700
2878
  if (!text.trim() && attachments.length === 0)
2701
2879
  return;
2702
2880
  const attachmentsJson = JSON.stringify(attachments);
@@ -2704,6 +2882,8 @@ export class FeishuBot {
2704
2882
  const contentHash = this.stableHash(normalizedPayload);
2705
2883
  const finalDeliveryKey = deliveryKey || sourceId;
2706
2884
  const deliveryMetaJson = JSON.stringify({
2885
+ botIdentity: this.config.appId,
2886
+ deliveryStopEpoch: this.stopGeneration(chatId),
2707
2887
  ...(finalReplyModel?.trim() ? { model: finalReplyModel.trim() } : {}),
2708
2888
  ...(provenance ? { provenance } : {}), ...(errorNoticeId ? { errorNoticeId } : {}),
2709
2889
  });
@@ -2722,12 +2902,6 @@ export class FeishuBot {
2722
2902
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
2723
2903
  return;
2724
2904
  }
2725
- else if (!deliveryKey && !correlatedFinal) {
2726
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
2727
- return;
2728
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
2729
- return;
2730
- }
2731
2905
  const deliveryId = this.store.enqueueDelivery({
2732
2906
  sessionKey: this.deliverySessionKey(chatId),
2733
2907
  chatId,
@@ -2983,10 +3157,10 @@ export class FeishuBot {
2983
3157
  this.errorNoticeChecks.set(chatId, promise);
2984
3158
  return promise;
2985
3159
  }
2986
- scheduleDeliveryRetry(chatId, attempt) {
3160
+ scheduleDeliveryRetry(chatId, attempt, local = false) {
2987
3161
  if (this.deliveryRetryTimers.has(chatId))
2988
3162
  return;
2989
- const delay = Math.min(30_000, DELIVERY_RETRY_BASE_MS * Math.max(1, 2 ** Math.max(0, attempt - 1)));
3163
+ const delay = Math.min(local ? 300_000 : 30_000, DELIVERY_RETRY_BASE_MS * Math.max(1, 2 ** Math.min(16, Math.max(0, attempt - 1))));
2990
3164
  const timer = setTimeout(() => {
2991
3165
  this.deliveryRetryTimers.delete(chatId);
2992
3166
  void this.dispatchPendingDeliveries(chatId).catch((err) => {
@@ -3020,6 +3194,16 @@ export class FeishuBot {
3020
3194
  }
3021
3195
  async dispatchPendingDeliveries(chatId, replyToMessageId) {
3022
3196
  this.installGoReceiptCapture();
3197
+ for (const id of this.localDeliveryRetryCustody.get(chatId) || []) {
3198
+ try {
3199
+ this.store.deferLocalDelivery(id, 'local_store_unavailable');
3200
+ this.localDeliveryRetryCustody.get(chatId)?.delete(id);
3201
+ }
3202
+ catch {
3203
+ this.scheduleDeliveryRetry(chatId, 10, true);
3204
+ return;
3205
+ }
3206
+ }
3023
3207
  let retryScheduled = false;
3024
3208
  while (true) {
3025
3209
  const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
@@ -3029,9 +3213,17 @@ export class FeishuBot {
3029
3213
  const ownerId = JSON.parse(item.deliveryMetaJson || "{}").provenance?.goJobId;
3030
3214
  const deliveryJob = ownerId ? this.store.getGoJobById(ownerId) : this.store.getActiveGoJob(this.config.name, chatId);
3031
3215
  const receipts = [
3216
+ ...this.store.listDeliveryReceipts(item.id),
3032
3217
  ...(deliveryJob ? this.store.listGoVisibleEvidence(deliveryJob.id).find(e => e.sourceKey === `outbox:${item.id}`)?.receipt?.messages ?? [] : []),
3033
3218
  ];
3034
- await this.sendOrdered(chatId, () => this.goDeliveryReceipts.run({ receipts, goId: deliveryJob?.id, outboxId: item.id, stage: "body", ordinals: new Map(), onReceipt: () => {
3219
+ await this.sendOrdered(chatId, () => this.goDeliveryReceipts.run({ receipts, goId: deliveryJob?.id, outboxId: item.id, chatId, stage: "body", ordinals: new Map(), effectGuard: () => {
3220
+ const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
3221
+ if ((p?.final || item.sourceType === 'run_error') &&
3222
+ (typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
3223
+ p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
3224
+ throw Object.assign(new Error('Delivery revoked by explicit stop before platform attempt'), { deliveryStopped: true });
3225
+ }
3226
+ }, onReceipt: () => {
3035
3227
  if (deliveryJob)
3036
3228
  this.store.recordGoDeliveryReceipt(deliveryJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
3037
3229
  } }, async () => {
@@ -3039,6 +3231,8 @@ export class FeishuBot {
3039
3231
  return;
3040
3232
  let failureStage = "text";
3041
3233
  try {
3234
+ this.store.flushKnownDeliveryReceipts(item.id);
3235
+ this.store.registerPartialAttachmentContext(item.id);
3042
3236
  const job = deliveryJob;
3043
3237
  const provenance = JSON.parse(item.deliveryMetaJson || "{}").provenance;
3044
3238
  const goSource = `outbox:${item.id}`;
@@ -3103,31 +3297,20 @@ export class FeishuBot {
3103
3297
  else {
3104
3298
  await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
3105
3299
  }
3106
- // Rows created by v1.4.3 may still target a live-status card. Preserve
3107
- // restart compatibility: send their text as a new message, then
3108
- // collapse the old status card in the cleanup stage below.
3109
- if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
3110
- this.store.markDeliveryTextDeliveredWithCleanup(item.id);
3111
- }
3112
- else {
3113
- this.store.markDeliveryTextDelivered(item.id);
3114
- }
3115
- // Proactive deliveries have no trigger-reply recording path, so
3116
- // without this row other bots' context sync never sees them
3117
- // (2026-10-16 blind spot: an audit reply delivered to Feishu but
3118
- // invisible to every other group member). The deterministic id
3119
- // keyed by deliveryKey keeps crash-retries idempotent; the unique
3120
- // constraint turns a replay into a no-op.
3121
- if (String(item.deliveryKey || "").startsWith("proactive:")) {
3122
- this.store.insert({
3123
- chatId,
3124
- messageId: `self-${this.config.name}-proactive-${item.deliveryKey}`,
3125
- senderType: "bot",
3126
- senderName: this.config.name,
3127
- content: item.content,
3128
- timestamp: Date.now(),
3129
- });
3130
- }
3300
+ const bodyReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === 'body');
3301
+ if (!bodyReceipts.length)
3302
+ throw new Error('No confirmed body receipt');
3303
+ this.store.registerPartialBodyContext(item.id, true);
3304
+ this.store.confirmDeliveryStage(item.id, 'body', item.content, bodyReceipts, 'text');
3305
+ if (item.deliveryMode === 'patch_live_status' && item.targetMessageId)
3306
+ this.store.markDeliveryCleanupPending(item.id, true);
3307
+ }
3308
+ // Repair a checkpoint/context gap without resending. Legacy rows
3309
+ // lacking actual receipts are exposed through the explicit audit API.
3310
+ if (item.textDelivered) {
3311
+ const bodyReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === 'body');
3312
+ if (bodyReceipts.length)
3313
+ this.store.confirmDeliveryStage(item.id, 'body', item.content, bodyReceipts, 'text');
3131
3314
  }
3132
3315
  // Attachments are independent from status-card cleanup. An unpatchable
3133
3316
  // or deleted status card must never block otherwise-sendable files.
@@ -3138,7 +3321,21 @@ export class FeishuBot {
3138
3321
  if (context)
3139
3322
  context.stage = `attachment:${i}`;
3140
3323
  await this.sendBridgeAttachment(chatId, attachments[i]);
3141
- this.store.markDeliveryAttachmentCursor(item.id, i + 1);
3324
+ const stage = `attachment:${i}`;
3325
+ const attachmentReceipts = this.store.listDeliveryReceipts(item.id).filter(r => r.stage === stage);
3326
+ if (!attachmentReceipts.length)
3327
+ throw new Error('No confirmed attachment receipt');
3328
+ const visiblePayload = attachmentReceipts.map(r => {
3329
+ try {
3330
+ const payload = JSON.parse(r.payload?.content || '{}');
3331
+ return payload.text || JSON.stringify(payload);
3332
+ }
3333
+ catch {
3334
+ return '';
3335
+ }
3336
+ }).filter(Boolean).join('\n');
3337
+ const description = `[Attachment: ${attachments[i].type || 'file'} ${attachments[i].path}]${attachments[i].caption ? ' ' + attachments[i].caption : ''}\n${visiblePayload}`;
3338
+ this.store.confirmDeliveryStage(item.id, stage, description, attachmentReceipts, i + 1);
3142
3339
  }
3143
3340
  // Cleanup last, after all payload is visible. Cleanup-only rows and
3144
3341
  // attachment-only finals enter here as a separately retryable stage.
@@ -3163,13 +3360,49 @@ export class FeishuBot {
3163
3360
  }
3164
3361
  }
3165
3362
  this.store.markDeliveryDelivered(item.id);
3363
+ if (provenance?.final && provenance.runId) {
3364
+ this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3365
+ void this.reconcileReactions(chatId).catch(() => { });
3366
+ }
3166
3367
  }
3167
3368
  catch (err) {
3369
+ if (failureStage === 'text') {
3370
+ try {
3371
+ this.store.registerPartialBodyContext(item.id);
3372
+ }
3373
+ catch { /* Durable operation receipts retain recoverable context custody. */ }
3374
+ }
3375
+ try {
3376
+ this.store.registerPartialAttachmentContext(item.id);
3377
+ }
3378
+ catch { /* Confirmed payload remains in receipt custody. */ }
3168
3379
  const activeJob = deliveryJob;
3169
3380
  if (activeJob && receipts.length)
3170
3381
  this.store.recordGoDeliveryReceipt(activeJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
3171
3382
  if (this.isOutOfChatError(err))
3172
3383
  this.markCurrentBotUnavailable(chatId, err);
3384
+ if (err?.deliveryStopped) {
3385
+ this.store.markDeliveryFailed(item.id);
3386
+ return;
3387
+ }
3388
+ const localFailure = String(err?.code || '').startsWith('SQLITE_');
3389
+ if (localFailure || this.store.hasUnknownDeliveryOperation(item.id)) {
3390
+ let custody = this.localDeliveryRetryCustody.get(chatId);
3391
+ if (!custody) {
3392
+ custody = new Set();
3393
+ this.localDeliveryRetryCustody.set(chatId, custody);
3394
+ }
3395
+ custody.add(item.id);
3396
+ let failures = 10;
3397
+ try {
3398
+ failures = this.store.deferLocalDelivery(item.id, localFailure ? 'local_checkpoint' : 'unknown_requires_audit');
3399
+ custody.delete(item.id);
3400
+ }
3401
+ catch { /* Retry custody remains live even if SQLite cannot record it yet. */ }
3402
+ retryScheduled = true;
3403
+ this.scheduleDeliveryRetry(chatId, failures, true);
3404
+ return;
3405
+ }
3173
3406
  const willRetry = this.store.retryDelivery(item.id, DELIVERY_MAX_ATTEMPTS);
3174
3407
  if (willRetry) {
3175
3408
  retryScheduled = true;
@@ -3374,14 +3607,6 @@ export class FeishuBot {
3374
3607
  const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
3375
3608
  .filter(Boolean)
3376
3609
  .join("\n");
3377
- this.store.insert({
3378
- chatId,
3379
- messageId: `self-${this.config.name}-discuss-${Date.now()}-${Math.random().toString(36).slice(2)}`,
3380
- senderType: "bot",
3381
- senderName: this.config.name,
3382
- content: storedContent,
3383
- timestamp: Date.now(),
3384
- });
3385
3610
  try {
3386
3611
  await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
3387
3612
  }
@@ -4174,31 +4399,47 @@ export class FeishuBot {
4174
4399
  reaction_type: { emoji_type: emojiType },
4175
4400
  },
4176
4401
  });
4177
- return resp.data?.reaction_id;
4402
+ const id = resp.data?.reaction_id;
4403
+ if (resp.code !== 0 || !id)
4404
+ throw new Error('Reaction add receipt not confirmed');
4405
+ return id;
4178
4406
  }
4179
4407
  /**
4180
4408
  * Remove a reaction by emoji type from a message.
4181
4409
  * Finds the bot's own reaction of that type and deletes it.
4182
4410
  */
4183
- async removeReaction(messageId, emojiType) {
4184
- try {
4185
- const resp = await this.client.im.messageReaction.list({
4186
- path: { message_id: messageId },
4187
- params: { reaction_type: emojiType },
4188
- });
4189
- const items = resp.data?.items || [];
4190
- for (const item of items) {
4191
- if (item.reaction_id) {
4192
- await this.client.im.messageReaction.delete({
4193
- path: { message_id: messageId, reaction_id: item.reaction_id },
4194
- });
4195
- break;
4196
- }
4197
- }
4198
- }
4199
- catch {
4200
- // ignore
4411
+ async removeReaction(messageId, emojiType, reactionId) {
4412
+ const remove = async (id) => {
4413
+ const deleted = await this.client.im.messageReaction.delete({ path: { message_id: messageId, reaction_id: id } });
4414
+ if (deleted.code !== 0)
4415
+ throw new Error('Reaction removal not confirmed');
4416
+ };
4417
+ if (reactionId) {
4418
+ await remove(reactionId);
4419
+ return;
4201
4420
  }
4421
+ // Migration of pre-receipt records: never guess ownership from list order
4422
+ // or operator_type alone. Feishu operator_id is the bot's open identity.
4423
+ if (!this.botOpenId)
4424
+ throw new Error('Own reaction operator identity unavailable');
4425
+ let page;
4426
+ const seen = new Set();
4427
+ do {
4428
+ const response = await this.client.im.messageReaction.list({ path: { message_id: messageId }, params: { reaction_type: emojiType, page_token: page, page_size: 100 } });
4429
+ if (response.code !== 0)
4430
+ throw new Error('Reaction lookup not confirmed');
4431
+ const data = response.data;
4432
+ for (const item of data?.items || []) {
4433
+ if (item.reaction_id && item.operator?.operator_type === 'app' && item.operator.operator_id === this.botOpenId &&
4434
+ (!item.reaction_type?.emoji_type || item.reaction_type.emoji_type === emojiType))
4435
+ await remove(item.reaction_id);
4436
+ }
4437
+ page = data?.has_more ? data.page_token : undefined;
4438
+ if (data?.has_more && (!page || seen.has(page)))
4439
+ throw new Error('Incomplete reaction ownership pagination');
4440
+ if (page)
4441
+ seen.add(page);
4442
+ } while (page);
4202
4443
  }
4203
4444
  async handleLocaleCommand(chatId, chatType, messageId, text) {
4204
4445
  if (chatType === "p2p") {
@@ -4622,6 +4863,10 @@ export class FeishuBot {
4622
4863
  // Go supervision: a reset replaces the executor session generation; pause
4623
4864
  // supervision instead of letting the checker judge a wiped transcript.
4624
4865
  void this.goController?.onExecutorSessionChanged(chatId, "执行会话被 /reset");
4866
+ this.stopEpoch.set(chatId, this.store.bumpStopGeneration(this.config.name, chatId));
4867
+ this.store.stopInsertions(this.config.name, chatId);
4868
+ this.store.cancelReactionIntents(this.config.name, chatId, this.stopGeneration(chatId));
4869
+ void this.openclawClient.cancelSteerSession?.(sessionKey, this.stopGeneration(chatId)).catch(() => { });
4625
4870
  // Fire reset (no response expected)
4626
4871
  this.openclawClient.resetSession(sessionKey).catch(() => { });
4627
4872
  // Wait a moment for it to take effect
@@ -4648,19 +4893,22 @@ export class FeishuBot {
4648
4893
  try {
4649
4894
  // Bump the stop generation FIRST so any in-flight auto-retry loop sees the
4650
4895
  // cancellation on its next iteration and stops retrying immediately.
4651
- this.stopEpoch.set(chatId, (this.stopEpoch.get(chatId) || 0) + 1);
4652
- // Abort all active runs for this session (chat.abort with no runId).
4653
- await this.openclawClient.abortChat(sessionKey).catch(() => { });
4654
- // Force-unlock the busy gate so queued messages can be processed.
4655
- this.busyChats.set(chatId, 0);
4656
- // Drop every pending trigger for this bot/chat so the stuck run is not retried.
4896
+ this.stopEpoch.set(chatId, this.store.bumpStopGeneration(this.config.name, chatId));
4897
+ this.store.cancelReactionIntents(this.config.name, chatId, this.stopGeneration(chatId));
4898
+ void this.reconcileReactions(chatId).catch(() => { });
4899
+ this.store.stopInsertions(this.config.name, chatId);
4900
+ void this.openclawClient.cancelSteerSession?.(sessionKey, this.stopGeneration(chatId)).catch(() => { });
4901
+ this.forceExpireQueueOwner(chatId, 'explicit user stop');
4902
+ // Capture/retire only pre-stop custody synchronously. A later user
4903
+ // input must survive the abort RPC await and keep its own reaction list.
4657
4904
  const cleared = this.store.clearAllPendingTriggers(this.config.name, chatId);
4658
- // Clear stuck Typing/Get reactions on queued messages.
4659
4905
  const acks = this.pendingAckMessages.get(chatId) || [];
4660
- for (const ack of acks) {
4661
- await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
4662
- }
4663
4906
  this.pendingAckMessages.set(chatId, []);
4907
+ for (const ack of acks)
4908
+ void this.receiptReaction(chatId, ack.rowId, ack.messageId, '').catch(() => { });
4909
+ await this.openclawClient.abortChat(sessionKey).catch(() => { });
4910
+ if (!this.queueRuns.has(chatId))
4911
+ this.busyChats.set(chatId, 0);
4664
4912
  const locale = this.chatLocale(chatId);
4665
4913
  await this.replyMessage(messageId, locale === "en"
4666
4914
  ? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now.`