openclaw-lark-multi-agent 1.8.4 → 1.8.5
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/README.md +23 -17
- package/README.zh-CN.md +3 -1
- package/dist/cli.js +19 -20
- package/dist/feishu-bot.d.ts +10 -0
- package/dist/feishu-bot.js +611 -363
- package/dist/message-store.d.ts +63 -1
- package/dist/message-store.js +336 -8
- package/dist/openclaw-client.d.ts +50 -24
- package/dist/openclaw-client.js +355 -133
- package/dist/session-status.d.ts +2 -0
- package/dist/session-status.js +1 -0
- package/docs/releases/1.8.5.md +65 -0
- package/package.json +10 -4
- package/plugins/lma-core/README.md +83 -0
- package/plugins/lma-core/dist/compaction.js +82 -0
- package/plugins/lma-core/dist/go-policy.js +25 -0
- package/plugins/lma-core/dist/go-registry.js +37 -0
- package/plugins/lma-core/dist/go.js +42 -0
- package/plugins/lma-core/dist/index.js +29 -0
- package/plugins/lma-core/dist/steer.js +212 -0
- package/plugins/lma-core/dist/tooltrim.js +152 -0
- package/plugins/lma-core/openclaw.plugin.json +45 -0
- package/plugins/lma-core/package.json +15 -0
- package/plugins/lma-steer/README.md +7 -0
- package/scripts/audit-delivery-context.mjs +49 -0
package/dist/feishu-bot.js
CHANGED
|
@@ -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
|
|
173
|
+
if (!api)
|
|
171
174
|
continue;
|
|
172
|
-
this.goReceiptApis.add(api);
|
|
173
175
|
for (const operation of operations) {
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
176
|
+
const method = api[operation];
|
|
177
|
+
if (typeof method !== 'function' || this.goReceiptApis.has(method))
|
|
176
178
|
continue;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
204
|
-
if (context?.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
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 (
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
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
|
|
1493
|
-
this.
|
|
1494
|
-
const run = this.processQueueInner(chatId,
|
|
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,
|
|
1535
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
2007
|
-
await this.
|
|
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
|
-
|
|
2027
|
-
|
|
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
|
-
|
|
2121
|
-
await this.
|
|
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
|
-
|
|
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 ??
|
|
2522
|
-
const wasStopped = () =>
|
|
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
|
-
|
|
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}:${
|
|
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(),
|
|
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
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4185
|
-
const
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
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.
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
this.
|
|
4656
|
-
|
|
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.`
|