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.
- package/README.md +11 -0
- package/README.zh-CN.md +10 -0
- package/dist/feishu-bot.d.ts +20 -0
- package/dist/feishu-bot.js +366 -35
- package/dist/go-controller.d.ts +148 -0
- package/dist/go-controller.js +838 -0
- 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 +142 -0
- package/dist/message-store.js +310 -0
- 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/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";
|
|
@@ -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
|
|
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
|
|
1056
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3194
|
-
|
|
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
|
-
|
|
3595
|
-
|
|
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.
|