openclaw-lark-multi-agent 1.8.4 → 1.8.6

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.
@@ -100,7 +100,69 @@ export type GoJobRow = {
100
100
  };
101
101
  export declare class MessageStore {
102
102
  private db;
103
- constructor(dbPath?: string);
103
+ private knownDeliveryReceipts;
104
+ constructor(dbPath?: string, options?: {
105
+ existingOnly?: boolean;
106
+ });
107
+ /** LMA-owned recovery journal. Never edits OpenClaw state. Unknown attempts
108
+ * remain durable and are never selected by the ordinary pending drain. */
109
+ private initReliabilityTables;
110
+ getStopGeneration(bot: string, chat: string): number;
111
+ bumpStopGeneration(bot: string, chat: string): number;
112
+ setReactionIntent(bot: string, chat: string, row: number, record: object): void;
113
+ getReactionIntent(bot: string, chat: string, row: number): any;
114
+ listReactionIntents(bot: string, chat?: string): any[];
115
+ bindDeliveryRun(bot: string, chat: string, runId: string, record: object): void;
116
+ getDeliveryRunTarget(bot: string, chat: string, runId: string): any;
117
+ /** Reserved is persisted before the send-attempt callback; dispatching is
118
+ * persisted before the RPC. Only reserved + unchanged stop generation proves
119
+ * no business effect and may be restored on startup. Unknown never replays. */
120
+ recoverUnsentReservations(bot: string): number;
121
+ saveAdmission(sessionKey: string, submissionId: string, state: string, record: object): void;
122
+ listAdmissions(sessionKey: string): any[];
123
+ stopExactRun(sessionKey: string, runId: string): void;
124
+ isStoppedObservation(sessionKey: string, runId: string): boolean;
125
+ settleRunReactionIntents(bot: string, chat: string, runId: string): void;
126
+ cancelReactionIntents(bot: string, chat: string, epoch: number): void;
127
+ saveRunObservation(sessionKey: string, runId: string, record: object, nextCheckAt?: number): void;
128
+ runObservationState(sessionKey: string, runId: string): string | undefined;
129
+ stopSessionObservations(sessionKey: string): void;
130
+ settleExactRunReactions(sessionKey: string, runId: string): void;
131
+ finishRunObservation(sessionKey: string, runId: string, state?: string): void;
132
+ listRunObservations(sessionKey?: string, due?: number, limit?: number): any[];
133
+ saveInsertion(bot: string, chat: string, row: number, id: string, state: string, record: object, next?: number): void;
134
+ claimInsertionAttempt(bot: string, chat: string, row: number): boolean;
135
+ getInsertion(bot: string, chat: string, row: number): any;
136
+ listInsertions(bot: string, chat?: string): any[];
137
+ isInsertionReserved(bot: string, chat: string, row: number): boolean;
138
+ stopInsertions(bot: string, chat: string): void;
139
+ hasUnknownDeliveryOperation(outbox: number): boolean;
140
+ /** Retain the full known result BEFORE the fallible main-journal checkpoint.
141
+ * The memory copy survives local SQLite failures while this process is alive;
142
+ * the independent insert journal also survives an interrupted UPDATE/reopen. */
143
+ rememberKnownDeliveryReceipt(outbox: number, key: string, record: any): void;
144
+ flushKnownDeliveryReceipts(outbox: number): void;
145
+ listDeliveryReceipts(outbox: number): Array<{
146
+ messageId: string;
147
+ operation: string;
148
+ payload: any;
149
+ stage: string;
150
+ }>;
151
+ getDeliveryOperation(outbox: number, key: string): any;
152
+ deferLocalDelivery(id: number, reason: string): number;
153
+ saveDeliveryOperation(outbox: number, key: string, state: string, record?: object): void;
154
+ /** Context registration and the corresponding payload checkpoint are one
155
+ * SQLite transaction. Receipt aliases deduplicate platform echoes by identity,
156
+ * not by text (two identical legitimate tasks must remain two tasks). */
157
+ confirmDeliveryStage(id: number, stage: string, content: string, receipts: Array<{
158
+ messageId: string;
159
+ }>, checkpoint: 'text' | 'none' | number): void;
160
+ registerPartialBodyContext(id: number, onlyIfAlreadyPartial?: boolean): void;
161
+ registerPartialAttachmentContext(id: number): void;
162
+ /** Read-only preview. Historical text_delivered alone is not a receipt.
163
+ * Explicit repair requires supplied exact outbox+platform IDs, never resends. */
164
+ previewMissingDeliveryContext(bot: string, limit?: number): any[];
165
+ registerHistoricalDeliveryContext(id: number, bot: string, chat: string, messageIds: string[]): void;
104
166
  /** Go / Go-Checker supervision state (design: artifacts/lma-go-checker-design-20260917 §10). */
105
167
  private initGoTables;
106
168
  private ensureGoIntegrationSchema;
@@ -4,14 +4,332 @@ import { resolve } from "path";
4
4
  import { mkdirSync } from "fs";
5
5
  export class MessageStore {
6
6
  db;
7
- constructor(dbPath) {
7
+ knownDeliveryReceipts = new Map();
8
+ constructor(dbPath, options = {}) {
8
9
  const path = dbPath || resolve(process.cwd(), "data", "messages.db");
9
10
  const dir = resolve(path, "..");
10
- mkdirSync(dir, { recursive: true });
11
- this.db = new Database(path);
11
+ if (!options.existingOnly)
12
+ mkdirSync(dir, { recursive: true });
13
+ this.db = new Database(path, options.existingOnly ? { fileMustExist: true } : {});
14
+ if (options.existingOnly)
15
+ return;
12
16
  this.db.pragma("journal_mode = WAL");
13
17
  this.init();
14
18
  this.initGoTables();
19
+ this.initReliabilityTables();
20
+ }
21
+ /** LMA-owned recovery journal. Never edits OpenClaw state. Unknown attempts
22
+ * remain durable and are never selected by the ordinary pending drain. */
23
+ initReliabilityTables() {
24
+ this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS lma_database_identity (id INTEGER PRIMARY KEY CHECK(id=1),owner TEXT NOT NULL);
26
+ INSERT OR IGNORE INTO lma_database_identity VALUES(1,'openclaw-lark-multi-agent:messages:v1');
27
+ CREATE TABLE IF NOT EXISTS bridge_generations(bot_name TEXT NOT NULL,chat_id TEXT NOT NULL,stop_epoch INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(bot_name,chat_id));
28
+ CREATE TABLE IF NOT EXISTS reaction_custody(bot_name TEXT NOT NULL,chat_id TEXT NOT NULL,row_id INTEGER NOT NULL,record_json TEXT NOT NULL,
29
+ PRIMARY KEY(bot_name,chat_id,row_id));
30
+ CREATE TABLE IF NOT EXISTS run_delivery_targets(bot_name TEXT NOT NULL,chat_id TEXT NOT NULL,run_id TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(bot_name,chat_id,run_id));
31
+ CREATE TABLE IF NOT EXISTS admission_custody(session_key TEXT NOT NULL,submission_id TEXT PRIMARY KEY,state TEXT NOT NULL,record_json TEXT NOT NULL);
32
+ CREATE TABLE IF NOT EXISTS run_observations (
33
+ session_key TEXT NOT NULL, run_id TEXT NOT NULL, record_json TEXT NOT NULL,
34
+ state TEXT NOT NULL DEFAULT 'observing', next_check_at INTEGER NOT NULL DEFAULT 0,
35
+ PRIMARY KEY(session_key,run_id));
36
+ CREATE TABLE IF NOT EXISTS insertion_custody (
37
+ bot_name TEXT NOT NULL,chat_id TEXT NOT NULL,message_row_id INTEGER NOT NULL,
38
+ insertion_id TEXT UNIQUE NOT NULL,record_json TEXT NOT NULL,state TEXT NOT NULL,
39
+ next_check_at INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(bot_name,chat_id,message_row_id));
40
+ CREATE TABLE IF NOT EXISTS delivery_operations (
41
+ outbox_id INTEGER NOT NULL,operation_key TEXT NOT NULL,state TEXT NOT NULL,
42
+ record_json TEXT NOT NULL DEFAULT '{}',PRIMARY KEY(outbox_id,operation_key));
43
+ CREATE TABLE IF NOT EXISTS delivery_receipt_custody (
44
+ outbox_id INTEGER NOT NULL,operation_key TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(outbox_id,operation_key));
45
+ CREATE TABLE IF NOT EXISTS delivery_local_obligations (
46
+ outbox_id INTEGER PRIMARY KEY,failures INTEGER NOT NULL DEFAULT 0,reason TEXT NOT NULL,updated_at INTEGER NOT NULL);
47
+ CREATE TABLE IF NOT EXISTS delivery_context (
48
+ outbox_id INTEGER NOT NULL,stage TEXT NOT NULL,message_row_id INTEGER,
49
+ PRIMARY KEY(outbox_id,stage));
50
+ CREATE TABLE IF NOT EXISTS platform_message_aliases (
51
+ message_id TEXT PRIMARY KEY,message_row_id INTEGER NOT NULL);
52
+ -- The preceding local candidate never dispatched these blocked inputs.
53
+ -- Migrate ONLY its explicit no-attempt rows; unknown/attempted stay fenced.
54
+ UPDATE insertion_custody SET state='pending',next_check_at=0
55
+ WHERE state='capability_blocked' AND json_extract(record_json,'$.attempted')=0;
56
+ `);
57
+ }
58
+ getStopGeneration(bot, chat) {
59
+ return this.db.prepare('SELECT stop_epoch FROM bridge_generations WHERE bot_name=? AND chat_id=?').get(bot, chat)?.stop_epoch || 0;
60
+ }
61
+ bumpStopGeneration(bot, chat) {
62
+ this.db.prepare(`INSERT INTO bridge_generations VALUES(?,?,1) ON CONFLICT(bot_name,chat_id) DO UPDATE SET stop_epoch=stop_epoch+1`).run(bot, chat);
63
+ return this.getStopGeneration(bot, chat);
64
+ }
65
+ setReactionIntent(bot, chat, row, record) {
66
+ this.db.prepare(`INSERT INTO reaction_custody VALUES(?,?,?,?) ON CONFLICT(bot_name,chat_id,row_id) DO UPDATE SET record_json=excluded.record_json`).run(bot, chat, row, JSON.stringify(record));
67
+ }
68
+ getReactionIntent(bot, chat, row) {
69
+ const r = this.db.prepare('SELECT record_json FROM reaction_custody WHERE bot_name=? AND chat_id=? AND row_id=?').get(bot, chat, row);
70
+ return r ? JSON.parse(r.record_json) : null;
71
+ }
72
+ listReactionIntents(bot, chat) {
73
+ return this.db.prepare(`SELECT chat_id,row_id,record_json FROM reaction_custody WHERE bot_name=? ${chat ? 'AND chat_id=?' : ''}
74
+ AND (json_extract(record_json,'$.desired')!=json_extract(record_json,'$.shown') OR json_array_length(json_extract(record_json,'$.cleanupReceipts'))>0) LIMIT 200`).all(bot, ...(chat ? [chat] : []))
75
+ .map(r => ({ ...JSON.parse(r.record_json), chatId: r.chat_id, rowId: r.row_id })).filter(r => r.desired !== r.shown || r.cleanupReceipts?.length);
76
+ }
77
+ bindDeliveryRun(bot, chat, runId, record) {
78
+ this.db.prepare('INSERT OR REPLACE INTO run_delivery_targets VALUES(?,?,?,?)').run(bot, chat, runId, JSON.stringify(record));
79
+ }
80
+ getDeliveryRunTarget(bot, chat, runId) {
81
+ const row = this.db.prepare('SELECT record_json FROM run_delivery_targets WHERE bot_name=? AND chat_id=? AND run_id=?').get(bot, chat, runId);
82
+ return row ? JSON.parse(row.record_json) : null;
83
+ }
84
+ /** Reserved is persisted before the send-attempt callback; dispatching is
85
+ * persisted before the RPC. Only reserved + unchanged stop generation proves
86
+ * no business effect and may be restored on startup. Unknown never replays. */
87
+ recoverUnsentReservations(bot) {
88
+ return this.db.transaction(() => {
89
+ let restored = 0;
90
+ const rows = this.db.prepare(`SELECT a.submission_id,t.chat_id,t.record_json FROM admission_custody a
91
+ JOIN run_delivery_targets t ON t.run_id=a.submission_id WHERE t.bot_name=? AND a.state='reserved' LIMIT 100`).all(bot);
92
+ for (const row of rows) {
93
+ const r = JSON.parse(row.record_json);
94
+ if (r.stopGeneration !== this.getStopGeneration(bot, row.chat_id))
95
+ continue;
96
+ for (const id of r.triggerIds || []) {
97
+ this.markPendingTrigger(bot, row.chat_id, id);
98
+ restored++;
99
+ }
100
+ this.db.prepare("UPDATE admission_custody SET state='restored-unsubmitted' WHERE submission_id=? AND state='reserved'").run(row.submission_id);
101
+ }
102
+ return restored;
103
+ })();
104
+ }
105
+ saveAdmission(sessionKey, submissionId, state, record) {
106
+ this.db.prepare(`INSERT INTO admission_custody VALUES(?,?,?,?) ON CONFLICT(submission_id) DO UPDATE SET state=excluded.state,record_json=excluded.record_json`).run(sessionKey, submissionId, state, JSON.stringify(record));
107
+ }
108
+ listAdmissions(sessionKey) {
109
+ return this.db.prepare('SELECT * FROM admission_custody WHERE session_key=? ORDER BY rowid DESC LIMIT 100').all(sessionKey).map(r => ({ ...JSON.parse(r.record_json), submissionId: r.submission_id, state: r.state }));
110
+ }
111
+ stopExactRun(sessionKey, runId) {
112
+ this.db.prepare(`INSERT INTO run_observations VALUES(?,?,'{}','stopped',0)
113
+ ON CONFLICT(session_key,run_id) DO UPDATE SET state='stopped'`).run(sessionKey, runId);
114
+ }
115
+ isStoppedObservation(sessionKey, runId) {
116
+ return !!this.db.prepare("SELECT 1 FROM run_observations WHERE session_key=? AND run_id=? AND state='stopped'").get(sessionKey, runId);
117
+ }
118
+ settleRunReactionIntents(bot, chat, runId) {
119
+ this.db.prepare(`UPDATE reaction_custody SET record_json=json_set(record_json,'$.desired','DONE')
120
+ WHERE bot_name=? AND chat_id=? AND json_extract(record_json,'$.runId')=?
121
+ AND json_extract(record_json,'$.desired') IN ('Get','Typing')
122
+ AND json_extract(record_json,'$.stopEpoch')=?`).run(bot, chat, runId, this.getStopGeneration(bot, chat));
123
+ }
124
+ cancelReactionIntents(bot, chat, epoch) {
125
+ this.db.prepare(`UPDATE reaction_custody SET record_json=json_set(record_json,'$.desired','','$.stopEpoch',?)
126
+ WHERE bot_name=? AND chat_id=? AND json_extract(record_json,'$.desired') IN ('Typing','Get')`).run(epoch, bot, chat);
127
+ }
128
+ saveRunObservation(sessionKey, runId, record, nextCheckAt = Date.now()) {
129
+ this.db.prepare(`INSERT INTO run_observations VALUES(?,?,?,'observing',?)
130
+ ON CONFLICT(session_key,run_id) DO UPDATE SET record_json=excluded.record_json,next_check_at=excluded.next_check_at
131
+ WHERE run_observations.state='observing'`).run(sessionKey, runId, JSON.stringify(record), nextCheckAt);
132
+ }
133
+ runObservationState(sessionKey, runId) {
134
+ return this.db.prepare('SELECT state FROM run_observations WHERE session_key=? AND run_id=?').get(sessionKey, runId)?.state;
135
+ }
136
+ stopSessionObservations(sessionKey) {
137
+ this.db.prepare("UPDATE run_observations SET state='stopped' WHERE session_key=? AND state IN ('observing','outbox_owned')").run(sessionKey);
138
+ }
139
+ settleExactRunReactions(sessionKey, runId) {
140
+ const targets = this.db.prepare('SELECT bot_name,chat_id FROM run_delivery_targets WHERE run_id=?').all(runId);
141
+ for (const target of targets) {
142
+ // run ids can repeat across gateways/sessions: require the exact durable target.
143
+ const row = this.getDeliveryRunTarget(target.bot_name, target.chat_id, runId);
144
+ const observed = this.db.prepare("SELECT 1 FROM run_observations WHERE session_key=? AND run_id=? AND state='observing'").get(sessionKey, runId);
145
+ if (observed && (row?.sessionKey || `agent:main:lma-${target.bot_name.toLowerCase()}-${target.chat_id}`) === sessionKey)
146
+ this.settleRunReactionIntents(target.bot_name, target.chat_id, runId);
147
+ }
148
+ }
149
+ finishRunObservation(sessionKey, runId, state = 'settled') {
150
+ this.db.prepare("UPDATE run_observations SET state=? WHERE session_key=? AND run_id=? AND state='observing'").run(state, sessionKey, runId);
151
+ }
152
+ listRunObservations(sessionKey, due = Date.now(), limit = 50) {
153
+ return this.db.prepare(`SELECT * FROM run_observations WHERE state='observing' AND next_check_at<=?
154
+ ${sessionKey ? 'AND session_key=?' : ''} ORDER BY next_check_at LIMIT ?`).all(due, ...(sessionKey ? [sessionKey] : []), limit)
155
+ .map(r => ({ ...JSON.parse(r.record_json), sessionKey: r.session_key, runId: r.run_id }));
156
+ }
157
+ saveInsertion(bot, chat, row, id, state, record, next = Date.now()) {
158
+ this.db.prepare(`INSERT INTO insertion_custody VALUES(?,?,?,?,?,?,?)
159
+ ON CONFLICT(bot_name,chat_id,message_row_id) DO UPDATE SET state=excluded.state,record_json=excluded.record_json,next_check_at=excluded.next_check_at
160
+ WHERE insertion_custody.state NOT IN ('stopped','committed')`).run(bot, chat, row, id, JSON.stringify(record), state, next);
161
+ }
162
+ claimInsertionAttempt(bot, chat, row) {
163
+ return this.db.prepare(`UPDATE insertion_custody SET state='dispatching',record_json=json_set(record_json,'$.attempted',json('true'))
164
+ WHERE bot_name=? AND chat_id=? AND message_row_id=? AND state='pending'`).run(bot, chat, row).changes === 1;
165
+ }
166
+ getInsertion(bot, chat, row) {
167
+ const r = this.db.prepare('SELECT * FROM insertion_custody WHERE bot_name=? AND chat_id=? AND message_row_id=?').get(bot, chat, row);
168
+ return r ? { ...JSON.parse(r.record_json), state: r.state, insertionId: r.insertion_id, rowId: r.message_row_id } : null;
169
+ }
170
+ listInsertions(bot, chat) {
171
+ return this.db.prepare(`SELECT * FROM insertion_custody WHERE bot_name=? ${chat ? 'AND chat_id=?' : ''}
172
+ AND state IN ('pending','accepted','unknown','dispatching') ORDER BY next_check_at LIMIT 100`).all(bot, ...(chat ? [chat] : []))
173
+ .map(r => ({ ...JSON.parse(r.record_json), state: r.state, insertionId: r.insertion_id, rowId: r.message_row_id, chatId: r.chat_id, nextCheckAt: r.next_check_at }));
174
+ }
175
+ isInsertionReserved(bot, chat, row) {
176
+ const item = this.getInsertion(bot, chat, row);
177
+ return !!item && !['ordinary', 'stopped'].includes(item.state);
178
+ }
179
+ stopInsertions(bot, chat) {
180
+ this.db.prepare("UPDATE insertion_custody SET state='stopped' WHERE bot_name=? AND chat_id=? AND state NOT IN ('committed','ordinary')").run(bot, chat);
181
+ }
182
+ hasUnknownDeliveryOperation(outbox) {
183
+ return !!this.db.prepare(`SELECT 1 FROM delivery_operations o WHERE outbox_id=? AND state IN ('attempted','unknown')
184
+ AND NOT EXISTS(SELECT 1 FROM delivery_receipt_custody k WHERE k.outbox_id=o.outbox_id AND k.operation_key=o.operation_key) LIMIT 1`).get(outbox);
185
+ }
186
+ /** Retain the full known result BEFORE the fallible main-journal checkpoint.
187
+ * The memory copy survives local SQLite failures while this process is alive;
188
+ * the independent insert journal also survives an interrupted UPDATE/reopen. */
189
+ rememberKnownDeliveryReceipt(outbox, key, record) {
190
+ this.knownDeliveryReceipts.set(`${outbox}:${key}`, { outbox, key, record });
191
+ this.flushKnownDeliveryReceipts(outbox);
192
+ }
193
+ flushKnownDeliveryReceipts(outbox) {
194
+ for (const [identity, item] of this.knownDeliveryReceipts)
195
+ if (item.outbox === outbox) {
196
+ this.db.prepare('INSERT OR REPLACE INTO delivery_receipt_custody VALUES(?,?,?)').run(outbox, item.key, JSON.stringify(item.record));
197
+ this.knownDeliveryReceipts.delete(identity);
198
+ }
199
+ }
200
+ listDeliveryReceipts(outbox) {
201
+ const records = new Map();
202
+ for (const r of this.db.prepare(`SELECT operation_key,record_json FROM delivery_operations WHERE outbox_id=? AND state='accepted'
203
+ UNION ALL SELECT operation_key,record_json FROM delivery_receipt_custody WHERE outbox_id=?`).all(outbox, outbox))
204
+ records.set(r.operation_key, JSON.parse(r.record_json));
205
+ for (const item of this.knownDeliveryReceipts.values())
206
+ if (item.outbox === outbox)
207
+ records.set(item.key, item.record);
208
+ return [...records].map(([key, r]) => ({ ...r, stage: key.split(':').slice(2, -1).join(':') }))
209
+ .filter(r => r.result?.data?.message_id || r.result?.message_id)
210
+ .map(r => ({ messageId: r.result?.data?.message_id || r.result?.message_id, operation: r.operation, payload: r.payload, stage: r.stage }));
211
+ }
212
+ getDeliveryOperation(outbox, key) {
213
+ const known = this.knownDeliveryReceipts.get(`${outbox}:${key}`)?.record;
214
+ const receipt = this.db.prepare('SELECT record_json FROM delivery_receipt_custody WHERE outbox_id=? AND operation_key=?').get(outbox, key);
215
+ if (known || receipt)
216
+ return { state: 'accepted', ...(known || JSON.parse(receipt.record_json)) };
217
+ const r = this.db.prepare('SELECT * FROM delivery_operations WHERE outbox_id=? AND operation_key=?').get(outbox, key);
218
+ return r ? { state: r.state, ...JSON.parse(r.record_json) } : null;
219
+ }
220
+ deferLocalDelivery(id, reason) {
221
+ return this.db.transaction(() => {
222
+ this.db.prepare(`INSERT INTO delivery_local_obligations VALUES(?,1,?,?) ON CONFLICT(outbox_id)
223
+ DO UPDATE SET failures=failures+1,reason=excluded.reason,updated_at=excluded.updated_at`).run(id, reason, Date.now());
224
+ // Local persistence and unknown audit custody consume no platform retry budget.
225
+ this.db.prepare("UPDATE delivery_outbox SET status='pending',attempts=MAX(0,attempts-1),updated_at=? WHERE id=? AND status='delivering'").run(Date.now(), id);
226
+ return this.db.prepare('SELECT failures FROM delivery_local_obligations WHERE outbox_id=?').get(id).failures;
227
+ })();
228
+ }
229
+ saveDeliveryOperation(outbox, key, state, record = {}) {
230
+ this.db.prepare(`INSERT INTO delivery_operations VALUES(?,?,?,?) ON CONFLICT(outbox_id,operation_key)
231
+ DO UPDATE SET state=excluded.state,record_json=excluded.record_json`).run(outbox, key, state, JSON.stringify(record));
232
+ }
233
+ /** Context registration and the corresponding payload checkpoint are one
234
+ * SQLite transaction. Receipt aliases deduplicate platform echoes by identity,
235
+ * not by text (two identical legitimate tasks must remain two tasks). */
236
+ confirmDeliveryStage(id, stage, content, receipts, checkpoint) {
237
+ this.db.transaction(() => {
238
+ const item = this.db.prepare('SELECT * FROM delivery_outbox WHERE id=?').get(id);
239
+ if (!item)
240
+ throw new Error('Missing outbox identity');
241
+ if (!this.db.prepare('SELECT 1 FROM delivery_context WHERE outbox_id=? AND stage=?').get(id, stage)) {
242
+ const worthy = ['assistant_visible', 'discussion', 'proactive'].some(k => item.source_type.startsWith(k));
243
+ let row = null;
244
+ if (worthy && content.trim() && content.trim().toUpperCase() !== 'NO_REPLY') {
245
+ const existing = receipts.map(r => this.getMessageId(r.messageId)).find(Boolean);
246
+ if (existing) {
247
+ const source = this.db.prepare('SELECT chat_id,sender_type,sender_name FROM messages WHERE id=?').get(existing);
248
+ if (source?.chat_id !== item.chat_id || source?.sender_type !== 'bot' || source?.sender_name !== item.bot_name)
249
+ throw new Error('Platform receipt identity conflicts with existing message origin');
250
+ }
251
+ const identity = JSON.parse(item.delivery_meta_json || '{}').botIdentity || item.bot_name;
252
+ const messageId = `lma:${JSON.stringify([identity, item.bot_name, item.chat_id, item.delivery_key, stage])}`;
253
+ row = existing ?? this.insert({ chatId: item.chat_id, messageId,
254
+ senderType: 'bot', senderName: item.bot_name, content, timestamp: Date.now() });
255
+ if (row === -1)
256
+ row = this.getMessageId(messageId);
257
+ for (const receipt of receipts)
258
+ if (row)
259
+ this.db.prepare('INSERT OR IGNORE INTO platform_message_aliases VALUES(?,?)').run(receipt.messageId, row);
260
+ }
261
+ this.db.prepare('INSERT INTO delivery_context VALUES(?,?,?)').run(id, stage, row);
262
+ }
263
+ if (checkpoint === 'text')
264
+ this.markDeliveryTextDelivered(id);
265
+ else if (typeof checkpoint === 'number')
266
+ this.markDeliveryAttachmentCursor(id, checkpoint);
267
+ })();
268
+ }
269
+ registerPartialBodyContext(id, onlyIfAlreadyPartial = false) {
270
+ const hasPartial = !!this.db.prepare("SELECT 1 FROM delivery_context WHERE outbox_id=? AND stage LIKE 'body:partial:%' LIMIT 1").get(id);
271
+ if (onlyIfAlreadyPartial && !hasPartial)
272
+ return;
273
+ for (const receipt of this.listDeliveryReceipts(id).filter(r => r.stage === 'body' && r.payload?.msg_type === 'text')) {
274
+ let text = '';
275
+ try {
276
+ text = JSON.parse(receipt.payload.content).text || '';
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ this.confirmDeliveryStage(id, `body:partial:${receipt.messageId}`, text, [receipt], 'none');
282
+ }
283
+ }
284
+ registerPartialAttachmentContext(id) {
285
+ for (const receipt of this.listDeliveryReceipts(id).filter(r => /^attachment:\d+$/.test(r.stage))) {
286
+ // Only confirmed, visible file/image payload. A document upload is not a message.
287
+ if (!['file', 'image'].includes(receipt.payload?.msg_type))
288
+ continue;
289
+ let payload;
290
+ try {
291
+ payload = JSON.parse(receipt.payload.content);
292
+ }
293
+ catch {
294
+ continue;
295
+ }
296
+ this.confirmDeliveryStage(id, `${receipt.stage}:partial:${receipt.messageId}`, `[Attachment: ${receipt.payload.msg_type}] ${JSON.stringify(payload)}`, [receipt], 'none');
297
+ }
298
+ }
299
+ /** Read-only preview. Historical text_delivered alone is not a receipt.
300
+ * Explicit repair requires supplied exact outbox+platform IDs, never resends. */
301
+ previewMissingDeliveryContext(bot, limit = 100) {
302
+ return this.db.prepare(`SELECT d.id,d.chat_id,d.delivery_key,d.source_type,d.text_delivered,d.attachment_cursor
303
+ FROM delivery_outbox d WHERE bot_name=? AND (status='delivered' AND NOT EXISTS(SELECT 1 FROM delivery_context c WHERE c.outbox_id=d.id)
304
+ OR EXISTS(SELECT 1 FROM delivery_operations o WHERE o.outbox_id=d.id AND o.state IN ('attempted','unknown'))) ORDER BY d.id LIMIT ?`).all(bot, limit);
305
+ }
306
+ registerHistoricalDeliveryContext(id, bot, chat, messageIds) {
307
+ const item = this.db.prepare("SELECT * FROM delivery_outbox WHERE id=? AND bot_name=? AND chat_id=?").get(id, bot, chat);
308
+ if (!item || !messageIds.length || messageIds.some(x => !x) || new Set(messageIds).size !== messageIds.length)
309
+ throw new Error('Exact outbox and independently verified platform receipts required');
310
+ if (item.status === 'delivered') {
311
+ this.confirmDeliveryStage(id, 'body', item.content, messageIds.map(messageId => ({ messageId })), 'text');
312
+ return;
313
+ }
314
+ // Explicit operator reconciliation, never an automatic rejection or resend.
315
+ // Match every supplied message to exactly one unresolved visible operation.
316
+ const unknown = this.db.prepare("SELECT * FROM delivery_operations WHERE outbox_id=? AND state IN ('attempted','unknown') ORDER BY operation_key").all(id)
317
+ .map(r => ({ ...r, record: JSON.parse(r.record_json) }));
318
+ const legacyBody = (r) => r.record.visible === undefined && r.operation_key.split(':').slice(2, -1).join(':') === 'body';
319
+ if (unknown.length !== messageIds.length || unknown.some(r => (!r.record.visible || !r.record.payload) && !legacyBody(r) || this.getDeliveryOperation(id, r.operation_key)?.state === 'accepted'))
320
+ throw new Error('Exact visible unknown operations and independent receipts required');
321
+ this.db.transaction(() => {
322
+ unknown.forEach((r, i) => {
323
+ const record = { ...r.record, visible: true, result: { code: 0, data: { message_id: messageIds[i], chat_id: chat } }, reconciliation: { independentlyVerified: true, at: Date.now(), bot, chat } };
324
+ this.rememberKnownDeliveryReceipt(id, r.operation_key, record);
325
+ this.saveDeliveryOperation(id, r.operation_key, 'accepted', record);
326
+ });
327
+ this.registerPartialBodyContext(id);
328
+ this.registerPartialAttachmentContext(id);
329
+ if (unknown.every(r => r.operation_key.split(':').slice(2, -1).join(':') === 'body'))
330
+ this.confirmDeliveryStage(id, 'body', item.content, messageIds.map(messageId => ({ messageId })), 'text');
331
+ this.db.prepare("UPDATE delivery_outbox SET status='pending' WHERE id=?").run(id);
332
+ })();
15
333
  }
16
334
  /** Go / Go-Checker supervision state (design: artifacts/lma-go-checker-design-20260917 §10). */
17
335
  initGoTables() {
@@ -664,6 +982,8 @@ export class MessageStore {
664
982
  */
665
983
  insert(msg) {
666
984
  try {
985
+ if (this.db.prepare('SELECT 1 FROM platform_message_aliases WHERE message_id=?').get(msg.messageId))
986
+ return -1;
667
987
  const result = this.db.prepare(`
668
988
  INSERT INTO messages (chat_id, message_id, sender_type, sender_name, content, timestamp, trigger_kind)
669
989
  VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -678,7 +998,7 @@ export class MessageStore {
678
998
  }
679
999
  getMessageId(messageId) {
680
1000
  const row = this.db.prepare(`SELECT id FROM messages WHERE message_id = ?`).get(messageId);
681
- return row?.id || null;
1001
+ return row?.id || this.db.prepare('SELECT message_row_id FROM platform_message_aliases WHERE message_id=?').get(messageId)?.message_row_id || null;
682
1002
  }
683
1003
  getMessageByMessageId(messageId) {
684
1004
  const row = this.db.prepare(`SELECT * FROM messages WHERE message_id = ?`).get(messageId);
@@ -776,13 +1096,20 @@ export class MessageStore {
776
1096
  `).run(botName, chatId, triggerMessageRowId, Date.now(), replyMessageId);
777
1097
  }
778
1098
  enqueueDelivery(item) {
779
- const now = Date.now();
780
- const result = this.db.prepare(`
1099
+ return this.db.transaction(() => {
1100
+ const p = JSON.parse(item.deliveryMetaJson || '{}').provenance;
1101
+ if (p?.final && p.sessionKey && p.runId && this.isStoppedObservation(p.sessionKey, p.runId))
1102
+ return null;
1103
+ const now = Date.now();
1104
+ const result = this.db.prepare(`
781
1105
  INSERT OR IGNORE INTO delivery_outbox
782
1106
  (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)
783
1107
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
784
1108
  `).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);
785
- return result.changes ? Number(result.lastInsertRowid) : null;
1109
+ if (p?.final === true && p.sessionKey && p.runId)
1110
+ this.finishRunObservation(p.sessionKey, p.runId, 'outbox_owned');
1111
+ return result.changes ? Number(result.lastInsertRowid) : null;
1112
+ })();
786
1113
  }
787
1114
  getDeliveryBySource(sessionKey, sourceType, sourceId) {
788
1115
  const row = this.db.prepare(`
@@ -899,7 +1226,8 @@ export class MessageStore {
899
1226
  const result = this.db.prepare(`
900
1227
  UPDATE delivery_outbox
901
1228
  SET status = 'pending', updated_at = ?
902
- WHERE bot_name = ? AND status = 'delivering'
1229
+ WHERE bot_name = ? AND (status = 'delivering' OR status='failed' AND EXISTS(
1230
+ SELECT 1 FROM delivery_operations o WHERE o.outbox_id=delivery_outbox.id AND o.state IN ('accepted','attempted','unknown')))
903
1231
  `).run(Date.now(), botName);
904
1232
  return result.changes;
905
1233
  }
@@ -2,7 +2,7 @@ import type { GoExecutorResult } from "./go-execution.js";
2
2
  import { type ErrorProvenance, type ProactiveMessageMeta } from "./terminal-errors.js";
3
3
  import { SessionWaitPaused, type SessionRuntimeStatus } from "./session-status.js";
4
4
  import { OpenClawConfig } from "./config.js";
5
- import { ChatMessage } from "./message-store.js";
5
+ import { MessageStore, ChatMessage } from "./message-store.js";
6
6
  export type ProgressEvent = {
7
7
  kind: "tool";
8
8
  phase: "start" | "end" | "error";
@@ -31,6 +31,10 @@ export declare class InactiveRunObservation extends Error {
31
31
  export declare class SupersededObservation extends Error {
32
32
  constructor(reason?: string);
33
33
  }
34
+ export declare class AdmissionUnconfirmed extends Error {
35
+ readonly submissionId: string;
36
+ constructor(submissionId: string, cause?: unknown);
37
+ }
34
38
  export type ChatAttachment = {
35
39
  type?: string;
36
40
  mimeType?: string;
@@ -67,8 +71,10 @@ export declare class OpenClawClient {
67
71
  /** Deduplicates 2026.8 canonical `agent.stream=tool` events against legacy
68
72
  * `stream=item` mirrors when a runtime emits both representations. */
69
73
  private recentToolEventKeys;
74
+ private sessionSettlementCallbacks;
70
75
  private sessionMessageCallbacks;
71
76
  private progressCallbacks;
77
+ private progressGenerations;
72
78
  /** Live collectReply fences per canonical session key. The most recent
73
79
  * collector for a session owns the shared event bucket; a new batch (or a
74
80
  * queue-custody revocation) fences stale ones synchronously so they cannot
@@ -115,6 +121,19 @@ export declare class OpenClawClient {
115
121
  private maintenanceConcurrency;
116
122
  private activeMaintenanceRpcs;
117
123
  private maintenanceWaiters;
124
+ private observationEpoch;
125
+ private stopGenerations;
126
+ private recoveryStore?;
127
+ private recoveryTimer?;
128
+ private recoveryChecking;
129
+ private eventCollectors;
130
+ private forwardedFinals;
131
+ /** One paced recovery scheduler for all persisted exact runs. No business
132
+ * replay, no per-run infinite timers, and each query has its own deadline. */
133
+ bindRecoveryStore(store: MessageStore): void;
134
+ private scheduleRecovery;
135
+ private recoverExactRuns;
136
+ private bufferEvent;
118
137
  constructor(config: OpenClawConfig);
119
138
  connect(): Promise<void>;
120
139
  private _doConnect;
@@ -179,24 +198,27 @@ export declare class OpenClawClient {
179
198
  message: string;
180
199
  label?: string;
181
200
  }): Promise<any>;
182
- /**
183
- * Submit a mid-run message through the lma-steer plugin.
184
- *
185
- * OpenClaw's public `chat.send { queueMode: "steer" }` returns `started` even
186
- * when it falls through to a later ordinary run; it therefore cannot tell the
187
- * bridge whether THIS message entered the active run. The plugin resolves the
188
- * active embedded run and reports `steered` only when it actually queued there.
189
- * A matching session.message remains the final consumption confirmation.
190
- *
191
- * If the plugin is missing, stale, or rejects the injection, return unavailable
192
- * and let LMA's durable trigger run normally after the current turn. Never use
193
- * the ambiguous public admission as a realtime-insertion success signal.
194
- */
195
- steer(sessionKey: string, text: string, displayText?: string): Promise<{
196
- status: "steered" | "unavailable" | "no_active_run";
197
- runId?: string;
198
- /** Cancel provisional transcript correlation when LMA falls back to a normal run. */
199
- cancelPending?: () => void;
201
+ /** Real-time session input uses the coordinated plugin's durable callback
202
+ * receipt protocol. SDK eligibility/text mirrors never prove acceptance;
203
+ * missing/legacy plugin observations stay pending without blind mutation. */
204
+ exactRunSettled(runId: string): Promise<boolean>;
205
+ cancelSteerSession(sessionKey: string, stopEpoch: number): Promise<void>;
206
+ steer(sessionKey: string, text: string, displayText?: string, identity?: {
207
+ insertionId: string;
208
+ expectedRunId: string;
209
+ expectedSessionId?: string;
210
+ stopEpoch?: number;
211
+ cancel?: boolean;
212
+ queryOnly?: boolean;
213
+ originalRunEnded?: boolean;
214
+ }): Promise<{
215
+ status: 'pending' | 'accepted' | 'rejected' | 'unknown' | 'target_changed' | 'target_ended' | 'stopped' | 'capability_blocked' | 'unavailable' | 'no_active_run';
216
+ attempted?: boolean;
217
+ ordinaryEligible?: boolean;
218
+ sessionId?: string;
219
+ reason?: string;
220
+ insertionId?: string;
221
+ expectedRunId?: string;
200
222
  }>;
201
223
  /** Callbacks fired when a steered message is confirmed consumed by the model
202
224
  * (matching sessionUser event). Keyed by bare/short session key. Multiple may
@@ -205,8 +227,8 @@ export declare class OpenClawClient {
205
227
  * Callbacks are one-shot: returning true removes them. */
206
228
  private steerConsumedCallbacks;
207
229
  /** Register a callback (per bare session key) invoked when a steered message
208
- * is actually consumed by the active run. The bridge uses this to flip the
209
- * Feishu reaction from "Typing" (awaiting insertion) to "Get" (inserted).
230
+ * is actually consumed by the active run. This legacy text observer is
231
+ * presentation-only; it cannot authorize a receipt reaction.
210
232
  * Return true from the callback to consume/remove it (one-shot). */
211
233
  onSteerConsumed(sessionKey: string, matchText: string, cb: (text: string) => boolean | void): () => void;
212
234
  private shortKey;
@@ -389,9 +411,11 @@ export declare class OpenClawClient {
389
411
  timeoutMs?: number;
390
412
  emptyFinalAsNoReply?: boolean;
391
413
  /** Called immediately before issuing chat.send RPC; use for at-most-once bookkeeping. */
392
- onSendAttempt?: () => void | Promise<void>;
414
+ onSendAttempt?: (submissionId: string) => void | Promise<void>;
393
415
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
394
416
  onSubmitted?: (runId: string) => void | Promise<void>;
417
+ /** Identifies collection target; not an acceptance receipt. */
418
+ onRunIdentified?: (runId: string) => void | Promise<void>;
395
419
  onProgress?: (event: ProgressEvent) => void | Promise<void>;
396
420
  onWaitPaused?: (notice: SessionWaitPaused) => void | Promise<void>;
397
421
  }): Promise<string>;
@@ -424,9 +448,11 @@ export declare class OpenClawClient {
424
448
  /** Disable bridge attachment hints for native commands and other exact pass-through messages. */
425
449
  includeBridgeAttachmentHint?: boolean;
426
450
  /** Called immediately before issuing chat.send RPC; use for at-most-once bookkeeping. */
427
- onSendAttempt?: () => void | Promise<void>;
451
+ onSendAttempt?: (submissionId: string) => void | Promise<void>;
428
452
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
429
453
  onSubmitted?: (runId: string) => void | Promise<void>;
454
+ /** Identifies collection target; not an acceptance receipt. */
455
+ onRunIdentified?: (runId: string) => void | Promise<void>;
430
456
  onProgress?: (event: ProgressEvent) => void | Promise<void>;
431
457
  onWaitPaused?: (notice: SessionWaitPaused) => void | Promise<void>;
432
458
  }): Promise<string>;
@@ -438,7 +464,7 @@ export declare class OpenClawClient {
438
464
  * Subscribe to message events for a session.
439
465
  * When the agent proactively produces a message, the callback fires.
440
466
  */
441
- subscribeSession(sessionKey: string, onMessage: (text: string, meta?: ProactiveMessageMeta) => void): Promise<void>;
467
+ subscribeSession(sessionKey: string, onMessage: (text: string, meta?: ProactiveMessageMeta) => void, onSettled?: (meta: ProactiveMessageMeta) => void | Promise<void>): Promise<void>;
442
468
  setVerboseTranscriptDelivery(sessionKey: string, enabled: boolean): void;
443
469
  unsubscribeSession(sessionKey: string): Promise<void>;
444
470
  /**