openclaw-lark-multi-agent 1.4.1 → 1.4.3

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.
@@ -23,6 +23,15 @@ export type LiveStatusView = {
23
23
  /** Terminal state: running / done / failed (lets renderer pick color). */
24
24
  state: "running" | "done" | "failed";
25
25
  };
26
+ /** Persisted metadata needed to turn the live-status card into the final answer. */
27
+ export type LiveStatusFinalMeta = {
28
+ messageId: string;
29
+ toolCalls: number;
30
+ elapsed: string;
31
+ model?: string;
32
+ locale: "zh" | "en";
33
+ noReply?: boolean;
34
+ };
26
35
  export type LiveStatusCallbacks = {
27
36
  /** Create the status card; returns the new message id. */
28
37
  create: (view: LiveStatusView) => Promise<string | undefined>;
@@ -71,6 +80,14 @@ export declare class LiveStatusController {
71
80
  start(initialDetail?: string): void;
72
81
  progress(event: ProgressEvent | string): Promise<void>;
73
82
  complete(): Promise<void>;
83
+ /**
84
+ * Freeze any successful terminal state (text, attachment-only, or NO_REPLY)
85
+ * without first emitting the compact done patch. The caller persists this
86
+ * metadata in the outbox, which owns the final patch/cleanup. If no card was
87
+ * created, the caller falls back to normal new-message delivery.
88
+ */
89
+ prepareTerminalDelivery(noReply?: boolean): Promise<LiveStatusFinalMeta | undefined>;
90
+ prepareFinalDelivery(): Promise<LiveStatusFinalMeta | undefined>;
74
91
  fail(): Promise<void>;
75
92
  /**
76
93
  * Finish when the model explicitly produced no reply (NO_REPLY) or an empty
@@ -109,6 +109,33 @@ export class LiveStatusController {
109
109
  return;
110
110
  await this.safeEditFinal(this.buildView());
111
111
  }
112
+ /**
113
+ * Freeze any successful terminal state (text, attachment-only, or NO_REPLY)
114
+ * without first emitting the compact done patch. The caller persists this
115
+ * metadata in the outbox, which owns the final patch/cleanup. If no card was
116
+ * created, the caller falls back to normal new-message delivery.
117
+ */
118
+ async prepareTerminalDelivery(noReply = false) {
119
+ this.state = "done";
120
+ this.noReplyResult = noReply;
121
+ this.finalized = true;
122
+ this.stopTimers();
123
+ if (this.createPromise)
124
+ await this.createPromise.catch(() => { });
125
+ if (!this.messageId)
126
+ return undefined;
127
+ return {
128
+ messageId: this.messageId,
129
+ toolCalls: this.toolCallCount,
130
+ elapsed: this.formatElapsed(),
131
+ model: this.opts.model,
132
+ locale: this.opts.locale === "en" ? "en" : "zh",
133
+ noReply,
134
+ };
135
+ }
136
+ async prepareFinalDelivery() {
137
+ return this.prepareTerminalDelivery(false);
138
+ }
112
139
  async fail() {
113
140
  this.state = "failed";
114
141
  this.finalized = true;
@@ -41,6 +41,19 @@ export interface DeliveryOutboxItem {
41
41
  content: string;
42
42
  attachmentsJson: string;
43
43
  replyToMessageId: string;
44
+ /** Delivery mode. `patch_live_status` turns the existing live-status card
45
+ * into the final answer instead of creating a second Feishu message. */
46
+ deliveryMode?: "send" | "patch_live_status";
47
+ /** Existing interactive-card message id to patch for patch_live_status. */
48
+ targetMessageId?: string;
49
+ /** Persisted JSON footer metadata (tool calls / elapsed / model / locale). */
50
+ deliveryMetaJson?: string;
51
+ /** Durable stage checkpoint: final text/card is already visible. */
52
+ textDelivered?: boolean;
53
+ /** Durable stage checkpoint: number of attachments already delivered. */
54
+ attachmentCursor?: number;
55
+ /** Final status-card cleanup (done summary after fallback/dedupe) is pending. */
56
+ cleanupPending?: boolean;
44
57
  status: "pending" | "delivering" | "delivered" | "failed";
45
58
  attempts: number;
46
59
  createdAt: number;
@@ -69,6 +82,8 @@ export declare class MessageStore {
69
82
  markDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number, replyMessageId?: string): void;
70
83
  enqueueDelivery(item: Omit<DeliveryOutboxItem, "id" | "status" | "attempts" | "createdAt" | "updatedAt">): number | null;
71
84
  getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
85
+ getDeliveryByKey(botName: string, chatId: string, deliveryKey: string): DeliveryOutboxItem | null;
86
+ listDeliveriesByKeyPrefix(botName: string, chatId: string, prefix: string): DeliveryOutboxItem[];
72
87
  getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
73
88
  markBotSeenInChat(botName: string, chatId: string): void;
74
89
  hasBotSeenInChat(botName: string, chatId: string): boolean;
@@ -78,13 +93,27 @@ export declare class MessageStore {
78
93
  hasAnyDeliveredToChat(botName: string, chatId: string): boolean;
79
94
  hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
80
95
  hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
96
+ /** Recover in-flight rows after process restart. A new process cannot own a
97
+ * legitimate delivering row, so every matching row returns to pending—even if
98
+ * the previous process crashed on its nominal final attempt. The restarted
99
+ * process gets another chance to finish rather than silently losing payload. */
100
+ resetDeliveringOnStartup(botName: string): number;
81
101
  resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
82
102
  restored: number;
83
103
  failed: number;
84
104
  };
85
105
  claimDelivery(id: number): boolean;
106
+ markDeliveryTextDelivered(id: number): void;
107
+ /** Atomically checkpoint fallback text and the remaining card cleanup. */
108
+ markDeliveryTextDeliveredWithCleanup(id: number): void;
109
+ markDeliveryAttachmentCursor(id: number, cursor: number): void;
110
+ /** Return a failed in-flight delivery to pending until its retry budget is spent. */
111
+ retryDelivery(id: number, maxAttempts: number): boolean;
112
+ markDeliveryCleanupPending(id: number, pending: boolean): void;
86
113
  markDeliveryDelivered(id: number): void;
87
114
  markDeliveryFailed(id: number): void;
115
+ /** Give a later explicit duplicate another chance after terminal failure. */
116
+ requeueFailedDelivery(id: number): boolean;
88
117
  private mapDelivery;
89
118
  /**
90
119
  * Get messages that haven't been synced to a bot's session yet.
@@ -148,6 +148,12 @@ export class MessageStore {
148
148
  content TEXT NOT NULL DEFAULT '',
149
149
  attachments_json TEXT NOT NULL DEFAULT '[]',
150
150
  reply_to_message_id TEXT NOT NULL DEFAULT '',
151
+ delivery_mode TEXT NOT NULL DEFAULT 'send',
152
+ target_message_id TEXT NOT NULL DEFAULT '',
153
+ delivery_meta_json TEXT NOT NULL DEFAULT '{}',
154
+ text_delivered INTEGER NOT NULL DEFAULT 0,
155
+ attachment_cursor INTEGER NOT NULL DEFAULT 0,
156
+ cleanup_pending INTEGER NOT NULL DEFAULT 0,
151
157
  status TEXT NOT NULL DEFAULT 'pending',
152
158
  attempts INTEGER NOT NULL DEFAULT 0,
153
159
  created_at INTEGER NOT NULL,
@@ -183,6 +189,42 @@ export class MessageStore {
183
189
  catch {
184
190
  // Column already exists
185
191
  }
192
+ try {
193
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'send'`);
194
+ }
195
+ catch {
196
+ // Column already exists
197
+ }
198
+ try {
199
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN target_message_id TEXT NOT NULL DEFAULT ''`);
200
+ }
201
+ catch {
202
+ // Column already exists
203
+ }
204
+ try {
205
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_meta_json TEXT NOT NULL DEFAULT '{}'`);
206
+ }
207
+ catch {
208
+ // Column already exists
209
+ }
210
+ try {
211
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN text_delivered INTEGER NOT NULL DEFAULT 0`);
212
+ }
213
+ catch {
214
+ // Column already exists
215
+ }
216
+ try {
217
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN attachment_cursor INTEGER NOT NULL DEFAULT 0`);
218
+ }
219
+ catch {
220
+ // Column already exists
221
+ }
222
+ try {
223
+ this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN cleanup_pending INTEGER NOT NULL DEFAULT 0`);
224
+ }
225
+ catch {
226
+ // Column already exists
227
+ }
186
228
  // Backfill stable keys for rows created by earlier outbox experiments before
187
229
  // creating indexes that reference the new columns.
188
230
  this.db.exec(`
@@ -194,9 +236,47 @@ export class MessageStore {
194
236
  this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_outbox_key ON delivery_outbox(bot_name, chat_id, delivery_key)`);
195
237
  }
196
238
  catch {
197
- // Existing duplicate experimental rows can prevent index creation in dev DB.
198
- // Fresh DBs still get the table-level UNIQUE constraint; dirty DBs still use
199
- // source unique + short-window content hash until cleaned.
239
+ // Legacy experimental databases may contain duplicate logical deliveries.
240
+ // Keep the earliest owner (and its stage checkpoints) deterministically,
241
+ // remove later duplicates, then REQUIRE the unique index. Continuing
242
+ // without it would allow duplicate visible finals on explicit trigger keys.
243
+ // Merge durable progress into the earliest owner before deleting later
244
+ // duplicates. Prefer the most-complete state so upgrading a dirty legacy DB
245
+ // cannot replay already-visible text/attachments or lose delivered state.
246
+ this.db.exec(`
247
+ UPDATE delivery_outbox AS owner
248
+ SET
249
+ text_delivered = (
250
+ SELECT MAX(d.text_delivered) FROM delivery_outbox d
251
+ WHERE d.bot_name=owner.bot_name AND d.chat_id=owner.chat_id AND d.delivery_key=owner.delivery_key
252
+ ),
253
+ attachment_cursor = (
254
+ SELECT MAX(d.attachment_cursor) FROM delivery_outbox d
255
+ WHERE d.bot_name=owner.bot_name AND d.chat_id=owner.chat_id AND d.delivery_key=owner.delivery_key
256
+ ),
257
+ cleanup_pending = (
258
+ SELECT MAX(d.cleanup_pending) FROM delivery_outbox d
259
+ WHERE d.bot_name=owner.bot_name AND d.chat_id=owner.chat_id AND d.delivery_key=owner.delivery_key
260
+ ),
261
+ attempts = (
262
+ SELECT MAX(d.attempts) FROM delivery_outbox d
263
+ WHERE d.bot_name=owner.bot_name AND d.chat_id=owner.chat_id AND d.delivery_key=owner.delivery_key
264
+ ),
265
+ status = CASE
266
+ WHEN EXISTS(SELECT 1 FROM delivery_outbox d WHERE d.bot_name=owner.bot_name AND d.chat_id=owner.chat_id AND d.delivery_key=owner.delivery_key AND d.status='delivered') THEN 'delivered'
267
+ WHEN EXISTS(SELECT 1 FROM delivery_outbox d WHERE d.bot_name=owner.bot_name AND d.chat_id=owner.chat_id AND d.delivery_key=owner.delivery_key AND d.status='pending') THEN 'pending'
268
+ WHEN EXISTS(SELECT 1 FROM delivery_outbox d WHERE d.bot_name=owner.bot_name AND d.chat_id=owner.chat_id AND d.delivery_key=owner.delivery_key AND d.status='delivering') THEN 'pending'
269
+ ELSE 'failed'
270
+ END
271
+ WHERE owner.id IN (
272
+ SELECT MIN(id) FROM delivery_outbox GROUP BY bot_name, chat_id, delivery_key HAVING COUNT(*) > 1
273
+ );
274
+ DELETE FROM delivery_outbox
275
+ WHERE id NOT IN (
276
+ SELECT MIN(id) FROM delivery_outbox GROUP BY bot_name, chat_id, delivery_key
277
+ );
278
+ `);
279
+ this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_outbox_key ON delivery_outbox(bot_name, chat_id, delivery_key)`);
200
280
  }
201
281
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_delivery_outbox_content ON delivery_outbox(bot_name, chat_id, content_hash, created_at)`);
202
282
  // Migration: add delivery_outbox reply target if missing
@@ -404,9 +484,9 @@ export class MessageStore {
404
484
  const now = Date.now();
405
485
  const result = this.db.prepare(`
406
486
  INSERT OR IGNORE INTO delivery_outbox
407
- (session_key, chat_id, bot_name, source_type, source_id, delivery_key, content_hash, content, attachments_json, reply_to_message_id, status, attempts, created_at, updated_at)
408
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
409
- `).run(item.sessionKey, item.chatId, item.botName, item.sourceType, item.sourceId, item.deliveryKey, item.contentHash, item.content, item.attachmentsJson, item.replyToMessageId || '', now, now);
487
+ (session_key, chat_id, bot_name, source_type, source_id, delivery_key, content_hash, content, attachments_json, reply_to_message_id, delivery_mode, target_message_id, delivery_meta_json, text_delivered, attachment_cursor, cleanup_pending, status, attempts, created_at, updated_at)
488
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
489
+ `).run(item.sessionKey, item.chatId, item.botName, item.sourceType, item.sourceId, item.deliveryKey, item.contentHash, item.content, item.attachmentsJson, item.replyToMessageId || '', item.deliveryMode || 'send', item.targetMessageId || '', item.deliveryMetaJson || '{}', item.textDelivered ? 1 : 0, item.attachmentCursor || 0, item.cleanupPending ? 1 : 0, now, now);
410
490
  return result.changes ? Number(result.lastInsertRowid) : null;
411
491
  }
412
492
  getDeliveryBySource(sessionKey, sourceType, sourceId) {
@@ -416,6 +496,21 @@ export class MessageStore {
416
496
  `).get(sessionKey, sourceType, sourceId);
417
497
  return row ? this.mapDelivery(row) : null;
418
498
  }
499
+ getDeliveryByKey(botName, chatId, deliveryKey) {
500
+ const row = this.db.prepare(`
501
+ SELECT * FROM delivery_outbox
502
+ WHERE bot_name = ? AND chat_id = ? AND delivery_key = ?
503
+ `).get(botName, chatId, deliveryKey);
504
+ return row ? this.mapDelivery(row) : null;
505
+ }
506
+ listDeliveriesByKeyPrefix(botName, chatId, prefix) {
507
+ const rows = this.db.prepare(`
508
+ SELECT * FROM delivery_outbox
509
+ WHERE bot_name = ? AND chat_id = ? AND delivery_key LIKE ?
510
+ ORDER BY id ASC
511
+ `).all(botName, chatId, `${prefix}%`);
512
+ return rows.map((row) => this.mapDelivery(row));
513
+ }
419
514
  getPendingDeliveries(chatId, botName, maxCount = 20) {
420
515
  if (chatId && botName) {
421
516
  const rows = this.db.prepare(`
@@ -501,17 +596,29 @@ export class MessageStore {
501
596
  }
502
597
  return false;
503
598
  }
599
+ /** Recover in-flight rows after process restart. A new process cannot own a
600
+ * legitimate delivering row, so every matching row returns to pending—even if
601
+ * the previous process crashed on its nominal final attempt. The restarted
602
+ * process gets another chance to finish rather than silently losing payload. */
603
+ resetDeliveringOnStartup(botName) {
604
+ const result = this.db.prepare(`
605
+ UPDATE delivery_outbox
606
+ SET status = 'pending', updated_at = ?
607
+ WHERE bot_name = ? AND status = 'delivering'
608
+ `).run(Date.now(), botName);
609
+ return result.changes;
610
+ }
504
611
  resetStaleDeliveries(botName, staleMs, maxAttempts) {
505
612
  const cutoff = Date.now() - staleMs;
506
613
  const fail = this.db.prepare(`
507
614
  UPDATE delivery_outbox
508
615
  SET status = 'failed', updated_at = ?
509
- WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts >= ?
616
+ WHERE bot_name = ? AND status = 'delivering' AND updated_at <= ? AND attempts >= ?
510
617
  `).run(Date.now(), botName, cutoff, maxAttempts);
511
618
  const restore = this.db.prepare(`
512
619
  UPDATE delivery_outbox
513
620
  SET status = 'pending', updated_at = ?
514
- WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts < ?
621
+ WHERE bot_name = ? AND status = 'delivering' AND updated_at <= ? AND attempts < ?
515
622
  `).run(Date.now(), botName, cutoff, maxAttempts);
516
623
  return { restored: restore.changes, failed: fail.changes };
517
624
  }
@@ -521,6 +628,38 @@ export class MessageStore {
521
628
  `).run(Date.now(), id);
522
629
  return result.changes === 1;
523
630
  }
631
+ markDeliveryTextDelivered(id) {
632
+ this.db.prepare(`
633
+ UPDATE delivery_outbox SET text_delivered = 1, updated_at = ? WHERE id = ?
634
+ `).run(Date.now(), id);
635
+ }
636
+ /** Atomically checkpoint fallback text and the remaining card cleanup. */
637
+ markDeliveryTextDeliveredWithCleanup(id) {
638
+ this.db.prepare(`
639
+ UPDATE delivery_outbox
640
+ SET text_delivered = 1, cleanup_pending = 1, updated_at = ?
641
+ WHERE id = ?
642
+ `).run(Date.now(), id);
643
+ }
644
+ markDeliveryAttachmentCursor(id, cursor) {
645
+ this.db.prepare(`
646
+ UPDATE delivery_outbox SET attachment_cursor = ?, updated_at = ? WHERE id = ?
647
+ `).run(cursor, Date.now(), id);
648
+ }
649
+ /** Return a failed in-flight delivery to pending until its retry budget is spent. */
650
+ retryDelivery(id, maxAttempts) {
651
+ const result = this.db.prepare(`
652
+ UPDATE delivery_outbox
653
+ SET status = 'pending', updated_at = ?
654
+ WHERE id = ? AND status = 'delivering' AND attempts < ?
655
+ `).run(Date.now(), id, maxAttempts);
656
+ return result.changes === 1;
657
+ }
658
+ markDeliveryCleanupPending(id, pending) {
659
+ this.db.prepare(`
660
+ UPDATE delivery_outbox SET cleanup_pending = ?, updated_at = ? WHERE id = ?
661
+ `).run(pending ? 1 : 0, Date.now(), id);
662
+ }
524
663
  markDeliveryDelivered(id) {
525
664
  this.db.prepare(`
526
665
  UPDATE delivery_outbox SET status = 'delivered', updated_at = ? WHERE id = ?
@@ -531,6 +670,15 @@ export class MessageStore {
531
670
  UPDATE delivery_outbox SET status = 'failed', updated_at = ? WHERE id = ?
532
671
  `).run(Date.now(), id);
533
672
  }
673
+ /** Give a later explicit duplicate another chance after terminal failure. */
674
+ requeueFailedDelivery(id) {
675
+ const result = this.db.prepare(`
676
+ UPDATE delivery_outbox
677
+ SET status = 'pending', attempts = 0, updated_at = ?
678
+ WHERE id = ? AND status = 'failed'
679
+ `).run(Date.now(), id);
680
+ return result.changes === 1;
681
+ }
534
682
  mapDelivery(row) {
535
683
  return {
536
684
  id: row.id,
@@ -544,6 +692,12 @@ export class MessageStore {
544
692
  content: row.content || '',
545
693
  attachmentsJson: row.attachments_json || '[]',
546
694
  replyToMessageId: row.reply_to_message_id || '',
695
+ deliveryMode: row.delivery_mode === 'patch_live_status' ? 'patch_live_status' : 'send',
696
+ targetMessageId: row.target_message_id || '',
697
+ deliveryMetaJson: row.delivery_meta_json || '{}',
698
+ textDelivered: Boolean(row.text_delivered),
699
+ attachmentCursor: Number(row.attachment_cursor || 0),
700
+ cleanupPending: Boolean(row.cleanup_pending),
547
701
  status: row.status,
548
702
  attempts: row.attempts || 0,
549
703
  createdAt: row.created_at,
@@ -105,6 +105,10 @@ export declare class OpenClawClient {
105
105
  * context-overflow / prompt-too-large, which OpenClaw recovers from via its
106
106
  * context-overflow-recovery + auto-compaction path.
107
107
  */
108
+ private isGenericChatError;
109
+ private resolveTerminalChatError;
110
+ private formatTerminalRunError;
111
+ private formatDurationSeconds;
108
112
  private isRecoverableAgentError;
109
113
  private pickBestCollectedText;
110
114
  createSession(params: {
@@ -527,6 +527,7 @@ export class OpenClawClient {
527
527
  let chatFinalTimer = null;
528
528
  let lifecycleEndTimer = null;
529
529
  let replayInvalidTimer = null;
530
+ let genericChatErrorTimer = null;
530
531
  let pendingRuntimeFailureText = "";
531
532
  let lastActivitySummary = "";
532
533
  let lastActivityAt = 0;
@@ -556,6 +557,8 @@ export class OpenClawClient {
556
557
  clearTimeout(lifecycleEndTimer);
557
558
  if (replayInvalidTimer)
558
559
  clearTimeout(replayInvalidTimer);
560
+ if (genericChatErrorTimer)
561
+ clearTimeout(genericChatErrorTimer);
559
562
  console.warn(`[OpenClaw] collectReply idle timeout for runId=${runId} sessionKey=${sessionKey}`);
560
563
  this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
561
564
  console.warn(`[OpenClaw] abort after collectReply idle timeout failed:`, err.message);
@@ -633,6 +636,8 @@ export class OpenClawClient {
633
636
  clearTimeout(lifecycleEndTimer);
634
637
  if (replayInvalidTimer)
635
638
  clearTimeout(replayInvalidTimer);
639
+ if (genericChatErrorTimer)
640
+ clearTimeout(genericChatErrorTimer);
636
641
  resolve(finalText);
637
642
  };
638
643
  const poller = setInterval(() => {
@@ -648,6 +653,8 @@ export class OpenClawClient {
648
653
  clearTimeout(lifecycleEndTimer);
649
654
  if (replayInvalidTimer)
650
655
  clearTimeout(replayInvalidTimer);
656
+ if (genericChatErrorTimer)
657
+ clearTimeout(genericChatErrorTimer);
651
658
  if (idleTimer)
652
659
  clearTimeout(idleTimer);
653
660
  const salvaged = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
@@ -904,6 +911,28 @@ export class OpenClawClient {
904
911
  // surfaces the failure / lets auto-retry kick in — instead of
905
912
  // surfacing whatever mid-run delta text happened to be collected.
906
913
  const errText = String(ev.data?.error || "chat error");
914
+ if (this.isGenericChatError(errText)) {
915
+ // Gateway can emit a terminal chat error without errorMessage,
916
+ // then persist a richer terminal snapshot a moment later. Do
917
+ // not expose the placeholder "chat error" to users. Reconcile
918
+ // through agent.wait, whose snapshot includes timeout phase,
919
+ // stop reason, and the real upstream error when available.
920
+ if (!genericChatErrorTimer) {
921
+ genericChatErrorTimer = setTimeout(() => {
922
+ genericChatErrorTimer = null;
923
+ void this.resolveTerminalChatError(evRunId || runId).then((detail) => {
924
+ clearTimeout(idleTimer);
925
+ clearInterval(poller);
926
+ if (chatFinalTimer)
927
+ clearTimeout(chatFinalTimer);
928
+ if (lifecycleEndTimer)
929
+ clearTimeout(lifecycleEndTimer);
930
+ reject(new Error(`Agent error: ${detail}`));
931
+ });
932
+ }, 350);
933
+ }
934
+ continue;
935
+ }
907
936
  if (this.isRecoverableAgentError(errText)) {
908
937
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
909
938
  resetIdleTimer();
@@ -917,6 +946,8 @@ export class OpenClawClient {
917
946
  clearTimeout(chatFinalTimer);
918
947
  if (lifecycleEndTimer)
919
948
  clearTimeout(lifecycleEndTimer);
949
+ if (genericChatErrorTimer)
950
+ clearTimeout(genericChatErrorTimer);
920
951
  // Abort the (possibly still-running) backgrounded run before we
921
952
  // reject, so an auto-retry probe does not race a zombie run on the
922
953
  // same session. Idempotent/best-effort.
@@ -939,6 +970,50 @@ export class OpenClawClient {
939
970
  * context-overflow / prompt-too-large, which OpenClaw recovers from via its
940
971
  * context-overflow-recovery + auto-compaction path.
941
972
  */
973
+ isGenericChatError(errText) {
974
+ const t = (errText || "").trim().toLowerCase();
975
+ return t === "chat error" || t === "agent error: chat error";
976
+ }
977
+ async resolveTerminalChatError(runId) {
978
+ try {
979
+ const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
980
+ return this.formatTerminalRunError(snapshot);
981
+ }
982
+ catch (err) {
983
+ const detail = err instanceof Error ? err.message : String(err);
984
+ return `OpenClaw 运行失败,但未返回具体原因${detail ? `(${detail})` : ""}`;
985
+ }
986
+ }
987
+ formatTerminalRunError(snapshot) {
988
+ const rawError = typeof snapshot?.error === "string" ? snapshot.error.trim() : "";
989
+ const timeoutPhase = typeof snapshot?.timeoutPhase === "string" ? snapshot.timeoutPhase.trim() : "";
990
+ const stopReason = typeof snapshot?.stopReason === "string" ? snapshot.stopReason.trim() : "";
991
+ const status = typeof snapshot?.status === "string" ? snapshot.status.trim() : "";
992
+ const timedOut = status === "timeout" || Boolean(timeoutPhase) || /timed?\s*out|timeout/i.test(rawError);
993
+ if (timedOut) {
994
+ const startedAt = Number(snapshot?.startedAt);
995
+ const endedAt = Number(snapshot?.endedAt);
996
+ const elapsedSeconds = Number.isFinite(startedAt) && Number.isFinite(endedAt) && endedAt > startedAt
997
+ ? Math.round((endedAt - startedAt) / 1000)
998
+ : 0;
999
+ const elapsed = elapsedSeconds > 0 ? `(运行约 ${this.formatDurationSeconds(elapsedSeconds)})` : "";
1000
+ const phase = timeoutPhase ? `,阶段:${timeoutPhase}` : "";
1001
+ return `Agent 运行超时${elapsed}${phase},已被 OpenClaw 终止`;
1002
+ }
1003
+ if (rawError)
1004
+ return rawError;
1005
+ if (stopReason === "stop" || stopReason === "rpc")
1006
+ return `Agent 被停止(${stopReason})`;
1007
+ const meta = [status && `状态=${status}`, stopReason && `原因=${stopReason}`, timeoutPhase && `阶段=${timeoutPhase}`].filter(Boolean).join(",");
1008
+ return meta ? `OpenClaw 运行失败:${meta}` : "OpenClaw 运行失败,但未返回具体原因";
1009
+ }
1010
+ formatDurationSeconds(seconds) {
1011
+ if (seconds % 3600 === 0)
1012
+ return `${seconds / 3600} 小时`;
1013
+ if (seconds % 60 === 0)
1014
+ return `${seconds / 60} 分钟`;
1015
+ return `${seconds} 秒`;
1016
+ }
942
1017
  isRecoverableAgentError(errText) {
943
1018
  const t = (errText || "").toLowerCase();
944
1019
  return t.includes("context overflow")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {