openclaw-lark-multi-agent 1.8.16 → 1.8.17
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/dist/feishu-bot.js +10 -0
- package/dist/live-status.d.ts +5 -2
- package/dist/live-status.js +35 -1
- package/dist/message-store.d.ts +10 -0
- package/dist/message-store.js +24 -0
- package/dist/queued-presentation.d.ts +6 -0
- package/dist/queued-presentation.js +97 -10
- package/docs/releases/1.8.17.md +30 -0
- package/package.json +3 -2
package/dist/feishu-bot.js
CHANGED
|
@@ -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.
|
package/dist/live-status.d.ts
CHANGED
|
@@ -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)
|
package/dist/live-status.js
CHANGED
|
@@ -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) {
|
package/dist/message-store.d.ts
CHANGED
|
@@ -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;
|
package/dist/message-store.js
CHANGED
|
@@ -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;
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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,75 @@ 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 eligible = (row) => {
|
|
432
|
+
const retry = row.view?.state === "replied" && !!row.linkedReply && !row.linkedReply.rendered &&
|
|
433
|
+
row.create === "accepted" && !!row.cardId && !Object.keys(row.editEffects || {}).length;
|
|
434
|
+
if ((!this.usable(row) && !retry) || row.retired || row.stopEpoch !== epoch || row.linkedReply?.rendered ||
|
|
435
|
+
evidence.createdAt < row.startedAt || (row.expectedInsertionRoot !== runId && row.runId !== runId))
|
|
436
|
+
return false;
|
|
437
|
+
const ordinary = row.runId === runId && !!row.ownerId && !!row.batchId ? row.ordinaryTriggerIds || [] : [];
|
|
438
|
+
const extra = row.triggerIds.filter(id => !ordinary.includes(id));
|
|
439
|
+
return extra.length > 0 && extra.every(id => {
|
|
440
|
+
const item = this.store.getInsertion(this.bot, chat, id);
|
|
441
|
+
const input = item?.messageId ? this.store.getMessageByMessageId(item.messageId) : undefined;
|
|
442
|
+
return row.enqueueAcceptedIds?.includes(id) && item?.state === 'accepted' && item.stopEpoch === epoch &&
|
|
443
|
+
item.expectedRunId === runId && (!evidence.sessionId || item.sessionId === evidence.sessionId) &&
|
|
444
|
+
input?.id === id && input.chatId === chat && input.senderType === 'human' && input.timestamp <= evidence.createdAt;
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
const flights = [];
|
|
448
|
+
// There is no await in this staging loop: seal every currently eligible
|
|
449
|
+
// cohort before the first SDK effect can allow new ingress to interleave.
|
|
450
|
+
for (const snapshot of this.records(chat)) {
|
|
451
|
+
const row = this.latest(snapshot);
|
|
452
|
+
if (!row || !eligible(row) || this.replyFlights.has(row.id) || (row.linkedReply?.retryAfter || 0) > Date.now())
|
|
453
|
+
continue;
|
|
454
|
+
const entry = this.hydrate(row);
|
|
455
|
+
const current = this.latest(entry.record);
|
|
456
|
+
if (!current || !eligible(current) || current.lease !== entry.record.lease)
|
|
457
|
+
continue;
|
|
458
|
+
const ids = JSON.stringify(current.triggerIds);
|
|
459
|
+
const preservePayload = !!current.cardId && evidence.platformMessageIds.includes(current.cardId);
|
|
460
|
+
// Desired persistence may itself have failed while the controller became
|
|
461
|
+
// terminal. A sealed, unrendered, clear-effect obligation permits only
|
|
462
|
+
// the controller's own no-flight/no-unknown retry, not business replay.
|
|
463
|
+
const retry = !!current.linkedReply && !current.linkedReply.rendered && !Object.keys(current.editEffects || {}).length;
|
|
464
|
+
const attempts = (current.linkedReply?.attempts || 0) + 1;
|
|
465
|
+
this.save({ ...current, linkedReply: { runId, outboxId: evidence.outboxId, deliveredAt: evidence.deliveredAt, attempts,
|
|
466
|
+
retryAfter: Date.now() + Math.min(15 * 60_000, 60_000 * 2 ** Math.min(attempts - 1, 4)),
|
|
467
|
+
...(preservePayload ? { rendered: "reply_payload" } : {}) } });
|
|
468
|
+
const flight = Promise.resolve().then(async () => {
|
|
469
|
+
const fresh = this.latest(entry.record);
|
|
470
|
+
const proof = this.store.getDeliveredRunReply(this.bot, this.app, chat, runId, epoch);
|
|
471
|
+
if (this.disposed || !fresh || fresh.retired || fresh.lease !== entry.record.lease ||
|
|
472
|
+
fresh.stopEpoch !== this.store.getStopGeneration(this.bot, chat) || JSON.stringify(fresh.triggerIds) !== ids ||
|
|
473
|
+
fresh.linkedReply?.outboxId !== evidence.outboxId || proof?.outboxId !== evidence.outboxId)
|
|
474
|
+
return;
|
|
475
|
+
await entry.live.linkedReplyDelivered(evidence.deliveredAt, preservePayload, retry);
|
|
476
|
+
}).catch(() => {
|
|
477
|
+
// Own rejection immediately: a later synchronous cohort read can fail
|
|
478
|
+
// before this method reaches allSettled. UI outages must not crash LMA.
|
|
479
|
+
}).finally(() => { if (this.replyFlights.get(current.id) === flight)
|
|
480
|
+
this.replyFlights.delete(current.id); });
|
|
481
|
+
this.replyFlights.set(current.id, flight);
|
|
482
|
+
flights.push(flight);
|
|
483
|
+
}
|
|
484
|
+
await Promise.allSettled(flights);
|
|
485
|
+
}
|
|
401
486
|
async settleRun(chat, runId) {
|
|
402
487
|
if (!runId || !this.enabled(chat))
|
|
403
488
|
return;
|
|
489
|
+
await this.reconcileDeliveredReply(chat, runId);
|
|
404
490
|
for (const row of this.records(chat))
|
|
405
491
|
if (this.usable(row) && row.runId === runId)
|
|
406
492
|
await this.hydrate(row).live.complete();
|
|
@@ -467,5 +553,6 @@ export class QueuedPresentations {
|
|
|
467
553
|
for (const entry of this.entries.values())
|
|
468
554
|
entry.live.dispose();
|
|
469
555
|
this.entries.clear();
|
|
556
|
+
this.replyFlights.clear();
|
|
470
557
|
}
|
|
471
558
|
}
|
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-lark-multi-agent",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.17",
|
|
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,8 @@
|
|
|
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"
|
|
81
82
|
],
|
|
82
83
|
"engines": {
|
|
83
84
|
"node": ">=22"
|