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 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)。
@@ -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;
@@ -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.message);
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
- await this.goController?.handleGoCommand(chatId, messageId, goArgs);
832
- markCommandSynced();
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 and retain the durable trigger
1090
- // until session.message proves this specific input was consumed.
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
- this.goController?.notifyRunOutcome(chatId, "assistant_reply", `trigger:${triggerId}`, shouldReply ? visibleReply : "(仅附件回复)");
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
- const errorText = this.formatUserVisibleError(err);
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
- const raw = err instanceof Error ? err.message : String(err);
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
- await this.sendOrdered(chatId, async () => {
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.