openclaw-lark-multi-agent 1.8.16 → 1.8.18

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.
@@ -1713,6 +1713,10 @@ export class FeishuBot {
1713
1713
  this.store.stopInsertionAtEpoch(this.config.name, chatId, item.rowId, item.stopEpoch);
1714
1714
  continue;
1715
1715
  }
1716
+ // UI-only recovery uses durable original-chat receipts; do not make it
1717
+ // depend on a new Core/session-info lookup after the reply was sent.
1718
+ if (item.state === 'accepted')
1719
+ void this.queuedPresentations.reconcileDeliveredReply(chatId, item.expectedRunId).catch(() => { });
1716
1720
  if (item.activityUnbound) {
1717
1721
  await this.reconcileCurrentInputActivity(chatId, item.rowId, item.messageId, item.text);
1718
1722
  if (!stillCurrent()) {
@@ -4310,6 +4314,12 @@ export class FeishuBot {
4310
4314
  }
4311
4315
  buildLiveStatusCard(view, chatId) {
4312
4316
  const en = this.isEn(chatId);
4317
+ if (view.state === "replied") {
4318
+ // Neutral UI-only closure, not a green business/input-completion claim.
4319
+ const summary = en ? `💬 Linked run replied · ⏱ ${view.elapsed}` : `💬 关联轮次已回复 · ⏱ 耗时${view.elapsed}`;
4320
+ return { schema: "2.0", config: { update_multi: true, width_mode: "fill" },
4321
+ body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] } };
4322
+ }
4313
4323
  // Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
4314
4324
  // one compact grey line (status emoji + tool-call count + total elapsed) so
4315
4325
  // the finished card does not take up much screen space.
@@ -23,8 +23,8 @@ export type LiveStatusView = {
23
23
  /** True when finished via noReply(): the model produced no user-visible reply. */
24
24
  noReply: boolean;
25
25
  /** Presentation only: queued/admitted do not claim model execution. */
26
- state: "queued" | "admitted" | "running" | "done" | "failed";
27
- phase?: "queued" | "admitted" | "working" | "waiting" | "done" | "failed";
26
+ state: "queued" | "admitted" | "running" | "done" | "failed" | "replied";
27
+ phase?: "queued" | "admitted" | "working" | "waiting" | "done" | "failed" | "replied";
28
28
  };
29
29
  /** Persisted metadata needed to turn the live-status card into the final answer. */
30
30
  export type LiveStatusFinalMeta = {
@@ -157,6 +157,9 @@ export declare class LiveStatusController {
157
157
  /** Release only local observation of a now-idle session, without claiming
158
158
  * the previous task succeeded or was aborted. Normal progress is unchanged. */
159
159
  pauseForUnconfirmedResult(detail: string, title?: string): Promise<void>;
160
+ /** Close only the display after the associated run's original-chat reply is
161
+ * platform-confirmed. This deliberately does not claim inserted-input commit. */
162
+ linkedReplyDelivered(deliveredAt: number, preserveReplyPayload?: boolean, retryZeroEffect?: boolean): Promise<void>;
160
163
  complete(): Promise<void>;
161
164
  /**
162
165
  * Freeze any successful terminal state (text, attachment-only, or NO_REPLY)
@@ -84,6 +84,11 @@ export class LiveStatusController {
84
84
  this.noReplyResult = restored.view.noReply;
85
85
  this.waitingForResult = restored.view.phase === "waiting";
86
86
  this.updatesFrozen = this.waitingForResult;
87
+ if (restored.view.state === "replied") {
88
+ this.finalized = true;
89
+ this.terminalOutcome = true;
90
+ this.updatesFrozen = true;
91
+ }
87
92
  }
88
93
  // Attempted/unknown create survives process loss as a no-create fence.
89
94
  if (restored.blockCreate && !this.messageId)
@@ -259,6 +264,33 @@ export class LiveStatusController {
259
264
  view.title = title || (this.opts.locale === "en" ? `${this.opts.botName} result unconfirmed` : `${this.opts.botName} 结果未确认`);
260
265
  await this.finishView(view);
261
266
  }
267
+ /** Close only the display after the associated run's original-chat reply is
268
+ * platform-confirmed. This deliberately does not claim inserted-input commit. */
269
+ async linkedReplyDelivered(deliveredAt, preserveReplyPayload = false, retryZeroEffect = false) {
270
+ const safeRetry = retryZeroEffect && this.state === "replied" && !this.unknownEdits.size && !this.editPump && !this.pendingEdit;
271
+ if (this.disposed || this.terminalOutcome && !safeRetry || !Number.isFinite(deliveredAt) || deliveredAt < this.startedAt)
272
+ return;
273
+ this.state = "replied";
274
+ this.waitingForResult = false;
275
+ this.updatesFrozen = true;
276
+ this.finalized = true;
277
+ this.terminalOutcome = true;
278
+ this.stopTimers();
279
+ const seconds = Math.max(0, Math.floor((deliveredAt - this.startedAt) / 1000));
280
+ const elapsed = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
281
+ const view = this.buildView();
282
+ view.title = this.opts.locale === "en" ? `${this.opts.botName} linked run replied` : `${this.opts.botName} 关联轮次已回复`;
283
+ view.elapsed = elapsed;
284
+ view.lines = [{ kind: "summary", text: this.opts.locale === "en" ? "Linked run reply delivered; input commitment is separate" : "关联轮次回复已送达;不据此确认追加输入提交", at: seconds }];
285
+ if (preserveReplyPayload) {
286
+ // The card itself already contains the business answer: never replace it
287
+ // with a compact status line or let this controller issue further writes.
288
+ this.callbacks.desired?.(view);
289
+ this.dispose();
290
+ return;
291
+ }
292
+ await this.finishView(view);
293
+ }
262
294
  async complete() {
263
295
  if (this.disposed || this.terminalOutcome)
264
296
  return;
@@ -683,6 +715,8 @@ export class LiveStatusController {
683
715
  title = en ? `\u2705 ${this.opts.botName} done` : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
684
716
  else if (this.state === "failed")
685
717
  title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
718
+ else if (this.state === "replied")
719
+ title = en ? `${this.opts.botName} linked run replied` : `${this.opts.botName} 关联轮次已回复`;
686
720
  else if (this.waitingForResult)
687
721
  title = en ? `${this.opts.botName} waiting (card frozen)` : `${this.opts.botName} 等待后续结果(已停止刷新)`;
688
722
  else if (this.state === "queued")
@@ -713,7 +747,7 @@ export class LiveStatusController {
713
747
  toolCalls: this.toolCallCount,
714
748
  noReply: this.noReplyResult,
715
749
  state: this.state,
716
- phase: this.state === "done" || this.state === "failed" ? this.state : this.waitingForResult ? "waiting" : this.state === "running" ? "working" : this.state,
750
+ phase: this.state === "done" || this.state === "failed" || this.state === "replied" ? this.state : this.waitingForResult ? "waiting" : this.state === "running" ? "working" : this.state,
717
751
  };
718
752
  }
719
753
  buildSummaryLine(en) {
@@ -145,6 +145,16 @@ export declare class MessageStore {
145
145
  stopExactRun(sessionKey: string, runId: string): void;
146
146
  isStoppedObservation(sessionKey: string, runId: string): boolean;
147
147
  hasUnconfirmedRunDelivery(bot: string, chat: string, runId: string): boolean;
148
+ /** UI-only witness of an exact run's visible reply in the original Bot/chat.
149
+ * Does not prove any legacy inserted input was committed or consumed. */
150
+ getDeliveredRunReply(bot: string, app: string, chat: string, runId: string, epoch: number): {
151
+ outboxId: number;
152
+ runId: string;
153
+ createdAt: number;
154
+ deliveredAt: number;
155
+ platformMessageIds: string[];
156
+ sessionId?: string;
157
+ } | undefined;
148
158
  settleRunReactionIntents(bot: string, chat: string, runId: string): void;
149
159
  cancelReactionIntents(bot: string, chat: string, epoch: number): void;
150
160
  saveRunObservation(sessionKey: string, runId: string, record: object, nextCheckAt?: number): void;
@@ -305,6 +315,7 @@ export declare class MessageStore {
305
315
  */
306
316
  insert(msg: ChatMessage): number;
307
317
  getMessageId(messageId: string): number | null;
318
+ getMessageByRowId(rowId: number): ChatMessage | null;
308
319
  getMessageByMessageId(messageId: string): ChatMessage | null;
309
320
  getLatestMessageId(chatId: string): number;
310
321
  markMessageRecalled(messageId: string, chatId: string, recalledAt?: number, recallType?: string): void;
@@ -195,6 +195,30 @@ export class MessageStore {
195
195
  const finals = rows.filter(row => row.source_type.startsWith('assistant_visible'));
196
196
  return (finals.length ? finals : rows).some(row => this.getDeliveryDispatchOutcome(bot, chat, row.delivery_key).state !== 'delivered');
197
197
  }
198
+ /** UI-only witness of an exact run's visible reply in the original Bot/chat.
199
+ * Does not prove any legacy inserted input was committed or consumed. */
200
+ getDeliveredRunReply(bot, app, chat, runId, epoch) {
201
+ if (!runId || this.hasUnconfirmedRunDelivery(bot, chat, runId))
202
+ return;
203
+ const rows = this.db.prepare(`SELECT id,delivery_key,created_at,updated_at,delivery_meta_json FROM delivery_outbox
204
+ WHERE bot_name=? AND chat_id=? AND status='delivered' AND source_type IN ('assistant_visible','discussion')
205
+ AND json_extract(delivery_meta_json,'$.provenance.runId')=?
206
+ AND json_extract(delivery_meta_json,'$.provenance.final')=1
207
+ AND json_extract(delivery_meta_json,'$.botIdentity')=?
208
+ AND json_extract(delivery_meta_json,'$.deliveryStopEpoch')=? ORDER BY id DESC LIMIT 20`)
209
+ .all(bot, chat, runId, app, epoch);
210
+ for (const row of rows) {
211
+ if (this.getDeliveryDispatchOutcome(bot, chat, row.delivery_key).state !== 'delivered')
212
+ continue;
213
+ const receipts = this.listDeliveryReceipts(row.id);
214
+ const ids = [...new Set(receipts.map(receipt => receipt.messageId).filter(Boolean))];
215
+ if (!ids.length || !Number.isFinite(row.created_at) || !Number.isFinite(row.updated_at))
216
+ continue;
217
+ const meta = JSON.parse(row.delivery_meta_json || '{}');
218
+ return { outboxId: row.id, runId, createdAt: row.created_at, deliveredAt: row.updated_at, platformMessageIds: ids,
219
+ sessionId: meta.provenance?.sessionId };
220
+ }
221
+ }
198
222
  settleRunReactionIntents(bot, chat, runId) {
199
223
  if (this.hasUnconfirmedRunDelivery(bot, chat, runId))
200
224
  return;
@@ -1140,6 +1164,10 @@ export class MessageStore {
1140
1164
  const row = this.db.prepare(`SELECT id FROM messages WHERE message_id = ?`).get(messageId);
1141
1165
  return row?.id || this.db.prepare('SELECT message_row_id FROM platform_message_aliases WHERE message_id=?').get(messageId)?.message_row_id || null;
1142
1166
  }
1167
+ getMessageByRowId(rowId) {
1168
+ const row = this.db.prepare('SELECT message_id FROM messages WHERE id = ?').get(rowId);
1169
+ return row ? this.getMessageByMessageId(row.message_id) : null;
1170
+ }
1143
1171
  getMessageByMessageId(messageId) {
1144
1172
  const row = this.db.prepare(`SELECT * FROM messages WHERE message_id = ?`).get(messageId);
1145
1173
  if (!row)
@@ -11,6 +11,8 @@ export declare class QueuedPresentations {
11
11
  private readonly enabled;
12
12
  private readonly english;
13
13
  private entries;
14
+ /** UI effects only; never an admission/execution lock. */
15
+ private replyFlights;
14
16
  /** Receipt custody only: never a business busy/admission flag or SDK retry. */
15
17
  private repairs;
16
18
  private repairTimer?;
@@ -40,6 +42,10 @@ export declare class QueuedPresentations {
40
42
  claim(chat: string, ids: number[], ownerId: string, batchId: string, native?: boolean): LiveStatusController | undefined;
41
43
  attempted(live: LiveStatusController | undefined, submissionId: string, ownerId: string, batchId: string): void;
42
44
  insertionAccepted(chat: string, rowId: number, expectedRunId: string): Promise<void>;
45
+ /** A legacy insertion owns no actual run. Its card may nevertheless report
46
+ * the exact associated run's platform-delivered reply, without changing any
47
+ * insertion, reaction, sync or scheduling record. Recoverable after restart. */
48
+ reconcileDeliveredReply(chat: string, runId: string): Promise<void>;
43
49
  settleRun(chat: string, runId: string): Promise<void>;
44
50
  admit(live: LiveStatusController | undefined, runId: string, ownerId: string, batchId: string): Promise<void>;
45
51
  /** A deliberate same-task continuation has its own real ACK. Never infer
@@ -11,6 +11,8 @@ export class QueuedPresentations {
11
11
  enabled;
12
12
  english;
13
13
  entries = new Map();
14
+ /** UI effects only; never an admission/execution lock. */
15
+ replyFlights = new Map();
14
16
  /** Receipt custody only: never a business busy/admission flag or SDK retry. */
15
17
  repairs = new Map();
16
18
  repairTimer;
@@ -61,6 +63,9 @@ export class QueuedPresentations {
61
63
  if (create.state === "never")
62
64
  delete row.createToken; // proven zero SDK
63
65
  }
66
+ const applied = repair.linkedReplyApplied;
67
+ if (applied && row.cardId === applied.cardId && row.linkedReply?.runId === applied.runId && row.linkedReply.outboxId === applied.outboxId)
68
+ row.linkedReply = { ...row.linkedReply, rendered: "summary" };
64
69
  for (const [token, receipt] of repair.edits) {
65
70
  if (receipt.state === "clear")
66
71
  delete row.editEffects[token];
@@ -89,7 +94,11 @@ export class QueuedPresentations {
89
94
  if (receipt.state === "clear" ? !row.editEffects?.[token]
90
95
  : row.editEffects?.[token] === "unknown" || !row.editEffects?.[token] && row.lease !== receipt.lease)
91
96
  repair.edits.delete(token);
92
- if (!repair.create && !repair.edits.size)
97
+ const applied = repair.linkedReplyApplied;
98
+ if (applied && row.cardId === applied.cardId && row.linkedReply?.runId === applied.runId &&
99
+ row.linkedReply.outboxId === applied.outboxId && row.linkedReply.rendered === "summary")
100
+ repair.linkedReplyApplied = undefined;
101
+ if (!repair.create && !repair.edits.size && !repair.linkedReplyApplied)
93
102
  this.repairs.delete(row.id);
94
103
  }
95
104
  if (!this.repairs.size) {
@@ -112,8 +121,10 @@ export class QueuedPresentations {
112
121
  repair.create = { token, lease: record.lease, state, cardId };
113
122
  this.scheduleRepair(true);
114
123
  }
115
- editReceipt(record, token, state) {
124
+ editReceipt(record, token, state, linkedReplyApplied) {
116
125
  const repair = this.repairFor(record);
126
+ if (linkedReplyApplied)
127
+ repair.linkedReplyApplied = linkedReplyApplied;
117
128
  if (repair.edits.get(token)?.state !== "clear" || state === "clear")
118
129
  repair.edits.set(token, { lease: record.lease, state });
119
130
  this.scheduleRepair(true);
@@ -156,11 +167,11 @@ export class QueuedPresentations {
156
167
  }
157
168
  usable(row) {
158
169
  return !row.retired && row.stopEpoch === this.store.getStopGeneration(this.bot, row.chatId)
159
- && row.view?.state !== "done" && row.view?.state !== "failed";
170
+ && !row.linkedReply?.rendered && row.view?.state !== "done" && row.view?.state !== "failed" && row.view?.state !== "replied";
160
171
  }
161
172
  current(record) {
162
173
  const row = this.latest(record);
163
- return !!row && this.usable(row) && row.lease === record.lease && this.enabled(record.chatId);
174
+ return !!row && this.usable(row) && !row.linkedReply && row.lease === record.lease && this.enabled(record.chatId);
164
175
  }
165
176
  hydrate(record) {
166
177
  const cached = this.entries.get(record.id);
@@ -279,6 +290,7 @@ export class QueuedPresentations {
279
290
  edit: async (id, view, effect) => {
280
291
  const token = randomUUID();
281
292
  let attempted = false, settled = false;
293
+ let linkedReplyApplied;
282
294
  try {
283
295
  await base.edit(id, view, { run: send => effect.run(async () => {
284
296
  try {
@@ -286,6 +298,8 @@ export class QueuedPresentations {
286
298
  if (!row || row.lease !== record.lease || row.retired || row.stopEpoch !== this.store.getStopGeneration(this.bot, row.chatId)
287
299
  || !this.enabled(row.chatId) || row.cardId !== id)
288
300
  throw new LiveStatusEffectSuppressedError("presentation edit suppressed before transport");
301
+ if (view.state === "replied" && row.linkedReply)
302
+ linkedReplyApplied = { runId: row.linkedReply.runId, outboxId: row.linkedReply.outboxId, cardId: id };
289
303
  try {
290
304
  this.save({ ...row, editEffects: { ...row.editEffects, [token]: "attempted" } });
291
305
  }
@@ -311,7 +325,7 @@ export class QueuedPresentations {
311
325
  settled = true;
312
326
  // Clear only THIS successful token; do not replay the SDK or erase
313
327
  // another unknown progress/terminal operation if SQLite is busy.
314
- this.editReceipt(record, token, "clear");
328
+ this.editReceipt(record, token, "clear", linkedReplyApplied);
315
329
  }) });
316
330
  // D2 has recorded the successful effect before local persistence.
317
331
  this.flushRepairs();
@@ -324,7 +338,8 @@ export class QueuedPresentations {
324
338
  },
325
339
  }, {
326
340
  botName: this.bot, locale: this.english(record.chatId) ? "en" : "zh", initialPhase: "queued",
327
- restore: { messageId: record.cardId, view: record.view, startedAt: record.startedAt,
341
+ restore: { messageId: record.cardId, view: record.linkedReply?.rendered && record.view
342
+ ? { ...record.view, state: "replied", phase: "replied" } : record.view, startedAt: record.startedAt,
328
343
  blockCreate: record.create !== "never", blockEdits: Object.keys(record.editEffects || {}).length > 0 },
329
344
  });
330
345
  const entry = { record, live };
@@ -334,12 +349,17 @@ export class QueuedPresentations {
334
349
  receive(chat, rowId, messageId, native = false, activeRunId) {
335
350
  if (native || !rowId || !this.enabled(chat))
336
351
  return undefined;
337
- const rows = this.records(chat).filter(row => this.usable(row));
352
+ const all = this.records(chat);
353
+ const replied = all.find(row => !row.retired && row.stopEpoch === this.store.getStopGeneration(this.bot, chat)
354
+ && (row.view?.state === "replied" || !!row.linkedReply?.rendered) && row.triggerIds.includes(rowId));
355
+ if (replied)
356
+ return this.hydrate(replied).live;
357
+ const rows = all.filter(row => this.usable(row));
338
358
  // Repeated ingress and steering bursts reuse one card. A foreign/old run
339
359
  // is never selected merely because it happens to have a card in this chat.
340
360
  let record = rows.find(row => row.triggerIds.includes(rowId))
341
- || rows.find(row => !row.runId && !row.submissionId)
342
- || (activeRunId ? rows.find(row => row.runId === activeRunId && row.view?.phase !== "waiting") : undefined);
361
+ || rows.find(row => !row.runId && !row.submissionId && !row.linkedReply)
362
+ || (activeRunId ? rows.find(row => row.runId === activeRunId && row.view?.phase !== "waiting" && !row.linkedReply) : undefined);
343
363
  if (!record) {
344
364
  record = { id: randomUUID(), chatId: chat, messageId, triggerIds: [], stopEpoch: this.store.getStopGeneration(this.bot, chat),
345
365
  startedAt: Date.now(), lease: "", create: "never" };
@@ -380,7 +400,7 @@ export class QueuedPresentations {
380
400
  this.save(row);
381
401
  }
382
402
  async insertionAccepted(chat, rowId, expectedRunId) {
383
- const row = this.records(chat).find(row => this.usable(row) && row.triggerIds.includes(rowId)
403
+ const row = this.records(chat).find(row => this.usable(row) && !row.linkedReply && row.triggerIds.includes(rowId)
384
404
  && (!row.runId || row.runId === expectedRunId));
385
405
  if (!row || !expectedRunId)
386
406
  return;
@@ -398,9 +418,94 @@ export class QueuedPresentations {
398
418
  else
399
419
  await live.showInputWaiting(detail, this.english(chat) ? `${this.bot} enqueued · input confirmation pending` : `${this.bot} 已排队 · 追加输入回执待确认`);
400
420
  }
421
+ /** A legacy insertion owns no actual run. Its card may nevertheless report
422
+ * the exact associated run's platform-delivered reply, without changing any
423
+ * insertion, reaction, sync or scheduling record. Recoverable after restart. */
424
+ async reconcileDeliveredReply(chat, runId) {
425
+ if (!runId || !this.enabled(chat))
426
+ return;
427
+ const epoch = this.store.getStopGeneration(this.bot, chat);
428
+ const evidence = this.store.getDeliveredRunReply(this.bot, this.app, chat, runId, epoch);
429
+ if (!evidence)
430
+ return;
431
+ const witnesses = (row, preserveSealedPayload = false) => {
432
+ const retry = row.view?.state === "replied" && !!row.linkedReply && !row.linkedReply.rendered &&
433
+ row.create === "accepted" && !!row.cardId && !Object.keys(row.editEffects || {}).length;
434
+ const preserved = preserveSealedPayload && row.linkedReply?.rendered === 'reply_payload';
435
+ if ((!this.usable(row) && !retry && !preserved) || row.retired || row.stopEpoch !== epoch || row.linkedReply?.rendered && !preserved ||
436
+ evidence.createdAt < row.startedAt || (row.expectedInsertionRoot !== runId && row.runId !== runId))
437
+ return;
438
+ const ordinary = row.runId && row.ownerId && row.batchId ? row.ordinaryTriggerIds || [] : [];
439
+ const extra = row.triggerIds.filter(id => !ordinary.includes(id));
440
+ if (!extra.length)
441
+ return;
442
+ const proofs = [];
443
+ for (const id of row.triggerIds) {
444
+ const input = this.store.getMessageByRowId(id);
445
+ const item = this.store.getInsertion(this.bot, chat, id);
446
+ const isOrdinary = ordinary.includes(id);
447
+ if (isOrdinary ? item && (item.state !== 'ordinary' || item.stopEpoch !== epoch) :
448
+ !row.enqueueAcceptedIds?.includes(id) || item?.state !== 'accepted' || item.stopEpoch !== epoch)
449
+ return;
450
+ if (!isOrdinary && !item.sessionId)
451
+ return;
452
+ const root = isOrdinary ? row.runId : item.expectedRunId;
453
+ const proof = root ? this.store.getDeliveredRunReply(this.bot, this.app, chat, root, epoch) : undefined;
454
+ if (!proof || input?.id !== id || input.chatId !== chat || input.senderType !== 'human' || input.timestamp > proof.createdAt ||
455
+ item && (item.messageId !== input.messageId || proof.sessionId && item.sessionId && item.sessionId !== proof.sessionId))
456
+ return;
457
+ proofs.push(proof);
458
+ }
459
+ return proofs;
460
+ };
461
+ const flights = [];
462
+ // There is no await in this staging loop: seal every currently eligible
463
+ // cohort before the first SDK effect can allow new ingress to interleave.
464
+ for (const snapshot of this.records(chat)) {
465
+ const row = this.latest(snapshot);
466
+ if (!row || !witnesses(row) || this.replyFlights.has(row.id) || (row.linkedReply?.retryAfter || 0) > Date.now())
467
+ continue;
468
+ const entry = this.hydrate(row);
469
+ const current = this.latest(entry.record);
470
+ const proofs = current && witnesses(current);
471
+ if (!current || !proofs || current.lease !== entry.record.lease)
472
+ continue;
473
+ const latestProof = proofs.reduce((a, b) => a.deliveredAt >= b.deliveredAt ? a : b);
474
+ const proofIds = [...new Set(proofs.map(proof => proof.outboxId))].sort((a, b) => a - b);
475
+ const ids = JSON.stringify(current.triggerIds);
476
+ const preservePayload = !!current.cardId && proofs.some(proof => proof.platformMessageIds.includes(current.cardId));
477
+ // Desired persistence may itself have failed while the controller became
478
+ // terminal. A sealed, unrendered, clear-effect obligation permits only
479
+ // the controller's own no-flight/no-unknown retry, not business replay.
480
+ const retry = !!current.linkedReply && !current.linkedReply.rendered && !Object.keys(current.editEffects || {}).length;
481
+ const attempts = (current.linkedReply?.attempts || 0) + 1;
482
+ this.save({ ...current, linkedReply: { runId: latestProof.runId, outboxId: latestProof.outboxId, deliveredAt: latestProof.deliveredAt,
483
+ relatedOutboxIds: proofIds, attempts,
484
+ retryAfter: Date.now() + Math.min(15 * 60_000, 60_000 * 2 ** Math.min(attempts - 1, 4)),
485
+ ...(preservePayload ? { rendered: "reply_payload" } : {}) } });
486
+ const flight = Promise.resolve().then(async () => {
487
+ const fresh = this.latest(entry.record);
488
+ const freshProofs = fresh && witnesses(fresh, preservePayload);
489
+ if (this.disposed || !fresh || fresh.retired || fresh.lease !== entry.record.lease ||
490
+ fresh.stopEpoch !== this.store.getStopGeneration(this.bot, chat) || JSON.stringify(fresh.triggerIds) !== ids ||
491
+ fresh.linkedReply?.outboxId !== latestProof.outboxId || !freshProofs ||
492
+ JSON.stringify([...new Set(freshProofs.map(proof => proof.outboxId))].sort((a, b) => a - b)) !== JSON.stringify(proofIds))
493
+ return;
494
+ await entry.live.linkedReplyDelivered(latestProof.deliveredAt, preservePayload, retry);
495
+ }).catch(() => {
496
+ // Own rejection immediately: a later synchronous cohort read can fail
497
+ // before this method reaches allSettled. UI outages must not crash LMA.
498
+ }).finally(() => { if (this.replyFlights.get(current.id) === flight)
499
+ this.replyFlights.delete(current.id); });
500
+ this.replyFlights.set(current.id, flight);
501
+ flights.push(flight);
502
+ }
503
+ await Promise.allSettled(flights);
504
+ }
401
505
  async settleRun(chat, runId) {
402
506
  if (!runId || !this.enabled(chat))
403
507
  return;
508
+ await this.reconcileDeliveredReply(chat, runId);
404
509
  for (const row of this.records(chat))
405
510
  if (this.usable(row) && row.runId === runId)
406
511
  await this.hydrate(row).live.complete();
@@ -467,5 +572,6 @@ export class QueuedPresentations {
467
572
  for (const entry of this.entries.values())
468
573
  entry.live.dispose();
469
574
  this.entries.clear();
575
+ this.replyFlights.clear();
470
576
  }
471
577
  }
@@ -0,0 +1,30 @@
1
+ # 1.8.17 — Close linked insertion status cards after the original-chat reply
2
+
3
+ Presentation-only correction to 1.8.16. A legacy insertion's independent queued
4
+ card can now compact to **关联轮次已回复 / Linked run replied** when the exact
5
+ associated run has a complete, platform-confirmed final reply in the same
6
+ Bot/application/chat/stop epoch. Elapsed time uses delivery time, not repair time.
7
+
8
+ This is deliberately **not** proof that the inserted input was committed,
9
+ consumed or completed. No insertion acceptance, Get/DONE, model-sync, ordinary
10
+ admission, task execution, business replay or delivery custody semantics change.
11
+
12
+ - Require exact message identity, input timestamp, run/session compatibility and
13
+ all accepted members of a coalesced cohort; reject incomplete/unknown delivery,
14
+ nonfinal/error/verbose notices, foreign roots, old epochs and earlier replies.
15
+ - Seal all eligible card cohorts before asynchronous effects, so a new input is
16
+ never silently included in a closing card. Preserve a card already containing
17
+ the actual business answer rather than replacing that answer with a status.
18
+ - Recover known cards after restart from durable original-chat SDK receipts.
19
+ Persist desired versus rendered UI status separately. Proven-zero-effect edits
20
+ may retry with bounded backoff; attempted/unknown effects remain fenced.
21
+ Successful edit receipts survive local journal-write failures without another
22
+ transport call.
23
+ - No schema migration, Core/plugin or Gateway configuration change is needed.
24
+ Bridge activation is needed; preserve runtime native dependencies and existing
25
+ business/insertion records. Do not replay or reset sessions to repair a card.
26
+
27
+ Validation: bridge and all bundled plugin builds; public SDK type contract;
28
+ complete offline suite **66 files / 1791 tests passed**, including 28 linked-card
29
+ regressions and existing insertion-honesty/no-replay tests. Live activation and
30
+ original-card platform acceptance are separate deployment gates.
@@ -0,0 +1,23 @@
1
+ # 1.8.18 — Independently witness every reply in a historical mixed card
2
+
3
+ Extends 1.8.17's presentation-only fix for a live race observed during validation:
4
+ 1.8.16 had already reused an old insertion's waiting card for a newer ordinary
5
+ run. Closing it from only the old run's reply would be incorrect; leaving it
6
+ waiting after both separate replies were delivered would also be misleading.
7
+
8
+ The card now closes only when **each member's own associated run** has a complete
9
+ original-Bot/application/chat/epoch platform reply receipt that postdates that
10
+ member. The newer ordinary reply cannot substitute for the older insertion's
11
+ reply, or vice versa. Elapsed time uses the latest verified delivery. Persist the
12
+ exact related outbox IDs; revalidate all proofs and membership before the effect.
13
+ If any verified reply already occupies the card, retain its business content
14
+ without a compact replacement. Pending/unknown members still prevent closure.
15
+
16
+ No input commitment/consumption, Get/DONE, model-sync, admission, business retry,
17
+ Core/plugin/config or schema changes. Bridge activation only; native dependencies
18
+ are unchanged. Existing unknown-effect fencing and UI-only zero-effect retries
19
+ remain intact. 1.8.17 tags/packages are not replaced; this is a new immutable patch.
20
+
21
+ Validation: bridge/plugins build, public SDK contract, **66 files / 1795 offline
22
+ tests**, including 32 linked-card controls (4 new historical mixed-card cases).
23
+ Live platform-card readback remains a separate deployment gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.16",
3
+ "version": "1.8.18",
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": {
@@ -77,7 +77,9 @@
77
77
  "docs/releases/1.8.10.md",
78
78
  "docs/releases/1.8.11.md",
79
79
  "docs/releases/1.8.15.md",
80
- "docs/releases/1.8.16.md"
80
+ "docs/releases/1.8.16.md",
81
+ "docs/releases/1.8.17.md",
82
+ "docs/releases/1.8.18.md"
81
83
  ],
82
84
  "engines": {
83
85
  "node": ">=22"