openclaw-lark-multi-agent 1.8.19 → 1.8.21
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/config.d.ts +15 -1
- package/dist/config.js +14 -2
- package/dist/feishu-bot.d.ts +47 -7
- package/dist/feishu-bot.js +423 -163
- package/dist/go-controller.d.ts +2 -0
- package/dist/go-controller.js +24 -8
- package/dist/input-status-display.d.ts +45 -0
- package/dist/input-status-display.js +67 -0
- package/dist/live-status.d.ts +22 -3
- package/dist/live-status.js +93 -25
- package/dist/message-store.d.ts +68 -2
- package/dist/message-store.js +249 -13
- package/dist/openclaw-client.d.ts +0 -6
- package/dist/openclaw-client.js +178 -67
- package/dist/queued-presentation.d.ts +8 -1
- package/dist/queued-presentation.js +109 -4
- package/dist/terminal-errors.d.ts +12 -0
- package/dist/terminal-errors.js +23 -3
- package/docs/releases/1.8.20.md +30 -0
- package/package.json +4 -3
package/dist/message-store.d.ts
CHANGED
|
@@ -8,8 +8,10 @@ export interface ChatInfo {
|
|
|
8
8
|
members: string;
|
|
9
9
|
/** Comma-separated member names */
|
|
10
10
|
memberNames: string;
|
|
11
|
-
/**
|
|
11
|
+
/** Display label of the p2p owner; not a durable identity once pinned. */
|
|
12
12
|
ownerBot: string;
|
|
13
|
+
/** Stable application identity of the p2p owner. Empty on legacy rows. */
|
|
14
|
+
ownerAppId?: string;
|
|
13
15
|
/** Legacy chat-level free discussion flag; per-bot mode is authoritative. */
|
|
14
16
|
freeDiscussion: boolean;
|
|
15
17
|
verbose: boolean;
|
|
@@ -169,7 +171,7 @@ export declare class MessageStore {
|
|
|
169
171
|
recoverUnsentReservations(bot: string): number;
|
|
170
172
|
saveAdmission(sessionKey: string, submissionId: string, state: string, record: object): void;
|
|
171
173
|
listAdmissions(sessionKey: string): any[];
|
|
172
|
-
stopExactRun(sessionKey: string, runId: string): void;
|
|
174
|
+
stopExactRun(sessionKey: string, runId: string, userStopped?: boolean): void;
|
|
173
175
|
isStoppedObservation(sessionKey: string, runId: string): boolean;
|
|
174
176
|
hasUnconfirmedRunDelivery(bot: string, chat: string, runId: string): boolean;
|
|
175
177
|
/** UI-only witness of an exact run's visible reply in the original Bot/chat.
|
|
@@ -188,6 +190,27 @@ export declare class MessageStore {
|
|
|
188
190
|
hasYieldedObservation(sessionKey: string): boolean;
|
|
189
191
|
getRunObservation(sessionKey: string, runId: string): any;
|
|
190
192
|
runObservationState(sessionKey: string, runId: string): string | undefined;
|
|
193
|
+
/** Observed reply latency for this chat, from real delivered replies.
|
|
194
|
+
*
|
|
195
|
+
* Used to size the "input still unconfirmed" window. A fixed short timer
|
|
196
|
+
* misfired on slow sessions where nearly every legitimate reply took longer
|
|
197
|
+
* than the timer, producing a false "not confirmed" notice for a run that
|
|
198
|
+
* was in fact progressing normally. Returns null when there is no history. */
|
|
199
|
+
observedReplyLatencyMs(bot: string, chat: string): number | null;
|
|
200
|
+
/** True only when THIS run was explicitly stopped by the user.
|
|
201
|
+
*
|
|
202
|
+
* Distinct from a retired observation: a group /stop retires every bot's
|
|
203
|
+
* observation on the shared session, which is NOT a user request to kill
|
|
204
|
+
* each individual run. */
|
|
205
|
+
isExplicitlyStoppedObservation(sessionKey: string, runId: string): boolean;
|
|
206
|
+
/** Retire session-scoped observation, but NEVER a run whose committed
|
|
207
|
+
* terminal reply has already been observed.
|
|
208
|
+
*
|
|
209
|
+
* A run with terminalObserved=true holds a COMPUTED answer. Retiring its
|
|
210
|
+
* observation (via /stop, /reset or an abort) removes the only durable
|
|
211
|
+
* custody that answer has, so recoverExactRuns can never deliver it and the
|
|
212
|
+
* computed reply is silently lost. A run without a terminal observation has
|
|
213
|
+
* nothing to deliver yet and may be retired normally. */
|
|
191
214
|
stopSessionObservations(sessionKey: string): void;
|
|
192
215
|
settleExactRunReactions(sessionKey: string, runId: string): void;
|
|
193
216
|
finishRunObservation(sessionKey: string, runId: string, state?: string): void;
|
|
@@ -209,6 +232,23 @@ export declare class MessageStore {
|
|
|
209
232
|
isInsertionReserved(bot: string, chat: string, row: number): boolean;
|
|
210
233
|
stopInsertionAtEpoch(bot: string, chat: string, row: number, epoch: number): void;
|
|
211
234
|
stopInsertions(bot: string, chat: string): void;
|
|
235
|
+
/** Group-scoped stop cleanup: every bot in this chat, not just the caller.
|
|
236
|
+
* A bare /stop in a multi-bot group is executed by ONE bot, but each bot keeps
|
|
237
|
+
* its own pending_triggers / insertion_custody / dispatch_custody / reaction
|
|
238
|
+
* rows for the same chat. Stopping only the executing bot left the others
|
|
239
|
+
* believing the chat was still busy, so their next message was queued and
|
|
240
|
+
* never dispatched. */
|
|
241
|
+
stopChatForAllBots(chat: string, exceptBot?: string): {
|
|
242
|
+
stoppedInsertions: number;
|
|
243
|
+
clearedTriggers: number;
|
|
244
|
+
clearedCustody: number;
|
|
245
|
+
};
|
|
246
|
+
/** Same predicate as stopInsertions, but reports how many rows it retired. */
|
|
247
|
+
stopInsertionsCount(bot: string, chat: string): number;
|
|
248
|
+
/** Drop a bot's dispatch custody for a chat regardless of owner/batch
|
|
249
|
+
* identity. forceExpireQueueOwner can no-op when the local queue owner is
|
|
250
|
+
* already gone, which used to strand the row forever. */
|
|
251
|
+
clearDispatchCustodyForChat(bot: string, chat: string): number;
|
|
212
252
|
hasUnknownDeliveryOperation(outbox: number): boolean;
|
|
213
253
|
/** Includes non-visible uploads/doc creation: absence of a message_id is
|
|
214
254
|
* not proof that no SDK effect has been attempted or accepted. */
|
|
@@ -333,6 +373,10 @@ export declare class MessageStore {
|
|
|
333
373
|
runId: string;
|
|
334
374
|
}, content: string, attachmentsJson: string): boolean;
|
|
335
375
|
hasRunFinalDelivery(botName: string, chatId: string, p: ErrorProvenance, confirmed: boolean): boolean;
|
|
376
|
+
/** Compatibility is exact provenance/legacy key + original app/epoch, never
|
|
377
|
+
* text similarity. Existing attempted/unknown/delivered rows retain custody;
|
|
378
|
+
* this lookup neither requeues nor edits historical deliveries. */
|
|
379
|
+
findRunErrorOwner(bot: string, chat: string, p: ErrorProvenance): DeliveryOutboxItem | undefined;
|
|
336
380
|
renewUnsentErrorDelivery(id: number, content: string, contentHash: string, metadata: string): void;
|
|
337
381
|
/** Withdraw only an unsent error notice. Never claim it was delivered or touch a result row. */
|
|
338
382
|
withdrawErrorDelivery(id: number): void;
|
|
@@ -421,6 +465,21 @@ export declare class MessageStore {
|
|
|
421
465
|
* guard shutting everyone down after N total bot messages.
|
|
422
466
|
*/
|
|
423
467
|
getBotStreak(chatId: string, botName: string): number;
|
|
468
|
+
/** Authorize the already app-specific SDK's private chat. The first event
|
|
469
|
+
* pins a new chat; a matching legacy label may be backfilled. Thereafter only
|
|
470
|
+
* the exact application pin authorizes access, including across bot renames.
|
|
471
|
+
* Existing unowned/mismatched/malformed rows fail closed, not first-claim wins.
|
|
472
|
+
* This operation changes no history, sync, pending input or delivery state. */
|
|
473
|
+
claimP2pOwner(chatId: string, botName: string, appId: string): boolean;
|
|
474
|
+
/** Operator-only CAS after independently verifying the original account.
|
|
475
|
+
* No runtime caller: a retired legacy label alone cannot prove app ownership.
|
|
476
|
+
* Idempotent for the exact destination, refuses any conflicting existing pin.
|
|
477
|
+
* Preserves cache metadata/timestamps and every business/history table.
|
|
478
|
+
* verifiedPreviousOwnerBot is an explicit independently verified old scope
|
|
479
|
+
* when an earlier operator repair already replaced the legacy cache label. */
|
|
480
|
+
migrateLegacyP2pOwner(chatId: string, expectedOwnerBot: string, botName: string, appId: string, verifiedPreviousOwnerBot?: string): boolean;
|
|
481
|
+
private rememberP2pOwnerAlias;
|
|
482
|
+
private validOwnerAppId;
|
|
424
483
|
upsertChatInfo(info: ChatInfo): void;
|
|
425
484
|
setFreeDiscussion(chatId: string, on: boolean): void;
|
|
426
485
|
setVerbose(chatId: string, verbose: boolean): void;
|
|
@@ -458,6 +517,13 @@ export declare class MessageStore {
|
|
|
458
517
|
*/
|
|
459
518
|
markBotProcessed(botName: string, messageId: string): void;
|
|
460
519
|
tryMarkBotProcessed(botName: string, messageId: string): boolean;
|
|
520
|
+
/** Private-only monotonic ingress fence. Write even for an owner-rejected
|
|
521
|
+
* event: a rejected input is a tombstone, never permission to retry after a
|
|
522
|
+
* rename. Legacy name tombstones are consulted ONLY in a pinned/explicitly
|
|
523
|
+
* verified owner scope (plus the existing current-name fence), never inferred
|
|
524
|
+
* from message history or mentions. Each duplicate promotes only its own ID;
|
|
525
|
+
* no historical name ledger is copied or renamed. Ownership is checked later. */
|
|
526
|
+
tryMarkP2pProcessed(appId: string, chatId: string, botName: string, messageId: string): boolean;
|
|
461
527
|
createGoJob(j: {
|
|
462
528
|
botName: string;
|
|
463
529
|
chatId: string;
|
package/dist/message-store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { errorIdentity } from "./terminal-errors.js";
|
|
1
|
+
import { closedStopReason, errorNoticeIdentity, errorIdentity } from "./terminal-errors.js";
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
import { resolve } from "path";
|
|
4
4
|
import { mkdirSync } from "fs";
|
|
@@ -258,9 +258,10 @@ export class MessageStore {
|
|
|
258
258
|
listAdmissions(sessionKey) {
|
|
259
259
|
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 }));
|
|
260
260
|
}
|
|
261
|
-
stopExactRun(sessionKey, runId) {
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
stopExactRun(sessionKey, runId, userStopped = false) {
|
|
262
|
+
const record = userStopped ? { userStopped: true } : {};
|
|
263
|
+
this.db.prepare(`INSERT INTO run_observations VALUES(?,?,?,'stopped',0)
|
|
264
|
+
ON CONFLICT(session_key,run_id) DO UPDATE SET state='stopped', record_json=json_patch(run_observations.record_json,excluded.record_json)`).run(sessionKey, runId, JSON.stringify(record));
|
|
264
265
|
}
|
|
265
266
|
isStoppedObservation(sessionKey, runId) {
|
|
266
267
|
return !!this.db.prepare("SELECT 1 FROM run_observations WHERE session_key=? AND run_id=? AND state='stopped'").get(sessionKey, runId);
|
|
@@ -329,8 +330,45 @@ export class MessageStore {
|
|
|
329
330
|
runObservationState(sessionKey, runId) {
|
|
330
331
|
return this.db.prepare('SELECT state FROM run_observations WHERE session_key=? AND run_id=?').get(sessionKey, runId)?.state;
|
|
331
332
|
}
|
|
333
|
+
/** Observed reply latency for this chat, from real delivered replies.
|
|
334
|
+
*
|
|
335
|
+
* Used to size the "input still unconfirmed" window. A fixed short timer
|
|
336
|
+
* misfired on slow sessions where nearly every legitimate reply took longer
|
|
337
|
+
* than the timer, producing a false "not confirmed" notice for a run that
|
|
338
|
+
* was in fact progressing normally. Returns null when there is no history. */
|
|
339
|
+
observedReplyLatencyMs(bot, chat) {
|
|
340
|
+
// p90 over real delivered replies. MAX would be dominated by a single
|
|
341
|
+
// pathological outlier and inflate the window for every later request.
|
|
342
|
+
const sql = "SELECT (d.delivered_at - m.timestamp) AS lat FROM delivered_replies d" +
|
|
343
|
+
" JOIN messages m ON m.id=d.trigger_message_row_id" +
|
|
344
|
+
" WHERE d.bot_name=? AND d.chat_id=? AND d.delivered_at > m.timestamp AND m.timestamp > 0" +
|
|
345
|
+
" ORDER BY d.delivered_at DESC LIMIT 30";
|
|
346
|
+
const rows = this.db.prepare(sql).all(bot, chat);
|
|
347
|
+
const lats = rows.map(r => Number(r.lat)).filter(x => Number.isFinite(x) && x > 0).sort((a, b) => a - b);
|
|
348
|
+
if (!lats.length)
|
|
349
|
+
return null;
|
|
350
|
+
const p90 = lats[Math.min(lats.length - 1, Math.floor(lats.length * 0.9))];
|
|
351
|
+
return Number.isFinite(p90) && p90 > 0 ? p90 : null;
|
|
352
|
+
}
|
|
353
|
+
/** True only when THIS run was explicitly stopped by the user.
|
|
354
|
+
*
|
|
355
|
+
* Distinct from a retired observation: a group /stop retires every bot's
|
|
356
|
+
* observation on the shared session, which is NOT a user request to kill
|
|
357
|
+
* each individual run. */
|
|
358
|
+
isExplicitlyStoppedObservation(sessionKey, runId) {
|
|
359
|
+
const row = this.db.prepare("SELECT 1 FROM run_observations WHERE session_key=? AND run_id=? AND state='stopped' AND json_extract(record_json,'$.userStopped')=1").get(sessionKey, runId);
|
|
360
|
+
return !!row;
|
|
361
|
+
}
|
|
362
|
+
/** Retire session-scoped observation, but NEVER a run whose committed
|
|
363
|
+
* terminal reply has already been observed.
|
|
364
|
+
*
|
|
365
|
+
* A run with terminalObserved=true holds a COMPUTED answer. Retiring its
|
|
366
|
+
* observation (via /stop, /reset or an abort) removes the only durable
|
|
367
|
+
* custody that answer has, so recoverExactRuns can never deliver it and the
|
|
368
|
+
* computed reply is silently lost. A run without a terminal observation has
|
|
369
|
+
* nothing to deliver yet and may be retired normally. */
|
|
332
370
|
stopSessionObservations(sessionKey) {
|
|
333
|
-
this.db.prepare("UPDATE run_observations SET state='stopped' WHERE session_key=? AND state IN ('observing','outbox_owned')").run(sessionKey);
|
|
371
|
+
this.db.prepare("UPDATE run_observations SET state='stopped' WHERE session_key=? AND state IN ('observing','outbox_owned') AND json_extract(record_json,'$.terminalObserved') IS NOT 1").run(sessionKey);
|
|
334
372
|
}
|
|
335
373
|
settleExactRunReactions(sessionKey, runId) {
|
|
336
374
|
const targets = this.db.prepare('SELECT bot_name,chat_id FROM run_delivery_targets WHERE run_id=?').all(runId);
|
|
@@ -406,6 +444,45 @@ export class MessageStore {
|
|
|
406
444
|
stopInsertions(bot, chat) {
|
|
407
445
|
this.db.prepare("UPDATE insertion_custody SET state='stopped' WHERE bot_name=? AND chat_id=? AND (state NOT IN ('committed','ordinary') OR state='ordinary' AND json_type(record_json,'$.followupAdmissionGuard')='object')").run(bot, chat);
|
|
408
446
|
}
|
|
447
|
+
/** Group-scoped stop cleanup: every bot in this chat, not just the caller.
|
|
448
|
+
* A bare /stop in a multi-bot group is executed by ONE bot, but each bot keeps
|
|
449
|
+
* its own pending_triggers / insertion_custody / dispatch_custody / reaction
|
|
450
|
+
* rows for the same chat. Stopping only the executing bot left the others
|
|
451
|
+
* believing the chat was still busy, so their next message was queued and
|
|
452
|
+
* never dispatched. */
|
|
453
|
+
stopChatForAllBots(chat, exceptBot) {
|
|
454
|
+
const bots = this.db.prepare("SELECT DISTINCT bot_name FROM pending_triggers WHERE chat_id=? UNION SELECT DISTINCT bot_name FROM insertion_custody WHERE chat_id=? UNION SELECT DISTINCT bot_name FROM dispatch_custody WHERE chat_id=? UNION SELECT DISTINCT bot_name FROM reaction_custody WHERE chat_id=?").all(chat, chat, chat, chat).map((r) => r.bot_name);
|
|
455
|
+
let stoppedInsertions = 0, clearedTriggers = 0, clearedCustody = 0;
|
|
456
|
+
for (const bot of bots) {
|
|
457
|
+
if (bot === exceptBot) {
|
|
458
|
+
// The executing bot already bumped its own epoch and cancelled its own
|
|
459
|
+
// reaction intents; touching them again would desynchronize its
|
|
460
|
+
// in-memory generation from the durable one.
|
|
461
|
+
stoppedInsertions += this.stopInsertionsCount(bot, chat);
|
|
462
|
+
clearedTriggers += this.clearAllPendingTriggers(bot, chat);
|
|
463
|
+
clearedCustody += this.clearDispatchCustodyForChat(bot, chat);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
// Invalidate every epoch-scoped record for this bot/chat: a stale
|
|
467
|
+
// dispatch_custody with an old stopEpoch can never be released later.
|
|
468
|
+
this.bumpStopGeneration(bot, chat);
|
|
469
|
+
this.cancelReactionIntents(bot, chat, this.getStopGeneration(bot, chat));
|
|
470
|
+
stoppedInsertions += this.stopInsertionsCount(bot, chat);
|
|
471
|
+
clearedTriggers += this.clearAllPendingTriggers(bot, chat);
|
|
472
|
+
clearedCustody += this.clearDispatchCustodyForChat(bot, chat);
|
|
473
|
+
}
|
|
474
|
+
return { stoppedInsertions, clearedTriggers, clearedCustody };
|
|
475
|
+
}
|
|
476
|
+
/** Same predicate as stopInsertions, but reports how many rows it retired. */
|
|
477
|
+
stopInsertionsCount(bot, chat) {
|
|
478
|
+
return this.db.prepare("UPDATE insertion_custody SET state='stopped' WHERE bot_name=? AND chat_id=? AND (state NOT IN ('committed','ordinary') OR state='ordinary' AND json_type(record_json,'$.followupAdmissionGuard')='object')").run(bot, chat).changes;
|
|
479
|
+
}
|
|
480
|
+
/** Drop a bot's dispatch custody for a chat regardless of owner/batch
|
|
481
|
+
* identity. forceExpireQueueOwner can no-op when the local queue owner is
|
|
482
|
+
* already gone, which used to strand the row forever. */
|
|
483
|
+
clearDispatchCustodyForChat(bot, chat) {
|
|
484
|
+
return this.db.prepare("DELETE FROM dispatch_custody WHERE bot_name=? AND chat_id=?").run(bot, chat).changes;
|
|
485
|
+
}
|
|
409
486
|
hasUnknownDeliveryOperation(outbox) {
|
|
410
487
|
return !!this.db.prepare(`SELECT 1 FROM delivery_operations o WHERE outbox_id=? AND state IN ('attempted','unknown')
|
|
411
488
|
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);
|
|
@@ -772,7 +849,7 @@ export class MessageStore {
|
|
|
772
849
|
this.db.prepare('UPDATE go_action_lifecycle SET state=?,receipt_json=? WHERE go_id=? AND action_key=? AND state!=?').run(state, receipt === undefined ? '' : JSON.stringify(receipt), goId, actionKey, 'accepted');
|
|
773
850
|
}
|
|
774
851
|
observeRunError(botName, chatId, provenance) {
|
|
775
|
-
const noticeId =
|
|
852
|
+
const noticeId = errorNoticeIdentity(provenance);
|
|
776
853
|
const previous = this.getErrorNotice(botName, chatId, noticeId);
|
|
777
854
|
if (previous) {
|
|
778
855
|
if (provenance.userStop) {
|
|
@@ -783,8 +860,8 @@ export class MessageStore {
|
|
|
783
860
|
}
|
|
784
861
|
// A committed transcript abort is weaker than a gateway terminal error.
|
|
785
862
|
// One stronger envelope can wake a parked observation; repeats cannot.
|
|
786
|
-
const terminalChat = (p) => p.source === "chat" && (p.state === "error" || (p.state === "final" && p.stopReason === "error"));
|
|
787
|
-
if (!provenance.userStop && !previous.provenance.userStop && terminalChat(provenance) && !terminalChat(previous.provenance)
|
|
863
|
+
const terminalChat = (p) => p.source === "chat" && (p.state === "error" || p.state === 'aborted' && !!closedStopReason(p.stopReason) || (p.state === "final" && p.stopReason === "error"));
|
|
864
|
+
if (!provenance.userStop && !previous.provenance.userStop && terminalChat(provenance) && (!terminalChat(previous.provenance) || !!closedStopReason(provenance.stopReason) && !closedStopReason(previous.provenance.stopReason))
|
|
788
865
|
&& ["pending", "parked"].includes(previous.status)) {
|
|
789
866
|
previous.provenance = provenance;
|
|
790
867
|
previous.status = "pending";
|
|
@@ -842,8 +919,45 @@ export class MessageStore {
|
|
|
842
919
|
AND json_extract(delivery_meta_json,'$.provenance.final')=1
|
|
843
920
|
AND json_extract(delivery_meta_json,'$.provenance.sessionKey')=?
|
|
844
921
|
AND json_extract(delivery_meta_json,'$.provenance.runId')=?
|
|
922
|
+
AND (? IS NULL OR json_extract(delivery_meta_json,'$.botIdentity')=?)
|
|
923
|
+
AND (? IS NULL OR json_extract(delivery_meta_json,'$.deliveryStopEpoch')=?)
|
|
924
|
+
AND (? IS NULL OR json_extract(delivery_meta_json,'$.provenance.sessionId') IS NULL OR json_extract(delivery_meta_json,'$.provenance.sessionId')=?)
|
|
845
925
|
AND ${confirmed ? "status='delivered'" : "status IN ('pending','delivering')"} LIMIT 1`)
|
|
846
|
-
.get(botName, chatId, p.sessionKey, p.runId));
|
|
926
|
+
.get(botName, chatId, p.sessionKey, p.runId, p.sessionId ? p.botIdentity ?? null : null, p.sessionId ? p.botIdentity ?? null : null, p.sessionId ? p.stopEpoch ?? null : null, p.sessionId ? p.stopEpoch ?? null : null, p.sessionId ?? null, p.sessionId ?? null));
|
|
927
|
+
}
|
|
928
|
+
/** Compatibility is exact provenance/legacy key + original app/epoch, never
|
|
929
|
+
* text similarity. Existing attempted/unknown/delivered rows retain custody;
|
|
930
|
+
* this lookup neither requeues nor edits historical deliveries. */
|
|
931
|
+
findRunErrorOwner(bot, chat, p) {
|
|
932
|
+
const target = this.getDeliveryRunTarget(bot, chat, p.runId);
|
|
933
|
+
const oldKey = `run-error:${errorIdentity(p.sessionKey, p.runId)}`;
|
|
934
|
+
const triggerKey = target?.triggerId ? `trigger:${target.triggerId}:provider-error` : '';
|
|
935
|
+
const rows = this.db.prepare(`SELECT * FROM delivery_outbox WHERE bot_name=? AND chat_id=?
|
|
936
|
+
AND source_type IN ('run_error','provider_error') AND (delivery_key IN (?,?,?) OR
|
|
937
|
+
json_extract(CASE WHEN json_valid(delivery_meta_json) THEN delivery_meta_json ELSE '{}' END,'$.provenance.runId')=?) ORDER BY id LIMIT 100`)
|
|
938
|
+
.all(bot, chat, oldKey, triggerKey, `run-error:${errorNoticeIdentity(p)}`, p.runId);
|
|
939
|
+
for (const row of rows) {
|
|
940
|
+
let meta;
|
|
941
|
+
try {
|
|
942
|
+
meta = JSON.parse(row.delivery_meta_json);
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (p.botIdentity && meta.botIdentity !== p.botIdentity || p.stopEpoch !== undefined && meta.deliveryStopEpoch !== p.stopEpoch)
|
|
948
|
+
continue;
|
|
949
|
+
const q = meta.provenance;
|
|
950
|
+
if (q?.runId && q.runId !== p.runId || q?.sessionKey && q.sessionKey !== p.sessionKey)
|
|
951
|
+
continue;
|
|
952
|
+
if (p.sessionId && q?.sessionId && q.sessionId !== p.sessionId)
|
|
953
|
+
continue;
|
|
954
|
+
const exact = q?.runId === p.runId && q.sessionKey === p.sessionKey;
|
|
955
|
+
const legacy = row.delivery_key === oldKey || row.delivery_key === triggerKey && target?.sessionKey === p.sessionKey &&
|
|
956
|
+
(p.stopEpoch === undefined || target.stopGeneration === p.stopEpoch) && (!p.sessionId || !target.sessionId || target.sessionId === p.sessionId);
|
|
957
|
+
if (exact || legacy)
|
|
958
|
+
return this.mapDelivery(row);
|
|
959
|
+
}
|
|
960
|
+
return undefined;
|
|
847
961
|
}
|
|
848
962
|
renewUnsentErrorDelivery(id, content, contentHash, metadata) {
|
|
849
963
|
this.db.prepare(`UPDATE delivery_outbox SET status='pending',content=?,content_hash=?,delivery_meta_json=?,updated_at=?
|
|
@@ -921,6 +1035,18 @@ export class MessageStore {
|
|
|
921
1035
|
PRIMARY KEY (bot_name, message_id)
|
|
922
1036
|
);
|
|
923
1037
|
|
|
1038
|
+
-- Private ingress dedup only: neither table grants chat ownership or
|
|
1039
|
+
-- changes name-scoped sessions, sync, triggers, delivery or group routing.
|
|
1040
|
+
CREATE TABLE IF NOT EXISTS p2p_processed_events (
|
|
1041
|
+
app_id TEXT NOT NULL, chat_id TEXT NOT NULL, message_id TEXT NOT NULL,
|
|
1042
|
+
PRIMARY KEY (app_id, chat_id, message_id)
|
|
1043
|
+
);
|
|
1044
|
+
CREATE TABLE IF NOT EXISTS p2p_owner_aliases (
|
|
1045
|
+
app_id TEXT NOT NULL, chat_id TEXT NOT NULL, bot_name TEXT NOT NULL,
|
|
1046
|
+
proof TEXT NOT NULL CHECK(proof IN ('pinned_owner','verified_migration')),
|
|
1047
|
+
PRIMARY KEY (app_id, chat_id, bot_name)
|
|
1048
|
+
);
|
|
1049
|
+
|
|
924
1050
|
-- Tracks recalled human messages. Recalled messages are kept in the raw
|
|
925
1051
|
-- message ledger for audit, but excluded from future OpenClaw context and
|
|
926
1052
|
-- pending trigger processing.
|
|
@@ -1183,6 +1309,13 @@ export class MessageStore {
|
|
|
1183
1309
|
catch {
|
|
1184
1310
|
// Column already exists
|
|
1185
1311
|
}
|
|
1312
|
+
// Additive only: never infer ownership from a retired display name. Legacy
|
|
1313
|
+
// rows are pinned lazily only when their existing owner label still matches.
|
|
1314
|
+
this.db.transaction(() => {
|
|
1315
|
+
if (!this.db.prepare('PRAGMA table_info(chat_info)').all().some(row => row.name === 'owner_app_id')) {
|
|
1316
|
+
this.db.exec(`ALTER TABLE chat_info ADD COLUMN owner_app_id TEXT NOT NULL DEFAULT ''`);
|
|
1317
|
+
}
|
|
1318
|
+
}).immediate();
|
|
1186
1319
|
// Migration: add trigger_kind column if missing
|
|
1187
1320
|
try {
|
|
1188
1321
|
this.db.exec(`ALTER TABLE messages ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'normal'`);
|
|
@@ -1727,10 +1860,87 @@ export class MessageStore {
|
|
|
1727
1860
|
return count;
|
|
1728
1861
|
}
|
|
1729
1862
|
// --- Chat info ---
|
|
1863
|
+
/** Authorize the already app-specific SDK's private chat. The first event
|
|
1864
|
+
* pins a new chat; a matching legacy label may be backfilled. Thereafter only
|
|
1865
|
+
* the exact application pin authorizes access, including across bot renames.
|
|
1866
|
+
* Existing unowned/mismatched/malformed rows fail closed, not first-claim wins.
|
|
1867
|
+
* This operation changes no history, sync, pending input or delivery state. */
|
|
1868
|
+
claimP2pOwner(chatId, botName, appId) {
|
|
1869
|
+
if (!chatId || !botName?.trim() || !this.validOwnerAppId(appId))
|
|
1870
|
+
return false;
|
|
1871
|
+
return this.db.transaction(() => {
|
|
1872
|
+
const row = this.db.prepare('SELECT chat_type,owner_bot,owner_app_id FROM chat_info WHERE chat_id=?').get(chatId);
|
|
1873
|
+
if (!row) {
|
|
1874
|
+
this.db.prepare(`INSERT INTO chat_info(chat_id,chat_type,chat_name,owner_bot,owner_app_id,updated_at)
|
|
1875
|
+
VALUES(?,'p2p','私聊',?,?,?)`).run(chatId, botName, appId, Date.now());
|
|
1876
|
+
this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
if (row.chat_type !== 'p2p')
|
|
1880
|
+
return false;
|
|
1881
|
+
if (row.owner_app_id === '') {
|
|
1882
|
+
if (row.owner_bot !== botName)
|
|
1883
|
+
return false;
|
|
1884
|
+
const pinned = this.db.prepare(`UPDATE chat_info SET owner_app_id=? WHERE chat_id=? AND chat_type='p2p' AND owner_bot=? AND owner_app_id=''`)
|
|
1885
|
+
.run(appId, chatId, botName).changes === 1;
|
|
1886
|
+
if (pinned)
|
|
1887
|
+
this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
|
|
1888
|
+
return pinned;
|
|
1889
|
+
}
|
|
1890
|
+
if (!this.validOwnerAppId(row.owner_app_id) || row.owner_app_id !== appId)
|
|
1891
|
+
return false;
|
|
1892
|
+
// Preserve the proven previous scope BEFORE replacing the readable label.
|
|
1893
|
+
this.rememberP2pOwnerAlias(chatId, appId, row.owner_bot, 'pinned_owner');
|
|
1894
|
+
if (row.owner_bot !== botName) {
|
|
1895
|
+
const renamed = this.db.prepare(`UPDATE chat_info SET owner_bot=? WHERE chat_id=? AND chat_type='p2p' AND owner_app_id=?`)
|
|
1896
|
+
.run(botName, chatId, appId).changes === 1;
|
|
1897
|
+
if (renamed)
|
|
1898
|
+
this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
|
|
1899
|
+
return renamed;
|
|
1900
|
+
}
|
|
1901
|
+
return true;
|
|
1902
|
+
}).immediate();
|
|
1903
|
+
}
|
|
1904
|
+
/** Operator-only CAS after independently verifying the original account.
|
|
1905
|
+
* No runtime caller: a retired legacy label alone cannot prove app ownership.
|
|
1906
|
+
* Idempotent for the exact destination, refuses any conflicting existing pin.
|
|
1907
|
+
* Preserves cache metadata/timestamps and every business/history table.
|
|
1908
|
+
* verifiedPreviousOwnerBot is an explicit independently verified old scope
|
|
1909
|
+
* when an earlier operator repair already replaced the legacy cache label. */
|
|
1910
|
+
migrateLegacyP2pOwner(chatId, expectedOwnerBot, botName, appId, verifiedPreviousOwnerBot) {
|
|
1911
|
+
if (!chatId || !expectedOwnerBot?.trim() || !botName?.trim() || !this.validOwnerAppId(appId)
|
|
1912
|
+
|| (verifiedPreviousOwnerBot !== undefined && !verifiedPreviousOwnerBot.trim()))
|
|
1913
|
+
return false;
|
|
1914
|
+
return this.db.transaction(() => {
|
|
1915
|
+
const migrated = this.db.prepare(`UPDATE chat_info SET owner_bot=?,owner_app_id=?
|
|
1916
|
+
WHERE chat_id=? AND chat_type='p2p' AND owner_bot=? AND owner_app_id=''`)
|
|
1917
|
+
.run(botName, appId, chatId, expectedOwnerBot).changes === 1;
|
|
1918
|
+
const row = this.db.prepare('SELECT chat_type,owner_bot,owner_app_id FROM chat_info WHERE chat_id=?').get(chatId);
|
|
1919
|
+
if (row?.chat_type !== 'p2p' || row.owner_bot !== botName || row.owner_app_id !== appId)
|
|
1920
|
+
return false;
|
|
1921
|
+
if (migrated)
|
|
1922
|
+
this.rememberP2pOwnerAlias(chatId, appId, expectedOwnerBot, 'verified_migration');
|
|
1923
|
+
this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
|
|
1924
|
+
if (verifiedPreviousOwnerBot !== undefined)
|
|
1925
|
+
this.rememberP2pOwnerAlias(chatId, appId, verifiedPreviousOwnerBot, 'verified_migration');
|
|
1926
|
+
return true;
|
|
1927
|
+
}).immediate();
|
|
1928
|
+
}
|
|
1929
|
+
rememberP2pOwnerAlias(chatId, appId, botName, proof) {
|
|
1930
|
+
if (typeof botName !== 'string' || !botName.trim())
|
|
1931
|
+
return;
|
|
1932
|
+
this.db.prepare('INSERT OR IGNORE INTO p2p_owner_aliases(app_id,chat_id,bot_name,proof) VALUES(?,?,?,?)')
|
|
1933
|
+
.run(appId, chatId, botName, proof);
|
|
1934
|
+
}
|
|
1935
|
+
validOwnerAppId(value) {
|
|
1936
|
+
// Exact, opaque identity, not a display name. Do not normalize malformed
|
|
1937
|
+
// pins into either a valid identity or the empty legacy sentinel.
|
|
1938
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]+$/.test(value);
|
|
1939
|
+
}
|
|
1730
1940
|
upsertChatInfo(info) {
|
|
1731
1941
|
this.db.prepare(`
|
|
1732
|
-
INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, discuss, discuss_max_rounds, chairman_bot, locale, updated_at)
|
|
1733
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1942
|
+
INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, owner_app_id, discuss, discuss_max_rounds, chairman_bot, locale, updated_at)
|
|
1943
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1734
1944
|
ON CONFLICT (chat_id) DO UPDATE SET
|
|
1735
1945
|
chat_type = excluded.chat_type,
|
|
1736
1946
|
chat_name = excluded.chat_name,
|
|
@@ -1738,13 +1948,13 @@ export class MessageStore {
|
|
|
1738
1948
|
member_names = excluded.member_names,
|
|
1739
1949
|
verbose = excluded.verbose,
|
|
1740
1950
|
free_discussion = excluded.free_discussion,
|
|
1741
|
-
owner_bot = CASE WHEN excluded.owner_bot != '' THEN excluded.owner_bot ELSE chat_info.owner_bot END,
|
|
1951
|
+
owner_bot = CASE WHEN chat_info.chat_type = 'p2p' THEN chat_info.owner_bot WHEN excluded.owner_bot != '' THEN excluded.owner_bot ELSE chat_info.owner_bot END,
|
|
1742
1952
|
discuss = CASE WHEN excluded.discuss != 0 THEN excluded.discuss ELSE chat_info.discuss END,
|
|
1743
1953
|
discuss_max_rounds = CASE WHEN excluded.discuss_max_rounds != 10 THEN excluded.discuss_max_rounds ELSE chat_info.discuss_max_rounds END,
|
|
1744
1954
|
chairman_bot = CASE WHEN excluded.chairman_bot != '' THEN excluded.chairman_bot ELSE chat_info.chairman_bot END,
|
|
1745
1955
|
locale = CASE WHEN excluded.locale != '' THEN excluded.locale ELSE chat_info.locale END,
|
|
1746
1956
|
updated_at = excluded.updated_at
|
|
1747
|
-
`).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.discuss ? 1 : 0, info.discussMaxRounds || 10, info.chairmanBot || '', info.locale || '', info.updatedAt);
|
|
1957
|
+
`).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.ownerAppId ?? '', info.discuss ? 1 : 0, info.discussMaxRounds || 10, info.chairmanBot || '', info.locale || '', info.updatedAt);
|
|
1748
1958
|
}
|
|
1749
1959
|
setFreeDiscussion(chatId, on) {
|
|
1750
1960
|
this.db.prepare(`UPDATE chat_info SET free_discussion = ? WHERE chat_id = ?`).run(on ? 1 : 0, chatId);
|
|
@@ -1868,6 +2078,7 @@ export class MessageStore {
|
|
|
1868
2078
|
members: row.members,
|
|
1869
2079
|
memberNames: row.member_names,
|
|
1870
2080
|
ownerBot: row.owner_bot || '',
|
|
2081
|
+
ownerAppId: row.owner_app_id,
|
|
1871
2082
|
freeDiscussion: !!row.free_discussion,
|
|
1872
2083
|
verbose: !!row.verbose,
|
|
1873
2084
|
discuss: !!row.discuss,
|
|
@@ -1886,6 +2097,7 @@ export class MessageStore {
|
|
|
1886
2097
|
members: r.members,
|
|
1887
2098
|
memberNames: r.member_names,
|
|
1888
2099
|
ownerBot: r.owner_bot || '',
|
|
2100
|
+
ownerAppId: r.owner_app_id,
|
|
1889
2101
|
freeDiscussion: !!r.free_discussion,
|
|
1890
2102
|
verbose: !!r.verbose,
|
|
1891
2103
|
discuss: !!r.discuss,
|
|
@@ -1926,6 +2138,30 @@ export class MessageStore {
|
|
|
1926
2138
|
const result = this.db.prepare(`INSERT OR IGNORE INTO processed_events (bot_name, message_id) VALUES (?, ?)`).run(botName, messageId);
|
|
1927
2139
|
return result.changes === 1;
|
|
1928
2140
|
}
|
|
2141
|
+
/** Private-only monotonic ingress fence. Write even for an owner-rejected
|
|
2142
|
+
* event: a rejected input is a tombstone, never permission to retry after a
|
|
2143
|
+
* rename. Legacy name tombstones are consulted ONLY in a pinned/explicitly
|
|
2144
|
+
* verified owner scope (plus the existing current-name fence), never inferred
|
|
2145
|
+
* from message history or mentions. Each duplicate promotes only its own ID;
|
|
2146
|
+
* no historical name ledger is copied or renamed. Ownership is checked later. */
|
|
2147
|
+
tryMarkP2pProcessed(appId, chatId, botName, messageId) {
|
|
2148
|
+
if (!this.validOwnerAppId(appId) || !chatId || !botName?.trim() || !messageId)
|
|
2149
|
+
return false;
|
|
2150
|
+
return this.db.transaction(() => {
|
|
2151
|
+
const owner = this.db.prepare('SELECT chat_type,owner_bot,owner_app_id FROM chat_info WHERE chat_id=?').get(chatId);
|
|
2152
|
+
if (owner?.chat_type === 'p2p' && owner.owner_app_id === appId) {
|
|
2153
|
+
// Intake dedup runs before metadata refresh/startup can replace a label.
|
|
2154
|
+
this.rememberP2pOwnerAlias(chatId, appId, owner.owner_bot, 'pinned_owner');
|
|
2155
|
+
}
|
|
2156
|
+
const legacyDuplicate = !!this.db.prepare(`SELECT 1 FROM p2p_owner_aliases a
|
|
2157
|
+
JOIN processed_events p ON p.bot_name=a.bot_name
|
|
2158
|
+
WHERE a.app_id=? AND a.chat_id=? AND p.message_id=? LIMIT 1`).get(appId, chatId, messageId);
|
|
2159
|
+
const freshApp = this.db.prepare('INSERT OR IGNORE INTO p2p_processed_events(app_id,chat_id,message_id) VALUES(?,?,?)')
|
|
2160
|
+
.run(appId, chatId, messageId).changes === 1;
|
|
2161
|
+
const freshName = this.tryMarkBotProcessed(botName, messageId);
|
|
2162
|
+
return freshApp && freshName && !legacyDuplicate;
|
|
2163
|
+
}).immediate();
|
|
2164
|
+
}
|
|
1929
2165
|
// --- Go / Go-Checker supervision (DESIGN.md §10) ---
|
|
1930
2166
|
createGoJob(j) {
|
|
1931
2167
|
const now = Date.now();
|
|
@@ -72,10 +72,6 @@ export type ChatAttachment = {
|
|
|
72
72
|
};
|
|
73
73
|
export declare const GATEWAY_PROTOCOL_MIN = 4;
|
|
74
74
|
export declare const GATEWAY_PROTOCOL_MAX = 4;
|
|
75
|
-
/**
|
|
76
|
-
* OpenClaw Gateway WebSocket client.
|
|
77
|
-
* Full agent pipeline — tools, memory, skills, context management by OpenClaw.
|
|
78
|
-
*/
|
|
79
75
|
export declare class OpenClawClient {
|
|
80
76
|
private goEvidenceObservers;
|
|
81
77
|
/** Raw full event observer. Observation is NOT a Feishu delivery receipt. */
|
|
@@ -234,8 +230,6 @@ export declare class OpenClawClient {
|
|
|
234
230
|
*/
|
|
235
231
|
private isGenericChatError;
|
|
236
232
|
private resolveTerminalChatError;
|
|
237
|
-
private formatTerminalRunError;
|
|
238
|
-
private formatDurationSeconds;
|
|
239
233
|
private isRecoverableAgentError;
|
|
240
234
|
createSession(params: {
|
|
241
235
|
key: string;
|