openclaw-lark-multi-agent 1.5.1 → 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.
@@ -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";
@@ -6,7 +7,9 @@ import { InactiveRunObservation } from "./openclaw-client.js";
6
7
  import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
7
8
  import { LiveStatusController } from "./live-status.js";
8
9
  import { CompactProgressController } from "./compact-progress.js";
10
+ import { GoController } from "./go-controller.js";
9
11
  import { errorIdentity } from "./terminal-errors.js";
12
+ import { formatProviderError, safeErrorSummary } from "./provider-errors.js";
10
13
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
11
14
  import { basename, extname, join, resolve } from "path";
12
15
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
@@ -137,6 +140,97 @@ export class FeishuBot {
137
140
  sendModelFooters = new Map();
138
141
  replyStatusFooters = new Map();
139
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();
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
+ }
233
+ goController = null;
140
234
  /** Per-chat durable outbox retry timer. */
141
235
  deliveryRetryTimers = new Map();
142
236
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
@@ -173,6 +267,7 @@ export class FeishuBot {
173
267
  appSecret: config.appSecret,
174
268
  appType: lark.AppType.SelfBuild,
175
269
  });
270
+ this.installGoReceiptCapture();
176
271
  this.wsClient = new lark.WSClient({
177
272
  appId: config.appId,
178
273
  appSecret: config.appSecret,
@@ -182,6 +277,55 @@ export class FeishuBot {
182
277
  "im.message.receive_v1": this.handleMessage.bind(this),
183
278
  "im.message.recalled_v1": this.handleMessageRecalled.bind(this),
184
279
  });
280
+ // Go / Go-Checker supervision (design: lma-go-checker-design-20260917).
281
+ this.goController = new GoController({
282
+ botName: config.name,
283
+ fallbackModel: config.model,
284
+ store,
285
+ client: openclawClient,
286
+ executorSessionKeyFor: (chatId) => this.getSessionKey(chatId),
287
+ notify: (chatId, text) => this.sendOrdered(chatId, () => this.sendMessage(chatId, text)),
288
+ reply: (messageId, text) => this.replyMessage(messageId, text),
289
+ deliverExecutorResult: (chatId, result, goId) => this.deliverGoExecutorResult(chatId, result, goId),
290
+ log: (msg) => console.log(msg),
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?.());
322
+ // Recovery runs shortly after boot: verify executor session generations
323
+ // for active Go jobs and re-arm pending checks.
324
+ const recoverTimer = setTimeout(() => {
325
+ void this.goController?.recoverOnStartup().catch((err) => console.warn(`[${config.name}] Go recovery failed:`, err.message));
326
+ }, 5000);
327
+ recoverTimer.unref?.();
328
+ this.store.onClose(() => { clearTimeout(recoverTimer); this.goController?.dispose(); });
185
329
  }
186
330
  async handleMessageRecalled(data) {
187
331
  console.log(`[${this.config.name}] Message recalled event:`, JSON.stringify(data));
@@ -276,7 +420,7 @@ export class FeishuBot {
276
420
  catch (err) {
277
421
  // Session/model setup is authoritative. Propagate policy/capability errors
278
422
  // so the queue can notify the user and never continue on the wrong model.
279
- console.warn(`[${this.config.name}] ensureSession error:`, err.message);
423
+ console.warn(`[${this.config.name}] ensureSession error:`, safeErrorSummary(err));
280
424
  throw err;
281
425
  }
282
426
  this.initializedSessions.add(sessionKey);
@@ -284,6 +428,11 @@ export class FeishuBot {
284
428
  this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
285
429
  await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
286
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;
287
436
  let provenance = meta?.error;
288
437
  if (!provenance && meta?.genericErrorCandidate && meta.runId && meta.sessionKey) {
289
438
  provenance = this.store.getErrorNotice(this.config.name, chatId, errorIdentity(meta.sessionKey, meta.runId))?.provenance;
@@ -570,7 +719,7 @@ export class FeishuBot {
570
719
  cleanText = commandText;
571
720
  }
572
721
  const preliminaryCommandName = cleanText.trim().split(/\s+/)[0]?.toLowerCase() || "";
573
- const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
722
+ const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale", "/go"]);
574
723
  const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
575
724
  // --- Record to local store (ALL messages, before command/response checks) ---
576
725
  const senderName = isBot
@@ -748,6 +897,7 @@ export class FeishuBot {
748
897
  `🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
749
898
  `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论(所有非 mute bot + Chairman 参与,忽略 free)`,
750
899
  `👑 /chairman @Bot|off — 设置/清除本群唯一 Chairman(状态见 /status)`,
900
+ `🎯 /go <目标>|status|pause|resume|stop — 独立 Go Checker 验收监督(执行 Agent 自称完成不等于验收通过)`,
751
901
  `🌐 /locale zh|en — 设置/查看当前群语言`,
752
902
  `❓ /help — 显示此帮助信息`,
753
903
  ``,
@@ -799,6 +949,27 @@ export class FeishuBot {
799
949
  markCommandSynced();
800
950
  return;
801
951
  }
952
+ if (commandName === "/go") {
953
+ // Go supervision targets one bot's executor session: groups require an
954
+ // explicit mention (single-bot group is the implicit target), p2p always.
955
+ const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
956
+ const goRoutedForMe = chatType === "p2p"
957
+ || (routing.hasTargetedMention ? routing.isCurrentBotMentioned : groupBotCount === 1);
958
+ if (goRoutedForMe) {
959
+ const goArgs = cleanText.trim().split(/\s+/).slice(1).join(" ").trim();
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
+ }
970
+ return;
971
+ }
972
+ }
802
973
  if (rejectModelAll) {
803
974
  await this.replyMessage(messageId, "❌ /model 是单 bot 设置,不能 @所有人。请明确 @ 一个 bot,例如:@Claude /model provider/model-id");
804
975
  markCommandSynced();
@@ -987,6 +1158,7 @@ export class FeishuBot {
987
1158
  if (!this.shouldRespond(chatType, message, isBot, chatId, routing))
988
1159
  return;
989
1160
  if (!isBot && insertedId > 0) {
1161
+ this.goController?.onHumanInput(chatId);
990
1162
  this.store.markPendingTrigger(this.config.name, chatId, insertedId);
991
1163
  }
992
1164
  // Track this message for reaction status updates
@@ -1044,17 +1216,68 @@ export class FeishuBot {
1044
1216
  });
1045
1217
  const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
1046
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
+ }
1047
1232
  if (insertedId > 0 && !consumedBeforeAck) {
1233
+ const steerStopEpoch = this.stopEpoch.get(chatId) || 0;
1234
+ this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
1048
1235
  this.pendingSteerCleanups.set(insertedId, () => {
1049
1236
  stopWatchingConsumption();
1050
1237
  if ("cancelPending" in steer)
1051
1238
  steer.cancelPending?.();
1052
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
+ }
1053
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);
1054
1278
  // The plugin confirmed queueing into the existing embedded run. Keep
1055
- // that run's original delivery target and retain the durable trigger
1056
- // until session.message proves this specific input was consumed.
1057
- 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)}..."`);
1058
1281
  return;
1059
1282
  }
1060
1283
  // Plugin missing/no active run/rejected: keep the durable trigger for
@@ -1086,7 +1309,7 @@ export class FeishuBot {
1086
1309
  await this.processQueue(chatId);
1087
1310
  }
1088
1311
  catch (err) {
1089
- console.error(`[${this.config.name}] Error:`, err);
1312
+ console.error(`[${this.config.name}] Error:`, safeErrorSummary(err));
1090
1313
  }
1091
1314
  }
1092
1315
  shouldUseLiveStatus(chatId, isNativeCommandTrigger = false) {
@@ -1194,6 +1417,19 @@ export class FeishuBot {
1194
1417
  * Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
1195
1418
  * Loops until no more unsynced human messages remain.
1196
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
+ }
1197
1433
  async processQueue(chatId) {
1198
1434
  const existing = this.queueRuns.get(chatId);
1199
1435
  if (existing) {
@@ -1202,12 +1438,18 @@ export class FeishuBot {
1202
1438
  }
1203
1439
  const run = this.processQueueInner(chatId).finally(() => {
1204
1440
  this.queueRuns.delete(chatId);
1441
+ this.runFailureObservers.delete(chatId);
1442
+ this.admittedSteerAcks.delete(chatId);
1205
1443
  });
1206
1444
  this.queueRuns.set(chatId, run);
1207
1445
  return run;
1208
1446
  }
1209
1447
  async processQueueInner(chatId) {
1210
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);
1211
1453
  const pendingMessages = this.store.getPendingTriggerMessages(this.config.name, chatId);
1212
1454
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
1213
1455
  // Only proceed if there are pending human messages that should actively trigger this bot.
@@ -1364,7 +1606,7 @@ export class FeishuBot {
1364
1606
  // busy marker, report the actionable error, and retire only this batch
1365
1607
  // so the chat cannot remain wedged indefinitely.
1366
1608
  this.busyChats.set(chatId, 0);
1367
- const errorText = this.formatUserVisibleError(err);
1609
+ const errorText = this.formatUserVisibleError(err, chatId);
1368
1610
  if (lastHuman.messageId) {
1369
1611
  await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:session-setup-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:session-setup-error`).catch(() => { });
1370
1612
  }
@@ -1387,6 +1629,8 @@ export class FeishuBot {
1387
1629
  ack.emoji = "Get";
1388
1630
  }
1389
1631
  }
1632
+ let foregroundRunId;
1633
+ const foregroundSessionId = this.store.getActiveGoJob(this.config.name, chatId)?.executorSessionId;
1390
1634
  let runAcceptedByOpenClaw = false;
1391
1635
  let liveStatus;
1392
1636
  try {
@@ -1442,6 +1686,7 @@ export class FeishuBot {
1442
1686
  markSubmittedBatch("send-attempt");
1443
1687
  },
1444
1688
  onSubmitted: (runId) => {
1689
+ foregroundRunId = runId;
1445
1690
  runAcceptedByOpenClaw = true;
1446
1691
  const target = this.activeDeliveryTargets.get(chatId);
1447
1692
  if (target?.triggerId === triggerId) {
@@ -1593,7 +1838,7 @@ export class FeishuBot {
1593
1838
  // Keep live status and final answer as separate messages. The final
1594
1839
  // answer is created at completion time so it remains at the bottom
1595
1840
  // even when the user inserted more messages during the run.
1596
- 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);
1597
1842
  await liveStatus?.complete();
1598
1843
  // Mark every merged trigger as delivered so retries/restarts do
1599
1844
  // not re-process any of them.
@@ -1601,6 +1846,9 @@ export class FeishuBot {
1601
1846
  this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
1602
1847
  // After a successful reply, warn the user once if context is high.
1603
1848
  void this.maybeAlertHighContext(chatId);
1849
+ // Go supervision: record the delivered final reply as evidence and
1850
+ // arm a debounced checker round (busy gates apply inside).
1851
+ // Actual body/attachment receipts are recorded by dispatchPendingDeliveries.
1604
1852
  }
1605
1853
  catch (err) {
1606
1854
  // enqueueAndDispatchDelivery already sent a user-visible delivery
@@ -1614,6 +1862,8 @@ export class FeishuBot {
1614
1862
  if (isRuntimeFailure && lastHuman.messageId) {
1615
1863
  await liveStatus?.fail().catch(() => { });
1616
1864
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1865
+ // Go supervision: terminal runtime failure is still checkable evidence.
1866
+ this.goController?.notifyRunOutcome(chatId, "run_error", `trigger:${triggerId}`, visibleReply || "(运行失败,无用户可见正文)");
1617
1867
  }
1618
1868
  else if (!shouldReply && !hasAttachments) {
1619
1869
  // A proactive session.message may already own this trigger and carry
@@ -1654,6 +1904,9 @@ export class FeishuBot {
1654
1904
  await liveStatus?.noReply().catch(() => { });
1655
1905
  }
1656
1906
  }
1907
+ // Go supervision: an explicit NO_REPLY round is still a terminal
1908
+ // outcome the checker may need to see (e.g. executor stayed silent).
1909
+ this.goController?.notifyRunOutcome(chatId, "no_reply", `trigger:${triggerId}`, "(本轮无用户可见回复 NO_REPLY)");
1657
1910
  }
1658
1911
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1659
1912
  // Replace ack reactions with DONE only for trigger messages actually
@@ -1671,6 +1924,18 @@ export class FeishuBot {
1671
1924
  }
1672
1925
  }
1673
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
+ }
1674
1939
  }
1675
1940
  catch (err) {
1676
1941
  let pause = err instanceof SessionWaitPaused ? err : undefined;
@@ -1698,6 +1963,7 @@ export class FeishuBot {
1698
1963
  break;
1699
1964
  }
1700
1965
  if (err instanceof InactiveRunObservation) {
1966
+ this.fireRunFailureObservers(chatId);
1701
1967
  await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
1702
1968
  if (!liveStatus?.id && lastHuman.messageId)
1703
1969
  await this.replyMessage(lastHuman.messageId, `ℹ️ ${err.message}`).catch(() => { });
@@ -1711,6 +1977,7 @@ export class FeishuBot {
1711
1977
  break;
1712
1978
  }
1713
1979
  if (err instanceof UnhealthySessionError) {
1980
+ this.fireRunFailureObservers(chatId);
1714
1981
  // stopForUnhealthySession already warned the user; mark the live
1715
1982
  // status as interrupted. The user can send another message to retry
1716
1983
  // without being forced through /reset.
@@ -1718,8 +1985,9 @@ export class FeishuBot {
1718
1985
  console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
1719
1986
  break;
1720
1987
  }
1721
- console.error(`[${this.config.name}] processQueue error:`, err);
1722
- 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);
1723
1991
  if (lastHuman.messageId) {
1724
1992
  // Error message goes through the normal delivery path; the live status
1725
1993
  // is a separate message that we mark interrupted afterwards.
@@ -1978,25 +2246,10 @@ export class FeishuBot {
1978
2246
  const key = triggerId ? `trigger:${triggerId}:wait-paused` : `wait-paused:${notice.snapshot.checkedAt}`;
1979
2247
  await this.enqueueAndDispatchDelivery(chatId, "wait_paused", key, notice.message, [], messageId, key, this.config.model);
1980
2248
  }
1981
- formatUserVisibleError(err) {
2249
+ formatUserVisibleError(err, chatId) {
1982
2250
  if (err instanceof SessionWaitPaused)
1983
2251
  return err.message;
1984
- const raw = err instanceof Error ? err.message : String(err);
1985
- let reason = raw.replace(/\s+/g, " ").trim();
1986
- if (/quota/i.test(reason) || /exceeded/i.test(reason)) {
1987
- const reset = reason.match(/reset at ([^.;]+)/i)?.[1];
1988
- reason = reset ? `模型/供应商额度已用尽,重置时间:${reset}` : "模型/供应商额度已用尽,请稍后重试或切换模型";
1989
- }
1990
- else if (/timeout/i.test(reason)) {
1991
- reason = "模型响应超时,请稍后重试";
1992
- }
1993
- else if (/schema|tool payload|rejected/i.test(reason)) {
1994
- reason = "模型供应商拒绝了请求格式或工具参数,需要调整模型/工具调用";
1995
- }
1996
- else if (reason.length > 220) {
1997
- reason = reason.slice(0, 220) + "...";
1998
- }
1999
- return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
2252
+ return formatProviderError(err, this.config.name, this.config.model, this.chatLocale(chatId));
2000
2253
  }
2001
2254
  isBridgeControlReply(text) {
2002
2255
  const t = text.trim();
@@ -2023,7 +2276,7 @@ export class FeishuBot {
2023
2276
  const t = text.trim();
2024
2277
  if (!t)
2025
2278
  return false;
2026
- return /^\/(help|status|compact|reset|stop|verbose|livestatus|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
2279
+ return /^\/(help|status|compact|reset|stop|verbose|livestatus|free|mute|mode|model|models|discuss|chairman|locale|go)(\s|$)/i.test(t)
2027
2280
  || this.isBridgeControlReply(t);
2028
2281
  }
2029
2282
  isRuntimeFailureText(text) {
@@ -2494,7 +2747,7 @@ export class FeishuBot {
2494
2747
  replyToMessageId: replyToMessageId || "",
2495
2748
  deliveryMode: "send",
2496
2749
  targetMessageId: "",
2497
- deliveryMetaJson: "{}",
2750
+ deliveryMetaJson,
2498
2751
  });
2499
2752
  }
2500
2753
  // Dispatch recovery/correction/supplement rows together in creation order.
@@ -2520,6 +2773,11 @@ export class FeishuBot {
2520
2773
  catch {
2521
2774
  verdict = { state: "pending", reason: "inspection_unavailable" };
2522
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
+ }
2523
2781
  // A stop or final may have arrived during the read-only lookup.
2524
2782
  const latest = this.store.getErrorNotice(this.config.name, record.chatId, record.noticeId);
2525
2783
  if (latest?.provenance.userStop) {
@@ -2637,18 +2895,56 @@ export class FeishuBot {
2637
2895
  timer.unref?.();
2638
2896
  this.deliveryRetryTimers.set(chatId, timer);
2639
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
+ }
2640
2920
  async dispatchPendingDeliveries(chatId, replyToMessageId) {
2921
+ this.installGoReceiptCapture();
2641
2922
  let retryScheduled = false;
2642
2923
  while (true) {
2643
2924
  const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
2644
2925
  if (pending.length === 0)
2645
2926
  break;
2646
2927
  for (const item of pending) {
2647
- 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 () => {
2648
2937
  if (!item.id || !this.store.claimDelivery(item.id))
2649
2938
  return;
2650
2939
  let failureStage = "text";
2651
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 || "[]") });
2652
2948
  const attachments = JSON.parse(item.attachmentsJson || "[]");
2653
2949
  // Final text is always sent as a NEW message. Never patch it into the
2654
2950
  // older live-status card: user messages inserted during the run must
@@ -2721,6 +3017,9 @@ export class FeishuBot {
2721
3017
  failureStage = "attachment";
2722
3018
  const cursor = Math.max(0, item.attachmentCursor || 0);
2723
3019
  for (let i = cursor; i < attachments.length; i++) {
3020
+ const context = this.goDeliveryReceipts.getStore();
3021
+ if (context)
3022
+ context.stage = `attachment:${i}`;
2724
3023
  await this.sendBridgeAttachment(chatId, attachments[i]);
2725
3024
  this.store.markDeliveryAttachmentCursor(item.id, i + 1);
2726
3025
  }
@@ -2735,9 +3034,23 @@ export class FeishuBot {
2735
3034
  await this.patchLiveStatusDoneSummary(item.targetMessageId, meta, chatId);
2736
3035
  this.store.markDeliveryCleanupPending(item.id, false);
2737
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
+ }
2738
3048
  this.store.markDeliveryDelivered(item.id);
2739
3049
  }
2740
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 });
2741
3054
  if (this.isOutOfChatError(err))
2742
3055
  this.markCurrentBotUnavailable(chatId, err);
2743
3056
  const willRetry = this.store.retryDelivery(item.id, DELIVERY_MAX_ATTEMPTS);
@@ -2771,7 +3084,7 @@ export class FeishuBot {
2771
3084
  console.warn(`[${this.config.name}] Failed to notify terminal delivery error:`, this.errorSummary(notifyErr));
2772
3085
  }
2773
3086
  }
2774
- });
3087
+ }));
2775
3088
  }
2776
3089
  // Drain subsequent pages immediately after successful batches. If any row
2777
3090
  // was returned to pending, honor its backoff timer instead of hot-looping.
@@ -3186,12 +3499,23 @@ export class FeishuBot {
3186
3499
  async deleteMessageById(messageId) {
3187
3500
  await this.client.im.v1.message.delete({ path: { message_id: messageId } });
3188
3501
  }
3502
+ /** Resolve the Go supervision footer marker for a reply target via the store. */
3503
+ goFooterForReply(messageId) {
3504
+ try {
3505
+ const chatId = this.store.getMessageByMessageId(messageId)?.chatId;
3506
+ return chatId ? this.goController?.footerMarker(chatId) ?? null : null;
3507
+ }
3508
+ catch {
3509
+ return null;
3510
+ }
3511
+ }
3189
3512
  async replyFinalMessage(messageId, text, model, status) {
3190
3513
  if (!model?.trim())
3191
3514
  return this.replyMessage(messageId, text);
3192
3515
  this.replyModelFooters.set(messageId, model.trim());
3193
- if (status)
3194
- this.replyStatusFooters.set(messageId, status);
3516
+ const effStatus = [status?.trim(), this.goFooterForReply(messageId)].filter(Boolean).join(" · ") || undefined;
3517
+ if (effStatus)
3518
+ this.replyStatusFooters.set(messageId, effStatus);
3195
3519
  try {
3196
3520
  return await this.replyMessage(messageId, text);
3197
3521
  }
@@ -3591,8 +3915,9 @@ export class FeishuBot {
3591
3915
  if (!model?.trim())
3592
3916
  return this.sendMessage(chatId, text);
3593
3917
  this.sendModelFooters.set(chatId, model.trim());
3594
- if (status)
3595
- this.sendStatusFooters.set(chatId, status);
3918
+ const effStatus = [status?.trim(), this.goController?.footerMarker(chatId) ?? null].filter(Boolean).join(" · ") || undefined;
3919
+ if (effStatus)
3920
+ this.sendStatusFooters.set(chatId, effStatus);
3596
3921
  try {
3597
3922
  return await this.sendMessage(chatId, text);
3598
3923
  }
@@ -4135,6 +4460,9 @@ export class FeishuBot {
4135
4460
  async handleResetCommand(chatId, messageId) {
4136
4461
  const sessionKey = this.getSessionKey(chatId);
4137
4462
  try {
4463
+ // Go supervision: a reset replaces the executor session generation; pause
4464
+ // supervision instead of letting the checker judge a wiped transcript.
4465
+ void this.goController?.onExecutorSessionChanged(chatId, "执行会话被 /reset");
4138
4466
  // Fire reset (no response expected)
4139
4467
  this.openclawClient.resetSession(sessionKey).catch(() => { });
4140
4468
  // Wait a moment for it to take effect
@@ -4155,6 +4483,9 @@ export class FeishuBot {
4155
4483
  */
4156
4484
  async handleStopCommand(chatId, messageId) {
4157
4485
  const sessionKey = this.getSessionKey(chatId);
4486
+ // Go supervision: a user stop must also pause related Go, so the checker
4487
+ // cannot wake the just-stopped task back up with a follow-up.
4488
+ void this.goController?.pauseSilently(chatId, "用户执行了 /stop");
4158
4489
  try {
4159
4490
  // Bump the stop generation FIRST so any in-flight auto-retry loop sees the
4160
4491
  // cancellation on its next iteration and stops retrying immediately.