openclaw-lark-multi-agent 1.6.0 → 1.7.0
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 +11 -0
- package/README.zh-CN.md +10 -0
- package/dist/feishu-bot.d.ts +16 -0
- package/dist/feishu-bot.js +303 -32
- package/dist/go-controller.d.ts +86 -12
- package/dist/go-controller.js +435 -131
- package/dist/go-execution.d.ts +10 -0
- package/dist/go-execution.js +1 -0
- package/dist/go-runtime-gate.d.ts +32 -0
- package/dist/go-runtime-gate.js +56 -0
- package/dist/message-store.d.ts +83 -0
- package/dist/message-store.js +142 -2
- package/dist/openclaw-client.d.ts +114 -0
- package/dist/openclaw-client.js +166 -12
- package/dist/provider-errors.d.ts +24 -0
- package/dist/provider-errors.js +344 -0
- package/dist/terminal-errors.d.ts +3 -0
- package/dist/terminal-errors.js +2 -1
- package/package.json +8 -3
- package/plugins/lma-go/README.md +22 -0
- package/plugins/lma-go/dist/index.js +35 -0
- package/plugins/lma-go/dist/policy.js +25 -0
- package/plugins/lma-go/dist/registry.js +37 -0
- package/plugins/lma-go/openclaw.plugin.json +1 -0
- package/plugins/lma-go/package.json +1 -0
package/README.md
CHANGED
|
@@ -626,3 +626,14 @@ This does not shorten the Gateway's execution limit. Unverified steer V2 and
|
|
|
626
626
|
question adapters are not included in this release.
|
|
627
627
|
|
|
628
628
|
Timing/API parameters and limitations: [foreground idle semantics](docs/foreground-idle-2026-09-11.md).
|
|
629
|
+
|
|
630
|
+
### Independent Go Checker
|
|
631
|
+
|
|
632
|
+
`/go <objective>` sets the goal, starts a dedicated tool-denied acceptance Checker,
|
|
633
|
+
and submits the original objective once at a safely observed idle boundary. Initial
|
|
634
|
+
and follow-up executor results use LMA’s durable body/attachment outbox with actual
|
|
635
|
+
platform receipts; no extra execution message is required. `/go status`,
|
|
636
|
+
`/go pause|resume|stop`, `/go model <provider/model>` control it. LMA's MessageStore
|
|
637
|
+
owns objective, criteria, revision and completion, independent of native Goal APIs.
|
|
638
|
+
Enable the included `lma-go` plugin first (operator-owned). Dynamic Checker binding
|
|
639
|
+
is enforced before model admission. See [integration boundaries and local activation](docs/go-integration-boundaries.md).
|
package/README.zh-CN.md
CHANGED
|
@@ -530,3 +530,13 @@ MIT
|
|
|
530
530
|
steer V2 与问答适配器。
|
|
531
531
|
|
|
532
532
|
计时、API 参数及限制详见 [前台静默计时说明](docs/foreground-idle-2026-09-11.md)。
|
|
533
|
+
|
|
534
|
+
### 独立 Go Checker
|
|
535
|
+
|
|
536
|
+
`/go <目标>` 设定目标、创建专用验收 Checker,并在安全空闲边界首次提交原目标,
|
|
537
|
+
无需再发一条执行消息。首次与自动跟进的执行结果统一通过 LMA 正文/附件持久化 outbox
|
|
538
|
+
投递,实际飞书消息回执才成为验收证据;`/go status`、`/go pause|resume|stop`、
|
|
539
|
+
`/go model <provider/model>` 控制监督。目标、验收条件、revision 和完成权由 LMA
|
|
540
|
+
MessageStore 管理,不依赖或修改原生 Goal。需由操作者启用随仓库提供的 `lma-go`
|
|
541
|
+
插件;动态绑定后真实禁止 Checker 全部工具。详见
|
|
542
|
+
[能力边界和本机激活步骤](docs/go-integration-boundaries.md)。
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -42,7 +42,18 @@ export declare class FeishuBot {
|
|
|
42
42
|
private sendModelFooters;
|
|
43
43
|
private replyStatusFooters;
|
|
44
44
|
private sendStatusFooters;
|
|
45
|
+
/** Batch-scoped run-failure observers: steered inputs whose owning batch
|
|
46
|
+
* fails restore their durable fallback. Fired in processQueueInner's
|
|
47
|
+
* failure arms; dropped at each new batch top and on queue exit. */
|
|
48
|
+
private runFailureObservers;
|
|
49
|
+
/** Steered messages admitted into the CURRENT batch (per chat). Flipped to
|
|
50
|
+
* DONE when that batch completes successfully; cleared (unflipped) when the
|
|
51
|
+
* batch scope ends any other way. */
|
|
52
|
+
private admittedSteerAcks;
|
|
45
53
|
/** Go / Go-Checker supervision controller (per-bot instance). */
|
|
54
|
+
private readonly goDeliveryReceipts;
|
|
55
|
+
private readonly goReceiptApis;
|
|
56
|
+
private installGoReceiptCapture;
|
|
46
57
|
private goController;
|
|
47
58
|
/** Per-chat durable outbox retry timer. */
|
|
48
59
|
private deliveryRetryTimers;
|
|
@@ -123,6 +134,8 @@ export declare class FeishuBot {
|
|
|
123
134
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
124
135
|
* Loops until no more unsynced human messages remain.
|
|
125
136
|
*/
|
|
137
|
+
/** Fire and clear run-failure observers for a chat (isolated callbacks). */
|
|
138
|
+
private fireRunFailureObservers;
|
|
126
139
|
private processQueue;
|
|
127
140
|
private processQueueInner;
|
|
128
141
|
private shouldHandleBridgeCommand;
|
|
@@ -211,6 +224,9 @@ export declare class FeishuBot {
|
|
|
211
224
|
/** Bounded read-only diagnosis; known live tasks retain low-frequency observation. */
|
|
212
225
|
private reconcileErrorNotices;
|
|
213
226
|
private scheduleDeliveryRetry;
|
|
227
|
+
/** Both initial and CONTINUE results enter the same ordinary LMA outbox.
|
|
228
|
+
* No host proactive-send assumption, no fake platform receipt. */
|
|
229
|
+
private deliverGoExecutorResult;
|
|
214
230
|
private dispatchPendingDeliveries;
|
|
215
231
|
private hasFreeModeBot;
|
|
216
232
|
private mentionedBotNames;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import * as lark from "@larksuiteoapi/node-sdk";
|
|
2
3
|
import { createRequire } from "module";
|
|
3
4
|
import { persistBotModel } from "./config.js";
|
|
@@ -8,6 +9,7 @@ import { LiveStatusController } from "./live-status.js";
|
|
|
8
9
|
import { CompactProgressController } from "./compact-progress.js";
|
|
9
10
|
import { GoController } from "./go-controller.js";
|
|
10
11
|
import { errorIdentity } from "./terminal-errors.js";
|
|
12
|
+
import { formatProviderError, safeErrorSummary } from "./provider-errors.js";
|
|
11
13
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
12
14
|
import { basename, extname, join, resolve } from "path";
|
|
13
15
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
@@ -138,7 +140,96 @@ export class FeishuBot {
|
|
|
138
140
|
sendModelFooters = new Map();
|
|
139
141
|
replyStatusFooters = new Map();
|
|
140
142
|
sendStatusFooters = new Map();
|
|
143
|
+
/** Batch-scoped run-failure observers: steered inputs whose owning batch
|
|
144
|
+
* fails restore their durable fallback. Fired in processQueueInner's
|
|
145
|
+
* failure arms; dropped at each new batch top and on queue exit. */
|
|
146
|
+
runFailureObservers = new Map();
|
|
147
|
+
/** Steered messages admitted into the CURRENT batch (per chat). Flipped to
|
|
148
|
+
* DONE when that batch completes successfully; cleared (unflipped) when the
|
|
149
|
+
* batch scope ends any other way. */
|
|
150
|
+
admittedSteerAcks = new Map();
|
|
141
151
|
/** Go / Go-Checker supervision controller (per-bot instance). */
|
|
152
|
+
goDeliveryReceipts = new AsyncLocalStorage();
|
|
153
|
+
goReceiptApis = new WeakSet();
|
|
154
|
+
installGoReceiptCapture() {
|
|
155
|
+
const docx = this.client.docx;
|
|
156
|
+
const candidates = [
|
|
157
|
+
...Array.from(new Set([this.client.im.message, this.client.im.v1?.message].filter(Boolean))).map(api => ({ api, operations: ["create", "reply"], visible: true })),
|
|
158
|
+
{ api: this.client.im.file, operations: ["create"], visible: false },
|
|
159
|
+
{ api: this.client.im.image, operations: ["create"], visible: false },
|
|
160
|
+
{ api: docx?.document, operations: ["create", "convert"], visible: false },
|
|
161
|
+
{ api: docx?.documentBlockDescendant, operations: ["create"], visible: false },
|
|
162
|
+
];
|
|
163
|
+
for (const { api, operations, visible } of candidates) {
|
|
164
|
+
if (!api || this.goReceiptApis.has(api))
|
|
165
|
+
continue;
|
|
166
|
+
this.goReceiptApis.add(api);
|
|
167
|
+
for (const operation of operations) {
|
|
168
|
+
const original = api[operation]?.bind(api);
|
|
169
|
+
if (!original)
|
|
170
|
+
continue;
|
|
171
|
+
api[operation] = async (params) => {
|
|
172
|
+
const context = this.goDeliveryReceipts.getStore();
|
|
173
|
+
const ordinal = context?.ordinals.get(context.stage) ?? 0;
|
|
174
|
+
context?.ordinals.set(context.stage, ordinal + 1);
|
|
175
|
+
const key = `outbox:${context?.outboxId}:${context?.stage}:${ordinal}`;
|
|
176
|
+
const previous = context?.goId ? this.store.getGoDeliveryOperation(context.goId, key) : null;
|
|
177
|
+
if (previous?.state === "rejected") {
|
|
178
|
+
const failure = JSON.parse(previous.response_json);
|
|
179
|
+
throw Object.assign(new Error(failure.message), { code: failure.code, response: { data: { code: failure.code, msg: failure.message } } });
|
|
180
|
+
}
|
|
181
|
+
if (context?.blocked || (previous && previous.state !== "accepted")) {
|
|
182
|
+
// Carry a durable unknown fence across *all* fallback operations after
|
|
183
|
+
// restart too, not merely the original failed card/attachment ordinal.
|
|
184
|
+
if (context)
|
|
185
|
+
context.blocked = true;
|
|
186
|
+
throw new Error("Go delivery outcome unknown; original operation cannot be replayed");
|
|
187
|
+
}
|
|
188
|
+
let result;
|
|
189
|
+
if (previous?.state === "accepted")
|
|
190
|
+
result = JSON.parse(previous.response_json).result;
|
|
191
|
+
else {
|
|
192
|
+
if (context?.goId)
|
|
193
|
+
this.store.recordGoDeliveryOperation(context.goId, key, "attempted");
|
|
194
|
+
try {
|
|
195
|
+
result = await original(params);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (context?.goId) {
|
|
199
|
+
const error = err;
|
|
200
|
+
const code = error?.response?.data?.code ?? error?.data?.code ?? error?.code;
|
|
201
|
+
const status = error?.response?.status;
|
|
202
|
+
// A documented API rejection is not a lost response. Cache it so
|
|
203
|
+
// deterministic card/text fallback can proceed without repeating it.
|
|
204
|
+
if (Number.isFinite(Number(code)) && Number(code) > 0 || Number.isInteger(status) && status >= 400 && status < 500) {
|
|
205
|
+
this.store.recordGoDeliveryOperation(context.goId, key, "rejected", { code, message: error?.response?.data?.msg ?? error?.message ?? "platform rejected" });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
this.store.recordGoDeliveryOperation(context.goId, key, "unknown");
|
|
209
|
+
context.blocked = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const id = result?.data?.message_id ?? result?.message_id;
|
|
216
|
+
if (visible && context?.goId && !id) {
|
|
217
|
+
this.store.recordGoDeliveryOperation(context.goId, key, "unknown");
|
|
218
|
+
context.blocked = true;
|
|
219
|
+
throw new Error("Missing actual Go platform message receipt");
|
|
220
|
+
}
|
|
221
|
+
if (context?.goId)
|
|
222
|
+
this.store.recordGoDeliveryOperation(context.goId, key, "accepted", { result, payload: visible ? params.data : undefined, operation });
|
|
223
|
+
if (visible && context && typeof id === "string" && id) {
|
|
224
|
+
if (!context.receipts.some(r => r.messageId === id))
|
|
225
|
+
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 });
|
|
226
|
+
context.onReceipt(); // Persist before the next chunk or attachment operation.
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
142
233
|
goController = null;
|
|
143
234
|
/** Per-chat durable outbox retry timer. */
|
|
144
235
|
deliveryRetryTimers = new Map();
|
|
@@ -176,6 +267,7 @@ export class FeishuBot {
|
|
|
176
267
|
appSecret: config.appSecret,
|
|
177
268
|
appType: lark.AppType.SelfBuild,
|
|
178
269
|
});
|
|
270
|
+
this.installGoReceiptCapture();
|
|
179
271
|
this.wsClient = new lark.WSClient({
|
|
180
272
|
appId: config.appId,
|
|
181
273
|
appSecret: config.appSecret,
|
|
@@ -194,14 +286,46 @@ export class FeishuBot {
|
|
|
194
286
|
executorSessionKeyFor: (chatId) => this.getSessionKey(chatId),
|
|
195
287
|
notify: (chatId, text) => this.sendOrdered(chatId, () => this.sendMessage(chatId, text)),
|
|
196
288
|
reply: (messageId, text) => this.replyMessage(messageId, text),
|
|
289
|
+
deliverExecutorResult: (chatId, result, goId) => this.deliverGoExecutorResult(chatId, result, goId),
|
|
197
290
|
log: (msg) => console.log(msg),
|
|
198
291
|
});
|
|
292
|
+
const unobserveGo = this.openclawClient.observeGoEvidence?.(({ channel, payload }) => {
|
|
293
|
+
const event = payload;
|
|
294
|
+
const exactKey = event?.sessionKey;
|
|
295
|
+
const sessionId = event?.sessionId;
|
|
296
|
+
const runId = event?.runId;
|
|
297
|
+
// Never infer generation or run identity from a prefix or current-session lookup.
|
|
298
|
+
if (typeof exactKey !== "string" || typeof sessionId !== "string" || typeof runId !== "string")
|
|
299
|
+
return;
|
|
300
|
+
for (const job of this.store.listActiveGoJobs(this.config.name)) {
|
|
301
|
+
if (exactKey !== job.executorSessionKey && exactKey !== `agent:main:${job.executorSessionKey}`)
|
|
302
|
+
continue;
|
|
303
|
+
if (sessionId !== job.executorSessionId)
|
|
304
|
+
continue;
|
|
305
|
+
const sourceKey = `${channel}:${runId}:${event.message?.id ?? event.id ?? event.seq ?? "unidentified"}`;
|
|
306
|
+
if (sourceKey.endsWith(":unidentified"))
|
|
307
|
+
continue;
|
|
308
|
+
const message = event.message;
|
|
309
|
+
const text = message?.role === "assistant" && Array.isArray(message.content)
|
|
310
|
+
? message.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n")
|
|
311
|
+
: event.stream === "assistant" ? (event.data?.text ?? event.data?.delta ?? "") : "";
|
|
312
|
+
const terminal = event.stream === "lifecycle" && ["end", "error"].includes(event.data?.phase);
|
|
313
|
+
if (!text && !terminal && !event.attachments?.length)
|
|
314
|
+
continue;
|
|
315
|
+
this.store.recordGoVisibleEvidence(job.id, sourceKey, { sessionId, runId, kind: channel,
|
|
316
|
+
text, attachments: event.attachments ?? message?.attachments ?? [] });
|
|
317
|
+
if (terminal)
|
|
318
|
+
this.goController?.notifyRunOutcome(job.chatId, "no_reply", sourceKey, "精确 run 终局状态更新;不等于投递/验收通过");
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
this.store.onClose(() => unobserveGo?.());
|
|
199
322
|
// Recovery runs shortly after boot: verify executor session generations
|
|
200
323
|
// for active Go jobs and re-arm pending checks.
|
|
201
324
|
const recoverTimer = setTimeout(() => {
|
|
202
325
|
void this.goController?.recoverOnStartup().catch((err) => console.warn(`[${config.name}] Go recovery failed:`, err.message));
|
|
203
326
|
}, 5000);
|
|
204
327
|
recoverTimer.unref?.();
|
|
328
|
+
this.store.onClose(() => { clearTimeout(recoverTimer); this.goController?.dispose(); });
|
|
205
329
|
}
|
|
206
330
|
async handleMessageRecalled(data) {
|
|
207
331
|
console.log(`[${this.config.name}] Message recalled event:`, JSON.stringify(data));
|
|
@@ -296,7 +420,7 @@ export class FeishuBot {
|
|
|
296
420
|
catch (err) {
|
|
297
421
|
// Session/model setup is authoritative. Propagate policy/capability errors
|
|
298
422
|
// so the queue can notify the user and never continue on the wrong model.
|
|
299
|
-
console.warn(`[${this.config.name}] ensureSession error:`, err
|
|
423
|
+
console.warn(`[${this.config.name}] ensureSession error:`, safeErrorSummary(err));
|
|
300
424
|
throw err;
|
|
301
425
|
}
|
|
302
426
|
this.initializedSessions.add(sessionKey);
|
|
@@ -304,6 +428,11 @@ export class FeishuBot {
|
|
|
304
428
|
this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
|
|
305
429
|
await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
|
|
306
430
|
try {
|
|
431
|
+
// The Controller owns exact Go executor finals. Streams/proactive callbacks
|
|
432
|
+
// can race collection (including after a bridge restart); never let that
|
|
433
|
+
// second path create a second body/attachment outbox owner.
|
|
434
|
+
if (meta?.runId && meta?.sessionId && this.store.ownsGoExecutorRun(this.config.name, chatId, meta.sessionKey || "", meta.sessionId, meta.runId))
|
|
435
|
+
return;
|
|
307
436
|
let provenance = meta?.error;
|
|
308
437
|
if (!provenance && meta?.genericErrorCandidate && meta.runId && meta.sessionKey) {
|
|
309
438
|
provenance = this.store.getErrorNotice(this.config.name, chatId, errorIdentity(meta.sessionKey, meta.runId))?.provenance;
|
|
@@ -828,8 +957,16 @@ export class FeishuBot {
|
|
|
828
957
|
|| (routing.hasTargetedMention ? routing.isCurrentBotMentioned : groupBotCount === 1);
|
|
829
958
|
if (goRoutedForMe) {
|
|
830
959
|
const goArgs = cleanText.trim().split(/\s+/).slice(1).join(" ").trim();
|
|
831
|
-
|
|
832
|
-
|
|
960
|
+
try {
|
|
961
|
+
const control = /^(?:status|pause|resume|stop|model)(?:\s|$)/i.test(goArgs) || !goArgs;
|
|
962
|
+
if (!control)
|
|
963
|
+
await this.ensureSession(chatId);
|
|
964
|
+
await this.goController?.handleGoCommand(chatId, messageId, goArgs);
|
|
965
|
+
}
|
|
966
|
+
finally {
|
|
967
|
+
markCommandSynced();
|
|
968
|
+
this.goController?.onCommandSettled(chatId);
|
|
969
|
+
}
|
|
833
970
|
return;
|
|
834
971
|
}
|
|
835
972
|
}
|
|
@@ -1021,6 +1158,7 @@ export class FeishuBot {
|
|
|
1021
1158
|
if (!this.shouldRespond(chatType, message, isBot, chatId, routing))
|
|
1022
1159
|
return;
|
|
1023
1160
|
if (!isBot && insertedId > 0) {
|
|
1161
|
+
this.goController?.onHumanInput(chatId);
|
|
1024
1162
|
this.store.markPendingTrigger(this.config.name, chatId, insertedId);
|
|
1025
1163
|
}
|
|
1026
1164
|
// Track this message for reaction status updates
|
|
@@ -1078,17 +1216,68 @@ export class FeishuBot {
|
|
|
1078
1216
|
});
|
|
1079
1217
|
const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
|
|
1080
1218
|
if (steer.status === "steered") {
|
|
1219
|
+
// OpenClaw 2026.9.9 no longer mirrors plugin-injected user messages as
|
|
1220
|
+
// session.message events, so the transcript-consumption observer may
|
|
1221
|
+
// never fire. Custody transfers to the active run at admission: clear
|
|
1222
|
+
// the durable trigger now (prevents the duplicate re-send after this
|
|
1223
|
+
// run ends) and arm an abnormal-end fallback that restores it.
|
|
1224
|
+
if (insertedId > 0) {
|
|
1225
|
+
// Consumption can land before admission returns (fast mirror) or
|
|
1226
|
+
// never (2026.9.9 removed mirrors); either way the message belongs
|
|
1227
|
+
// to this batch for the Get -> DONE flip.
|
|
1228
|
+
const steerAckList = this.admittedSteerAcks.get(chatId) || [];
|
|
1229
|
+
steerAckList.push({ messageId, insertedId });
|
|
1230
|
+
this.admittedSteerAcks.set(chatId, steerAckList);
|
|
1231
|
+
}
|
|
1081
1232
|
if (insertedId > 0 && !consumedBeforeAck) {
|
|
1233
|
+
const steerStopEpoch = this.stopEpoch.get(chatId) || 0;
|
|
1234
|
+
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
1082
1235
|
this.pendingSteerCleanups.set(insertedId, () => {
|
|
1083
1236
|
stopWatchingConsumption();
|
|
1084
1237
|
if ("cancelPending" in steer)
|
|
1085
1238
|
steer.cancelPending?.();
|
|
1086
1239
|
});
|
|
1240
|
+
// The owning batch's failure restores the durable fallback. The
|
|
1241
|
+
// queue promise itself never rejects (processQueueInner isolates
|
|
1242
|
+
// errors), so failures are observed via runFailureObservers.
|
|
1243
|
+
const observers = this.runFailureObservers.get(chatId) || [];
|
|
1244
|
+
observers.push(() => {
|
|
1245
|
+
if (consumedBeforeAck)
|
|
1246
|
+
return;
|
|
1247
|
+
// A /stop during the run must not resurrect stopped input.
|
|
1248
|
+
if ((this.stopEpoch.get(chatId) || 0) !== steerStopEpoch)
|
|
1249
|
+
return;
|
|
1250
|
+
this.store.markPendingTrigger(this.config.name, chatId, insertedId);
|
|
1251
|
+
// The restored input takes the ordinary queue path: drop its
|
|
1252
|
+
// admitted-steer ack so only the new batch's DONE applies.
|
|
1253
|
+
this.admittedSteerAcks.set(chatId, (this.admittedSteerAcks.get(chatId) || []).filter((a) => a.insertedId !== insertedId));
|
|
1254
|
+
console.log(`[${this.config.name}] steer fallback restored after failed run for ${chatId.slice(-8)}: "${cleanText.substring(0, 40)}..."`);
|
|
1255
|
+
// Deferred: the current queue run is still active here; a fresh
|
|
1256
|
+
// drain must start after its finally releases the chat.
|
|
1257
|
+
setTimeout(() => { void this.processQueue(chatId).catch(() => { }); }, 0).unref?.();
|
|
1258
|
+
});
|
|
1259
|
+
this.runFailureObservers.set(chatId, observers);
|
|
1260
|
+
const owner = this.queueRuns.get(chatId);
|
|
1261
|
+
if (owner) {
|
|
1262
|
+
void owner.then(() => {
|
|
1263
|
+
// Queue ended without a batch failure: the steered input was
|
|
1264
|
+
// admitted into its batch; release the provisional correlation.
|
|
1265
|
+
stopWatchingConsumption();
|
|
1266
|
+
this.pendingSteerCleanups.delete(insertedId);
|
|
1267
|
+
}).catch(() => { });
|
|
1268
|
+
}
|
|
1087
1269
|
}
|
|
1270
|
+
// Custody transferred: acknowledge on Feishu immediately.
|
|
1271
|
+
void this.removeReaction(messageId, "Typing").catch(() => { });
|
|
1272
|
+
void this.addReaction(messageId, "Get").catch(() => { });
|
|
1273
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
1274
|
+
for (const a of acks)
|
|
1275
|
+
if (a.messageId === messageId)
|
|
1276
|
+
a.emoji = "Get";
|
|
1277
|
+
this.pendingAckMessages.set(chatId, acks);
|
|
1088
1278
|
// The plugin confirmed queueing into the existing embedded run. Keep
|
|
1089
|
-
// that run's original delivery target
|
|
1090
|
-
|
|
1091
|
-
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, plugin steer accepted (awaiting transcript consumption): "${cleanText.substring(0, 50)}..."`);
|
|
1279
|
+
// that run's original delivery target.
|
|
1280
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, plugin steer accepted (custody transferred to active run): "${cleanText.substring(0, 50)}..."`);
|
|
1092
1281
|
return;
|
|
1093
1282
|
}
|
|
1094
1283
|
// Plugin missing/no active run/rejected: keep the durable trigger for
|
|
@@ -1120,7 +1309,7 @@ export class FeishuBot {
|
|
|
1120
1309
|
await this.processQueue(chatId);
|
|
1121
1310
|
}
|
|
1122
1311
|
catch (err) {
|
|
1123
|
-
console.error(`[${this.config.name}] Error:`, err);
|
|
1312
|
+
console.error(`[${this.config.name}] Error:`, safeErrorSummary(err));
|
|
1124
1313
|
}
|
|
1125
1314
|
}
|
|
1126
1315
|
shouldUseLiveStatus(chatId, isNativeCommandTrigger = false) {
|
|
@@ -1228,6 +1417,19 @@ export class FeishuBot {
|
|
|
1228
1417
|
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
1229
1418
|
* Loops until no more unsynced human messages remain.
|
|
1230
1419
|
*/
|
|
1420
|
+
/** Fire and clear run-failure observers for a chat (isolated callbacks). */
|
|
1421
|
+
fireRunFailureObservers(chatId) {
|
|
1422
|
+
const obs = this.runFailureObservers.get(chatId);
|
|
1423
|
+
if (!obs || obs.length === 0)
|
|
1424
|
+
return;
|
|
1425
|
+
this.runFailureObservers.delete(chatId);
|
|
1426
|
+
for (const cb of obs) {
|
|
1427
|
+
try {
|
|
1428
|
+
cb();
|
|
1429
|
+
}
|
|
1430
|
+
catch { /* observer failures are isolated */ }
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1231
1433
|
async processQueue(chatId) {
|
|
1232
1434
|
const existing = this.queueRuns.get(chatId);
|
|
1233
1435
|
if (existing) {
|
|
@@ -1236,12 +1438,18 @@ export class FeishuBot {
|
|
|
1236
1438
|
}
|
|
1237
1439
|
const run = this.processQueueInner(chatId).finally(() => {
|
|
1238
1440
|
this.queueRuns.delete(chatId);
|
|
1441
|
+
this.runFailureObservers.delete(chatId);
|
|
1442
|
+
this.admittedSteerAcks.delete(chatId);
|
|
1239
1443
|
});
|
|
1240
1444
|
this.queueRuns.set(chatId, run);
|
|
1241
1445
|
return run;
|
|
1242
1446
|
}
|
|
1243
1447
|
async processQueueInner(chatId) {
|
|
1244
1448
|
while (true) {
|
|
1449
|
+
// A fresh batch starts: steered inputs admitted into the PREVIOUS batch
|
|
1450
|
+
// completed with it (consumed or not); their failure observers are stale.
|
|
1451
|
+
this.runFailureObservers.delete(chatId);
|
|
1452
|
+
this.admittedSteerAcks.delete(chatId);
|
|
1245
1453
|
const pendingMessages = this.store.getPendingTriggerMessages(this.config.name, chatId);
|
|
1246
1454
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
|
|
1247
1455
|
// Only proceed if there are pending human messages that should actively trigger this bot.
|
|
@@ -1398,7 +1606,7 @@ export class FeishuBot {
|
|
|
1398
1606
|
// busy marker, report the actionable error, and retire only this batch
|
|
1399
1607
|
// so the chat cannot remain wedged indefinitely.
|
|
1400
1608
|
this.busyChats.set(chatId, 0);
|
|
1401
|
-
const errorText = this.formatUserVisibleError(err);
|
|
1609
|
+
const errorText = this.formatUserVisibleError(err, chatId);
|
|
1402
1610
|
if (lastHuman.messageId) {
|
|
1403
1611
|
await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:session-setup-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:session-setup-error`).catch(() => { });
|
|
1404
1612
|
}
|
|
@@ -1421,6 +1629,8 @@ export class FeishuBot {
|
|
|
1421
1629
|
ack.emoji = "Get";
|
|
1422
1630
|
}
|
|
1423
1631
|
}
|
|
1632
|
+
let foregroundRunId;
|
|
1633
|
+
const foregroundSessionId = this.store.getActiveGoJob(this.config.name, chatId)?.executorSessionId;
|
|
1424
1634
|
let runAcceptedByOpenClaw = false;
|
|
1425
1635
|
let liveStatus;
|
|
1426
1636
|
try {
|
|
@@ -1476,6 +1686,7 @@ export class FeishuBot {
|
|
|
1476
1686
|
markSubmittedBatch("send-attempt");
|
|
1477
1687
|
},
|
|
1478
1688
|
onSubmitted: (runId) => {
|
|
1689
|
+
foregroundRunId = runId;
|
|
1479
1690
|
runAcceptedByOpenClaw = true;
|
|
1480
1691
|
const target = this.activeDeliveryTargets.get(chatId);
|
|
1481
1692
|
if (target?.triggerId === triggerId) {
|
|
@@ -1627,7 +1838,7 @@ export class FeishuBot {
|
|
|
1627
1838
|
// Keep live status and final answer as separate messages. The final
|
|
1628
1839
|
// answer is created at completion time so it remains at the bottom
|
|
1629
1840
|
// even when the user inserted more messages during the run.
|
|
1630
|
-
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);
|
|
1841
|
+
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);
|
|
1631
1842
|
await liveStatus?.complete();
|
|
1632
1843
|
// Mark every merged trigger as delivered so retries/restarts do
|
|
1633
1844
|
// not re-process any of them.
|
|
@@ -1637,7 +1848,7 @@ export class FeishuBot {
|
|
|
1637
1848
|
void this.maybeAlertHighContext(chatId);
|
|
1638
1849
|
// Go supervision: record the delivered final reply as evidence and
|
|
1639
1850
|
// arm a debounced checker round (busy gates apply inside).
|
|
1640
|
-
|
|
1851
|
+
// Actual body/attachment receipts are recorded by dispatchPendingDeliveries.
|
|
1641
1852
|
}
|
|
1642
1853
|
catch (err) {
|
|
1643
1854
|
// enqueueAndDispatchDelivery already sent a user-visible delivery
|
|
@@ -1713,6 +1924,18 @@ export class FeishuBot {
|
|
|
1713
1924
|
}
|
|
1714
1925
|
}
|
|
1715
1926
|
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
1927
|
+
// Steered messages admitted into THIS batch were consumed with it:
|
|
1928
|
+
// flip their Get → DONE alongside the batch's own triggers.
|
|
1929
|
+
const admittedSteers = this.admittedSteerAcks.get(chatId) || [];
|
|
1930
|
+
if (admittedSteers.length > 0) {
|
|
1931
|
+
this.admittedSteerAcks.delete(chatId);
|
|
1932
|
+
for (const s of admittedSteers) {
|
|
1933
|
+
await this.removeReaction(s.messageId, "Get").catch(() => { });
|
|
1934
|
+
await this.addReaction(s.messageId, "DONE").catch(() => { });
|
|
1935
|
+
}
|
|
1936
|
+
const acksAfter = this.pendingAckMessages.get(chatId) || [];
|
|
1937
|
+
this.pendingAckMessages.set(chatId, acksAfter.filter((a) => !admittedSteers.some((s) => a.messageId === s.messageId)));
|
|
1938
|
+
}
|
|
1716
1939
|
}
|
|
1717
1940
|
catch (err) {
|
|
1718
1941
|
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
@@ -1740,6 +1963,7 @@ export class FeishuBot {
|
|
|
1740
1963
|
break;
|
|
1741
1964
|
}
|
|
1742
1965
|
if (err instanceof InactiveRunObservation) {
|
|
1966
|
+
this.fireRunFailureObservers(chatId);
|
|
1743
1967
|
await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
|
|
1744
1968
|
if (!liveStatus?.id && lastHuman.messageId)
|
|
1745
1969
|
await this.replyMessage(lastHuman.messageId, `ℹ️ ${err.message}`).catch(() => { });
|
|
@@ -1753,6 +1977,7 @@ export class FeishuBot {
|
|
|
1753
1977
|
break;
|
|
1754
1978
|
}
|
|
1755
1979
|
if (err instanceof UnhealthySessionError) {
|
|
1980
|
+
this.fireRunFailureObservers(chatId);
|
|
1756
1981
|
// stopForUnhealthySession already warned the user; mark the live
|
|
1757
1982
|
// status as interrupted. The user can send another message to retry
|
|
1758
1983
|
// without being forced through /reset.
|
|
@@ -1760,8 +1985,9 @@ export class FeishuBot {
|
|
|
1760
1985
|
console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
|
|
1761
1986
|
break;
|
|
1762
1987
|
}
|
|
1763
|
-
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
1764
|
-
|
|
1988
|
+
console.error(`[${this.config.name}] processQueue error:`, safeErrorSummary(err));
|
|
1989
|
+
this.fireRunFailureObservers(chatId);
|
|
1990
|
+
const errorText = this.formatUserVisibleError(err, chatId);
|
|
1765
1991
|
if (lastHuman.messageId) {
|
|
1766
1992
|
// Error message goes through the normal delivery path; the live status
|
|
1767
1993
|
// is a separate message that we mark interrupted afterwards.
|
|
@@ -2020,25 +2246,10 @@ export class FeishuBot {
|
|
|
2020
2246
|
const key = triggerId ? `trigger:${triggerId}:wait-paused` : `wait-paused:${notice.snapshot.checkedAt}`;
|
|
2021
2247
|
await this.enqueueAndDispatchDelivery(chatId, "wait_paused", key, notice.message, [], messageId, key, this.config.model);
|
|
2022
2248
|
}
|
|
2023
|
-
formatUserVisibleError(err) {
|
|
2249
|
+
formatUserVisibleError(err, chatId) {
|
|
2024
2250
|
if (err instanceof SessionWaitPaused)
|
|
2025
2251
|
return err.message;
|
|
2026
|
-
|
|
2027
|
-
let reason = raw.replace(/\s+/g, " ").trim();
|
|
2028
|
-
if (/quota/i.test(reason) || /exceeded/i.test(reason)) {
|
|
2029
|
-
const reset = reason.match(/reset at ([^.;]+)/i)?.[1];
|
|
2030
|
-
reason = reset ? `模型/供应商额度已用尽,重置时间:${reset}` : "模型/供应商额度已用尽,请稍后重试或切换模型";
|
|
2031
|
-
}
|
|
2032
|
-
else if (/timeout/i.test(reason)) {
|
|
2033
|
-
reason = "模型响应超时,请稍后重试";
|
|
2034
|
-
}
|
|
2035
|
-
else if (/schema|tool payload|rejected/i.test(reason)) {
|
|
2036
|
-
reason = "模型供应商拒绝了请求格式或工具参数,需要调整模型/工具调用";
|
|
2037
|
-
}
|
|
2038
|
-
else if (reason.length > 220) {
|
|
2039
|
-
reason = reason.slice(0, 220) + "...";
|
|
2040
|
-
}
|
|
2041
|
-
return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
|
|
2252
|
+
return formatProviderError(err, this.config.name, this.config.model, this.chatLocale(chatId));
|
|
2042
2253
|
}
|
|
2043
2254
|
isBridgeControlReply(text) {
|
|
2044
2255
|
const t = text.trim();
|
|
@@ -2536,7 +2747,7 @@ export class FeishuBot {
|
|
|
2536
2747
|
replyToMessageId: replyToMessageId || "",
|
|
2537
2748
|
deliveryMode: "send",
|
|
2538
2749
|
targetMessageId: "",
|
|
2539
|
-
deliveryMetaJson
|
|
2750
|
+
deliveryMetaJson,
|
|
2540
2751
|
});
|
|
2541
2752
|
}
|
|
2542
2753
|
// Dispatch recovery/correction/supplement rows together in creation order.
|
|
@@ -2562,6 +2773,11 @@ export class FeishuBot {
|
|
|
2562
2773
|
catch {
|
|
2563
2774
|
verdict = { state: "pending", reason: "inspection_unavailable" };
|
|
2564
2775
|
}
|
|
2776
|
+
// Presentation only: leave exact-run finality, stop suppression and durable
|
|
2777
|
+
// outbox keys owned by the existing verdict; no retry/model fallback here.
|
|
2778
|
+
if (verdict.state === "terminal" && verdict.reason === "ordinary_gateway_error") {
|
|
2779
|
+
verdict = { ...verdict, text: this.formatUserVisibleError(record.provenance.detail, record.chatId) };
|
|
2780
|
+
}
|
|
2565
2781
|
// A stop or final may have arrived during the read-only lookup.
|
|
2566
2782
|
const latest = this.store.getErrorNotice(this.config.name, record.chatId, record.noticeId);
|
|
2567
2783
|
if (latest?.provenance.userStop) {
|
|
@@ -2679,18 +2895,56 @@ export class FeishuBot {
|
|
|
2679
2895
|
timer.unref?.();
|
|
2680
2896
|
this.deliveryRetryTimers.set(chatId, timer);
|
|
2681
2897
|
}
|
|
2898
|
+
/** Both initial and CONTINUE results enter the same ordinary LMA outbox.
|
|
2899
|
+
* No host proactive-send assumption, no fake platform receipt. */
|
|
2900
|
+
async deliverGoExecutorResult(chatId, result, goId) {
|
|
2901
|
+
const source = `go-run:${result.sessionId}:${result.runId}`;
|
|
2902
|
+
if (result.disposition === "silent" || result.disposition === "empty") {
|
|
2903
|
+
this.goController?.notifyExactRunOutcome(goId, "no_reply", source, result.disposition === "silent" ? "精确执行 run 已终局:NO_REPLY(无用户可见正文)" : "精确执行 run 已终局:terminalReply.empty(真无正文,非未确认)", result.sessionId, result.runId);
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
const failure = result.disposition === "error";
|
|
2907
|
+
const parsed = this.extractBridgeAttachments(failure ? `⚠️ Go 执行运行已确认终局错误\n原因:${result.error || "unknown"}` : result.text);
|
|
2908
|
+
if (parsed.text.trim() === "NO_REPLY")
|
|
2909
|
+
parsed.text = "";
|
|
2910
|
+
if (!parsed.text.trim() && !parsed.attachments.length) {
|
|
2911
|
+
this.goController?.notifyExactRunOutcome(goId, "no_reply", source, "精确执行 run 已终局,但解析后无正文/附件", result.sessionId, result.runId);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
await this.enqueueAndDispatchDelivery(chatId, failure ? "go_executor_error" : "assistant_visible", source, parsed.text.trim(), parsed.attachments, undefined, source, this.config.model, { sessionKey: result.sessionKey, sessionId: result.sessionId, runId: result.runId, final: true, goJobId: goId });
|
|
2915
|
+
// Pending/rejected/unknown platform responses must not be mistaken for
|
|
2916
|
+
// visible evidence. The outbox retains its own safe receipt recovery path.
|
|
2917
|
+
if (this.store.getDeliveryByKey(this.config.name, chatId, source)?.status !== "delivered")
|
|
2918
|
+
throw new Error("Go executor outbox delivery pending/unconfirmed");
|
|
2919
|
+
}
|
|
2682
2920
|
async dispatchPendingDeliveries(chatId, replyToMessageId) {
|
|
2921
|
+
this.installGoReceiptCapture();
|
|
2683
2922
|
let retryScheduled = false;
|
|
2684
2923
|
while (true) {
|
|
2685
2924
|
const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
|
|
2686
2925
|
if (pending.length === 0)
|
|
2687
2926
|
break;
|
|
2688
2927
|
for (const item of pending) {
|
|
2689
|
-
|
|
2928
|
+
const ownerId = JSON.parse(item.deliveryMetaJson || "{}").provenance?.goJobId;
|
|
2929
|
+
const deliveryJob = ownerId ? this.store.getGoJobById(ownerId) : this.store.getActiveGoJob(this.config.name, chatId);
|
|
2930
|
+
const receipts = [
|
|
2931
|
+
...(deliveryJob ? this.store.listGoVisibleEvidence(deliveryJob.id).find(e => e.sourceKey === `outbox:${item.id}`)?.receipt?.messages ?? [] : []),
|
|
2932
|
+
];
|
|
2933
|
+
await this.sendOrdered(chatId, () => this.goDeliveryReceipts.run({ receipts, goId: deliveryJob?.id, outboxId: item.id, stage: "body", ordinals: new Map(), onReceipt: () => {
|
|
2934
|
+
if (deliveryJob)
|
|
2935
|
+
this.store.recordGoDeliveryReceipt(deliveryJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
|
|
2936
|
+
} }, async () => {
|
|
2690
2937
|
if (!item.id || !this.store.claimDelivery(item.id))
|
|
2691
2938
|
return;
|
|
2692
2939
|
let failureStage = "text";
|
|
2693
2940
|
try {
|
|
2941
|
+
const job = deliveryJob;
|
|
2942
|
+
const provenance = JSON.parse(item.deliveryMetaJson || "{}").provenance;
|
|
2943
|
+
const goSource = `outbox:${item.id}`;
|
|
2944
|
+
const eligible = job && ["assistant_visible", "verbose_transcript", "run_error", "provider_error", "delayed_error", "proactive", "go_executor_error"].some(k => item.sourceType.startsWith(k));
|
|
2945
|
+
if (eligible)
|
|
2946
|
+
this.store.recordGoVisibleEvidence(job.id, goSource, { sessionId: provenance?.sessionId ?? "",
|
|
2947
|
+
runId: provenance?.runId ?? "", kind: item.sourceType, text: item.content, attachments: JSON.parse(item.attachmentsJson || "[]") });
|
|
2694
2948
|
const attachments = JSON.parse(item.attachmentsJson || "[]");
|
|
2695
2949
|
// Final text is always sent as a NEW message. Never patch it into the
|
|
2696
2950
|
// older live-status card: user messages inserted during the run must
|
|
@@ -2763,6 +3017,9 @@ export class FeishuBot {
|
|
|
2763
3017
|
failureStage = "attachment";
|
|
2764
3018
|
const cursor = Math.max(0, item.attachmentCursor || 0);
|
|
2765
3019
|
for (let i = cursor; i < attachments.length; i++) {
|
|
3020
|
+
const context = this.goDeliveryReceipts.getStore();
|
|
3021
|
+
if (context)
|
|
3022
|
+
context.stage = `attachment:${i}`;
|
|
2766
3023
|
await this.sendBridgeAttachment(chatId, attachments[i]);
|
|
2767
3024
|
this.store.markDeliveryAttachmentCursor(item.id, i + 1);
|
|
2768
3025
|
}
|
|
@@ -2777,9 +3034,23 @@ export class FeishuBot {
|
|
|
2777
3034
|
await this.patchLiveStatusDoneSummary(item.targetMessageId, meta, chatId);
|
|
2778
3035
|
this.store.markDeliveryCleanupPending(item.id, false);
|
|
2779
3036
|
}
|
|
3037
|
+
if (eligible && receipts.length) {
|
|
3038
|
+
this.store.recordGoDeliveryReceipt(job.id, goSource, { messages: receipts, outboxId: item.id });
|
|
3039
|
+
if (provenance?.runId && provenance?.sessionId === job.executorSessionId) {
|
|
3040
|
+
const kind = ["run_error", "provider_error", "delayed_error", "go_executor_error"].some(k => item.sourceType.startsWith(k)) ? "run_error" : "assistant_reply";
|
|
3041
|
+
const evidence = JSON.stringify({ text: item.content, attachments, receipt: receipts });
|
|
3042
|
+
if (ownerId)
|
|
3043
|
+
this.goController?.notifyExactRunOutcome(job.id, kind, goSource, evidence, provenance.sessionId, provenance.runId);
|
|
3044
|
+
else
|
|
3045
|
+
this.goController?.notifyRunOutcome(chatId, kind, goSource, evidence);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
2780
3048
|
this.store.markDeliveryDelivered(item.id);
|
|
2781
3049
|
}
|
|
2782
3050
|
catch (err) {
|
|
3051
|
+
const activeJob = deliveryJob;
|
|
3052
|
+
if (activeJob && receipts.length)
|
|
3053
|
+
this.store.recordGoDeliveryReceipt(activeJob.id, `outbox:${item.id}`, { messages: receipts, partial: true });
|
|
2783
3054
|
if (this.isOutOfChatError(err))
|
|
2784
3055
|
this.markCurrentBotUnavailable(chatId, err);
|
|
2785
3056
|
const willRetry = this.store.retryDelivery(item.id, DELIVERY_MAX_ATTEMPTS);
|
|
@@ -2813,7 +3084,7 @@ export class FeishuBot {
|
|
|
2813
3084
|
console.warn(`[${this.config.name}] Failed to notify terminal delivery error:`, this.errorSummary(notifyErr));
|
|
2814
3085
|
}
|
|
2815
3086
|
}
|
|
2816
|
-
});
|
|
3087
|
+
}));
|
|
2817
3088
|
}
|
|
2818
3089
|
// Drain subsequent pages immediately after successful batches. If any row
|
|
2819
3090
|
// was returned to pending, honor its backoff timer instead of hot-looping.
|