openclaw-lark-multi-agent 1.8.20 → 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 CHANGED
@@ -1,12 +1,26 @@
1
1
  import { type Locale } from "./i18n.js";
2
+ /** Profile used by a bot that does not name one.
3
+ *
4
+ * Keeps every pre-profile configuration on the historical default. */
5
+ export declare const DEFAULT_BOT_PROFILE = "main";
2
6
  export interface BotConfig {
3
7
  name: string;
4
8
  appId: string;
5
9
  appSecret: string;
6
- model: string;
10
+ /** Model routed for this bot. Optional: when absent or blank the bot
11
+ * follows the model of its OpenClaw profile instead of overriding it. */
12
+ model?: string;
7
13
  locale?: Locale;
8
14
  /** Optional per-bot thinking level (informational fallback for footers). */
9
15
  thinking?: string;
16
+ /** OpenClaw agent/profile that owns this bot's sessions.
17
+ *
18
+ * Optional for backward compatibility: a bot that does not name a profile
19
+ * keeps the historical `main` profile, so existing configs behave exactly
20
+ * as before. When set, the bot's sessions are addressed under that
21
+ * profile, giving it that profile's own workspace, memory and identity.
22
+ * Defaults to "main" when absent or blank. */
23
+ profile?: string;
10
24
  }
11
25
  export interface OpenClawConfig {
12
26
  baseUrl: string;
package/dist/config.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { readFileSync, writeFileSync } from "fs";
2
2
  import { resolve } from "path";
3
3
  import { normalizeLocale } from "./i18n.js";
4
+ /** Profile used by a bot that does not name one.
5
+ *
6
+ * Keeps every pre-profile configuration on the historical default. */
7
+ export const DEFAULT_BOT_PROFILE = "main";
4
8
  export function loadConfig(path) {
5
9
  const configPath = path || resolve(process.cwd(), "config.json");
6
10
  const raw = readFileSync(configPath, "utf-8");
@@ -12,13 +16,21 @@ export function loadConfig(path) {
12
16
  throw new Error("No bots configured");
13
17
  }
14
18
  for (const bot of config.bots) {
15
- if (!bot.appId || !bot.appSecret || !bot.model) {
16
- throw new Error(`Bot "${bot.name}" missing appId, appSecret, or model`);
19
+ if (!bot.appId || !bot.appSecret) {
20
+ throw new Error(`Bot "${bot.name}" missing appId or appSecret`);
17
21
  }
18
22
  }
19
23
  config.locale = normalizeLocale(config.locale);
20
24
  for (const bot of config.bots) {
21
25
  bot.locale = normalizeLocale(bot.locale || config.locale);
26
+ // A bot without a profile keeps the historical `main` profile. Older
27
+ // configs stay byte-identical in behaviour; a profile is only a routing
28
+ // choice, never a required field.
29
+ const profile = String(bot.profile == null ? "" : bot.profile).trim();
30
+ bot.profile = profile || DEFAULT_BOT_PROFILE;
31
+ // An empty model means "follow the profile's own model".
32
+ const model = String(bot.model == null ? "" : bot.model).trim();
33
+ bot.model = model || undefined;
22
34
  }
23
35
  // Validate uniqueness
24
36
  const names = config.bots.map((b) => b.name);
@@ -93,8 +93,6 @@ export declare class FeishuBot {
93
93
  private deliveryRetryTimers;
94
94
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
95
95
  private lastRealDeliveryAt;
96
- /** Per-chat: whether we've already sent the high-context alert this cycle. */
97
- private contextAlerted;
98
96
  /** Active pre-admission target for the current chat operation. */
99
97
  private activeDeliveryTargets;
100
98
  /** Stable targets for concurrent runs in one chat. A steer admission can
@@ -120,7 +118,21 @@ export declare class FeishuBot {
120
118
  * Get the session key for a specific chat.
121
119
  * Format: lma-<botname>-<chatId>
122
120
  */
121
+ /** OpenClaw profile that owns this bot's sessions.
122
+ *
123
+ * Defaults to "main" so a configuration written before profiles existed
124
+ * keeps its historical behaviour exactly. */
125
+ private get profile();
123
126
  getSessionKey(chatId: string): string;
127
+ /** Physical session key for this bot: the logical key addressed under the
128
+ * bot's OpenClaw profile.
129
+ *
130
+ * The LOGICAL key (getSessionKey) stays profile-free so durable records keep
131
+ * their historical shape and never need a migration. Every key that is
132
+ * actually handed to the Gateway goes through this method, so a bot bound to
133
+ * a non-default profile owns its sessions under that profile (its own
134
+ * workspace, memory and identity) while the stored records stay compatible. */
135
+ physicalSessionKey(chatId: string): string;
124
136
  private chatLocale;
125
137
  private isEn;
126
138
  private lmaBridgePolicy;
@@ -164,6 +176,14 @@ export declare class FeishuBot {
164
176
  private handleResolvedMessage;
165
177
  /** Separate guarded-admission proof. Aggregate busy never becomes idle. */
166
178
  private observedFollowupGuard;
179
+ /** How long an unconfirmed input may stay silent before the user is told.
180
+ *
181
+ * A fixed 60s window misfired on every slow session: measured reply latency
182
+ * in a busy group was p50 3.8min / p90 20.2min / max 135min, so 85% of real
183
+ * replies exceeded the old threshold and every one of them produced a false
184
+ * "not confirmed" notice. Scale the window with the session's own observed
185
+ * latency so a healthy slow run is never reported as stuck. */
186
+ private unconfirmedGraceMs;
167
187
  /** A positive refusal must be durable AND explained to the sender. */
168
188
  private retainChangedFollowup;
169
189
  private explainUnconfirmedFollowup;
@@ -266,11 +286,15 @@ export declare class FeishuBot {
266
286
  * or 0 if it cannot be determined. Same basis as /status. */
267
287
  private getContextUsagePercent;
268
288
  /**
269
- * After a reply is delivered, alert the user once if context usage crossed the
270
- * threshold (default 80%), so they can compact before a session bloats into the
271
- * timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
272
- * alerted we stay quiet until usage drops back below the threshold (e.g. after
273
- * a compact), then re-arm.
289
+ * After a message completes, alert the user when context usage is at or above
290
+ * the threshold (default 80%), so they can compact before the session bloats
291
+ * into the timeout/lock/overflow failure modes.
292
+ *
293
+ * The check runs after EVERY completed message and re-notifies on every message
294
+ * while usage stays above the threshold. There is deliberately no per-chat
295
+ * "already warned" latch: with one, a single failed send (or a stale latch) would
296
+ * silently suppress every later warning, and the user would stop being told to
297
+ * compact while their context keeps growing.
274
298
  */
275
299
  private maybeAlertHighContext;
276
300
  /** True when the agent's reply is exactly a completion phrase (zh or en,
@@ -486,6 +510,12 @@ export declare class FeishuBot {
486
510
  * runs for the session, clears the busy lock and every pending trigger, and
487
511
  * resets stuck reactions so new messages are processed normally again.
488
512
  */
513
+ /** Release every peer bot's in-memory scheduling state for one chat.
514
+ * Each bot instance keeps its own queueRuns / queueOwners / busyChats /
515
+ * pendingAckMessages maps; a durable row cleanup cannot reach those. A
516
+ * group-scoped /stop must retire them too, otherwise the peer's queue owner
517
+ * survives and its next message is treated as "busy" forever. */
518
+ private releasePeerBotsForChat;
489
519
  private handleStopCommand;
490
520
  /**
491
521
  * Fetch chat info (name, type, members) via Feishu API and cache in SQLite.
@@ -32,7 +32,21 @@ export class SendDeadlineError extends Error {
32
32
  }
33
33
  }
34
34
  const nonemptyReceipt = (value) => typeof value === 'string' && !!value.trim();
35
- const canonicalPhysicalSessionKey = (key) => /^agent:[^:]+:/.test(key) ? key : `agent:main:${key}`;
35
+ /** Resolve the PHYSICAL session key for a logical LMA key.
36
+ *
37
+ * A bot may be bound to a specific OpenClaw profile. The profile selects the
38
+ * agent that owns the session, so the physical key carries it as an
39
+ * `agent:<profile>:` prefix. A key that already carries an explicit
40
+ * `agent:<id>:` prefix is left untouched.
41
+ *
42
+ * With the default profile this is byte-identical to the historical
43
+ * `agent:main:<key>`, so existing durable records keep matching. */
44
+ const canonicalPhysicalSessionKey = (key, profile) => {
45
+ if (/^agent:[^:]+:/.test(key))
46
+ return key;
47
+ const agent = String(profile || "main").trim() || "main";
48
+ return `agent:${agent}:${key}`;
49
+ };
36
50
  const require = createRequire(import.meta.url);
37
51
  const LMA_VERSION = require("../package.json").version;
38
52
  const MAX_BOT_STREAK = 10;
@@ -329,8 +343,6 @@ export class FeishuBot {
329
343
  deliveryRetryTimers = new Map();
330
344
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
331
345
  lastRealDeliveryAt = new Map();
332
- /** Per-chat: whether we've already sent the high-context alert this cycle. */
333
- contextAlerted = new Map();
334
346
  /** Active pre-admission target for the current chat operation. */
335
347
  activeDeliveryTargets = new Map();
336
348
  /** Stable targets for concurrent runs in one chat. A steer admission can
@@ -387,10 +399,11 @@ export class FeishuBot {
387
399
  // Go / Go-Checker supervision (design: lma-go-checker-design-20260917).
388
400
  this.goController = new GoController({
389
401
  botName: config.name,
390
- fallbackModel: config.model,
402
+ fallbackModel: config.model || "",
403
+ profile: this.profile,
391
404
  store,
392
405
  client: openclawClient,
393
- executorSessionKeyFor: (chatId) => this.getSessionKey(chatId),
406
+ executorSessionKeyFor: (chatId) => this.physicalSessionKey(chatId),
394
407
  notify: (chatId, text) => this.sendOrdered(chatId, () => this.sendMessage(chatId, text)),
395
408
  reply: (messageId, text) => this.replyMessage(messageId, text),
396
409
  deliverExecutorResult: (chatId, result, goId) => this.deliverGoExecutorResult(chatId, result, goId),
@@ -405,7 +418,7 @@ export class FeishuBot {
405
418
  if (typeof exactKey !== "string" || typeof sessionId !== "string" || typeof runId !== "string")
406
419
  return;
407
420
  for (const job of this.store.listActiveGoJobs(this.config.name)) {
408
- if (exactKey !== job.executorSessionKey && exactKey !== `agent:main:${job.executorSessionKey}`)
421
+ if (exactKey !== job.executorSessionKey && exactKey !== canonicalPhysicalSessionKey(job.executorSessionKey, this.profile))
409
422
  continue;
410
423
  if (sessionId !== job.executorSessionId)
411
424
  continue;
@@ -469,9 +482,27 @@ export class FeishuBot {
469
482
  * Get the session key for a specific chat.
470
483
  * Format: lma-<botname>-<chatId>
471
484
  */
485
+ /** OpenClaw profile that owns this bot's sessions.
486
+ *
487
+ * Defaults to "main" so a configuration written before profiles existed
488
+ * keeps its historical behaviour exactly. */
489
+ get profile() {
490
+ return String(this.config.profile || "main").trim() || "main";
491
+ }
472
492
  getSessionKey(chatId) {
473
493
  return `lma-${this.config.name.toLowerCase()}-${chatId}`;
474
494
  }
495
+ /** Physical session key for this bot: the logical key addressed under the
496
+ * bot's OpenClaw profile.
497
+ *
498
+ * The LOGICAL key (getSessionKey) stays profile-free so durable records keep
499
+ * their historical shape and never need a migration. Every key that is
500
+ * actually handed to the Gateway goes through this method, so a bot bound to
501
+ * a non-default profile owns its sessions under that profile (its own
502
+ * workspace, memory and identity) while the stored records stay compatible. */
503
+ physicalSessionKey(chatId) {
504
+ return canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile);
505
+ }
475
506
  chatLocale(chatId) {
476
507
  return normalizeLocale(chatId ? this.store.getChatLocale(chatId) || this.locale : this.locale);
477
508
  }
@@ -495,7 +526,11 @@ export class FeishuBot {
495
526
  * Lazy: only creates on first message in that chat.
496
527
  */
497
528
  async ensureSession(chatId, setupFence) {
498
- const sessionKey = this.getSessionKey(chatId);
529
+ // The cache is keyed by the LOGICAL key so it stays profile-free and
530
+ // consistent with the rest of the durable bookkeeping; the Gateway is
531
+ // always addressed with the PHYSICAL key (profile included).
532
+ const logicalKey = this.getSessionKey(chatId);
533
+ const sessionKey = this.physicalSessionKey(chatId);
499
534
  await this.assertIdentityEffect();
500
535
  const held = this.identityEffects?.getStore();
501
536
  const expectedSessionId = held?.item.target.sessionId || setupFence?.sessionId;
@@ -506,9 +541,12 @@ export class FeishuBot {
506
541
  if (info?.session?.sessionId !== setupFence.sessionId || info.session.archived)
507
542
  throw new SupersededObservation('retained setup physical target changed');
508
543
  }
509
- if (this.initializedSessions.has(sessionKey)) {
544
+ if (this.initializedSessions.has(logicalKey)) {
510
545
  // Already initialized this process lifetime, just ensure model
511
- const corrected = (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model));
546
+ // No configured model means the bot follows its profile's own model.
547
+ const corrected = this.config.model
548
+ ? (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model))
549
+ : false;
512
550
  setupGuard();
513
551
  if (corrected) {
514
552
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
@@ -524,7 +562,9 @@ export class FeishuBot {
524
562
  if (existing?.session) {
525
563
  // Session exists — preserve it, only ensure model is correct
526
564
  console.log(`[${this.config.name}] Session exists: ${sessionKey} (tokens: ${existing.session.totalTokens || 0})`);
527
- const corrected = (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model));
565
+ const corrected = this.config.model
566
+ ? (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model))
567
+ : false;
528
568
  setupGuard();
529
569
  if (corrected) {
530
570
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
@@ -534,11 +574,13 @@ export class FeishuBot {
534
574
  // Session doesn't exist — create new
535
575
  await this.openclawClient.createSession({
536
576
  key: sessionKey,
537
- model: this.config.model,
577
+ model: this.config.model || "",
538
578
  label: `LMA: ${this.config.name} [${chatId.slice(-8)}]`,
539
579
  });
540
- // Always patch model after create to ensure it takes effect
541
- await this.openclawClient.patchSession({ key: sessionKey, model: this.config.model });
580
+ // Only patch when a model is configured; otherwise the profile's own
581
+ // model must be left untouched.
582
+ if (this.config.model)
583
+ await this.openclawClient.patchSession({ key: sessionKey, model: this.config.model });
542
584
  await this.injectBridgePolicy(sessionKey);
543
585
  console.log(`[${this.config.name}] Session created: ${sessionKey} (model: ${this.config.model})`);
544
586
  }
@@ -550,7 +592,7 @@ export class FeishuBot {
550
592
  throw err;
551
593
  }
552
594
  setupGuard();
553
- this.initializedSessions.add(sessionKey);
595
+ this.initializedSessions.add(logicalKey);
554
596
  // Subscribe to session events (proactive messages + tool calls)
555
597
  this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
556
598
  await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
@@ -604,7 +646,7 @@ export class FeishuBot {
604
646
  console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
605
647
  }
606
648
  }, async (meta) => {
607
- if (!meta.runId || this.store.isStoppedObservation(`agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}`, meta.runId))
649
+ if (!meta.runId || this.store.isStoppedObservation(canonicalPhysicalSessionKey(sessionKey.replace(/^agent:[^:]+:/, ''), this.profile), meta.runId))
608
650
  return;
609
651
  if (this.store.hasUnconfirmedRunDelivery(this.config.name, chatId, meta.runId))
610
652
  return;
@@ -660,7 +702,7 @@ export class FeishuBot {
660
702
  await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
661
703
  if (this.identityClosed)
662
704
  return;
663
- console.log(`[${this.config.name}] Bot started (model: ${this.config.model}, open_id: ${this.botOpenId || "unknown"})`);
705
+ console.log(`[${this.config.name}] Bot started (model: ${this.config.model || `profile ${this.profile}`}, open_id: ${this.botOpenId || "unknown"})`);
664
706
  // Drain any unsynced messages left from previous run
665
707
  this.drainOnStartup();
666
708
  void this.drainIdentityIngress().catch(err => console.warn(`[${this.config.name}] Identity ingress recovery failed:`, err.message));
@@ -693,14 +735,14 @@ export class FeishuBot {
693
735
  });
694
736
  }
695
737
  captureIdentityTarget(messageId, chatId) {
696
- const key = this.getSessionKey(chatId);
738
+ const key = this.physicalSessionKey(chatId);
697
739
  const work = (async () => {
698
740
  const info = await this.identitySessionInfo(key);
699
741
  if (this.identityClosed)
700
742
  return;
701
743
  const session = info?.session;
702
744
  const target = typeof session?.sessionId === 'string' && session.sessionId.trim() && session.sessionId.length <= 256 && session.archived !== true
703
- && (!session.key || typeof session.key === 'string' && canonicalPhysicalSessionKey(session.key) === canonicalPhysicalSessionKey(key))
745
+ && (!session.key || typeof session.key === 'string' && canonicalPhysicalSessionKey(session.key, this.profile) === canonicalPhysicalSessionKey(key, this.profile))
704
746
  ? { state: 'bound', sessionKey: key, sessionId: session.sessionId } : { state: 'unbound', sessionKey: key };
705
747
  if (target.state === 'unbound')
706
748
  console.warn(`[${this.config.name}] Identity input ${messageId} has no proven original physical session; retained without retargeting`);
@@ -880,7 +922,12 @@ export class FeishuBot {
880
922
  if (this.identityTargetRepairs.has(item.messageId))
881
923
  return;
882
924
  item.target = this.store.getIdentityTarget(this.config.name, this.config.appId, item.messageId);
883
- if (item.target.state !== 'bound' || !item.target.sessionId || item.target.sessionKey !== this.getSessionKey(item.chatId))
925
+ // An identity target may hold the physical key (current records) or the
926
+ // logical key (records written before profiles existed). Both denote the
927
+ // SAME session, so both are accepted here; comparing against only one
928
+ // form silently skips identity recovery and the message is never sent.
929
+ if (item.target.state !== 'bound' || !item.target.sessionId ||
930
+ (item.target.sessionKey !== this.physicalSessionKey(item.chatId) && item.target.sessionKey !== this.getSessionKey(item.chatId)))
884
931
  continue;
885
932
  const info = await this.identitySessionInfo(item.target.sessionKey);
886
933
  if (this.identityClosed)
@@ -1462,7 +1509,7 @@ export class FeishuBot {
1462
1509
  const isOn = this.store.getBotVerbose(this.config.name, chatId);
1463
1510
  const nextVerbose = !isOn;
1464
1511
  this.store.setBotVerbose(this.config.name, chatId, nextVerbose);
1465
- this.openclawClient.setVerboseTranscriptDelivery(this.getSessionKey(chatId), nextVerbose);
1512
+ this.openclawClient.setVerboseTranscriptDelivery(this.physicalSessionKey(chatId), nextVerbose);
1466
1513
  if (isOn) {
1467
1514
  await this.replyMessage(messageId, `🔇 ${this.config.name} Verbose 已关闭\n只影响当前 Bot 在当前会话的 Tool call 和中间文本显示`);
1468
1515
  }
@@ -1730,9 +1777,23 @@ export class FeishuBot {
1730
1777
  if (!activity.followupAdmissionEligible || activity.identityConflict || activity.activeRunId && !noEffectRunId || activity.archived || status.archived ||
1731
1778
  !status.sessionId || status.sessionId !== activity.sessionId || !activity.expectedLeafEntryId)
1732
1779
  return;
1733
- return { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId)), sessionId: status.sessionId, expectedLeafEntryId: activity.expectedLeafEntryId,
1780
+ return { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile), sessionId: status.sessionId, expectedLeafEntryId: activity.expectedLeafEntryId,
1734
1781
  ...(noEffectRunId ? { nonInjectableRunId: noEffectRunId } : {}) };
1735
1782
  }
1783
+ /** How long an unconfirmed input may stay silent before the user is told.
1784
+ *
1785
+ * A fixed 60s window misfired on every slow session: measured reply latency
1786
+ * in a busy group was p50 3.8min / p90 20.2min / max 135min, so 85% of real
1787
+ * replies exceeded the old threshold and every one of them produced a false
1788
+ * "not confirmed" notice. Scale the window with the session's own observed
1789
+ * latency so a healthy slow run is never reported as stuck. */
1790
+ unconfirmedGraceMs(chatId) {
1791
+ const observed = this.store.observedReplyLatencyMs(this.config.name, chatId);
1792
+ const floor = 60_000;
1793
+ if (!observed || !Number.isFinite(observed) || observed <= 0)
1794
+ return floor;
1795
+ return Math.min(Math.max(floor, Math.ceil(observed * 1.5)), 30 * 60_000);
1796
+ }
1736
1797
  /** A positive refusal must be durable AND explained to the sender. */
1737
1798
  retainChangedFollowup(chatId, item, reason) {
1738
1799
  this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'target_changed', { ...item, reason });
@@ -1749,7 +1810,7 @@ export class FeishuBot {
1749
1810
  for (const item of this.store.listInsertions(this.config.name, chatId)) {
1750
1811
  if (item.state !== 'pending' || item.attempted || item.followupAdmissionGuard || item.ordinaryEligible === false ||
1751
1812
  item.ownerId !== saved.id || item.batchId !== saved.batchId || item.stopEpoch !== saved.stopEpoch || item.sessionId !== proof.sessionId ||
1752
- !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== proof.sessionKey)
1813
+ !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== proof.sessionKey)
1753
1814
  continue;
1754
1815
  const source = this.store.getMessageByMessageId(item.messageId);
1755
1816
  if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
@@ -1773,7 +1834,7 @@ export class FeishuBot {
1773
1834
  !this.queueOwners.get(chatId)?.triggerIds?.has(rowId);
1774
1835
  if (!unsubmitted())
1775
1836
  return;
1776
- await this.openclawClient.waitForSessionStop?.(this.getSessionKey(chatId));
1837
+ await this.openclawClient.waitForSessionStop?.(this.physicalSessionKey(chatId));
1777
1838
  if (!unsubmitted())
1778
1839
  return;
1779
1840
  // Session creation/model setup is not agent admission. Initialize a truly
@@ -1799,15 +1860,15 @@ export class FeishuBot {
1799
1860
  }
1800
1861
  if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1801
1862
  return;
1802
- const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
1863
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId));
1803
1864
  if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1804
1865
  return;
1805
1866
  const current = this.store.getInsertion(this.config.name, chatId, rowId);
1806
1867
  if (current && (!current.activityUnbound || current.attempted))
1807
1868
  return;
1808
- if (current && (current.stopEpoch !== epoch || !current.sessionKey || canonicalPhysicalSessionKey(current.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId))))
1869
+ if (current && (current.stopEpoch !== epoch || !current.sessionKey || canonicalPhysicalSessionKey(current.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile)))
1809
1870
  return;
1810
- const activity = await this.openclawClient.getSessionDispatchStatus(this.getSessionKey(chatId));
1871
+ const activity = await this.openclawClient.getSessionDispatchStatus(this.physicalSessionKey(chatId));
1811
1872
  if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1812
1873
  return;
1813
1874
  // Another recovery/ingress lookup may have identified the root and begun
@@ -1823,6 +1884,13 @@ export class FeishuBot {
1823
1884
  !!current?.sessionId && !!status.sessionId && current.sessionId !== status.sessionId;
1824
1885
  const source = this.store.getMessageByMessageId(messageId);
1825
1886
  const ordinaryHuman = source?.id === rowId && source.chatId === chatId && source.senderType === 'human' && source.triggerKind === 'normal';
1887
+ if (current?.followupBlocked && !archived && !generationConflict) {
1888
+ this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1889
+ ...current, followupBlocked: false, followupAdmissionGuard: undefined, followupObservedAt: undefined,
1890
+ reason: 'followup block cleared; awaiting admission re-observation',
1891
+ }, Date.now() + 5000);
1892
+ return;
1893
+ }
1826
1894
  const proof = ordinaryHuman && compatible && !current?.followupBlocked && !archived ? this.observedFollowupGuard(chatId, status, activity) : undefined;
1827
1895
  const previous = current?.followupAdmissionGuard;
1828
1896
  if (previous && (archived || generationConflict)) {
@@ -1871,7 +1939,7 @@ export class FeishuBot {
1871
1939
  ...((archived || generationConflict) ? { followupBlocked: true } : {}),
1872
1940
  reason: identified ? 'current Gateway run observed; insertion custody only' : blockerReason,
1873
1941
  }, Date.now() + (identified ? 0 : 5000));
1874
- if (!identified && !proof && current?.unboundSince && Date.now() - current.unboundSince >= 60_000 &&
1942
+ if (!identified && !proof && current?.unboundSince && Date.now() - current.unboundSince >= this.unconfirmedGraceMs(chatId) &&
1875
1943
  (archived || generationConflict || activity.busyConfirmed || current.restrictedObservation))
1876
1944
  this.explainUnconfirmedFollowup(chatId, { ...current, rowId, messageId });
1877
1945
  }
@@ -1886,7 +1954,7 @@ export class FeishuBot {
1886
1954
  const source = this.store.getMessageByMessageId(item.messageId);
1887
1955
  if (item.state !== 'pending' || item.attempted || item.ordinaryEligible === false || !item.sessionId ||
1888
1956
  !source || source.id !== item.rowId || source.chatId !== chatId || source.senderType !== 'human' || source.triggerKind !== 'normal' ||
1889
- !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
1957
+ !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile))
1890
1958
  return false;
1891
1959
  const admissionEpoch = this.queueAdmissionEpoch.get(chatId) || 0;
1892
1960
  const snapshot = this.store.getInsertion(this.config.name, chatId, item.rowId);
@@ -1936,14 +2004,14 @@ export class FeishuBot {
1936
2004
  return (this.queueAdmissionEpoch.get(chatId) || 0) === admissionEpoch && this.stopGeneration(chatId) === epoch && saved.stopEpoch === epoch && JSON.stringify(now) === JSON.stringify(saved) &&
1937
2005
  this.queueOwners.get(chatId) === local && (!local || local.batch === batch && local.runId === runId && (local.admitted === true || local.yielded === true) && local.collecting === true);
1938
2006
  };
1939
- const activity = await this.openclawClient.getSessionDispatchStatus?.(this.getSessionKey(chatId)).catch(() => null);
2007
+ const activity = await this.openclawClient.getSessionDispatchStatus?.(this.physicalSessionKey(chatId)).catch(() => null);
1940
2008
  if (!unchanged() || !activity || activity.archived || activity.sessionId !== saved.sessionId)
1941
2009
  return false;
1942
2010
  if (activity.activeRunId === saved.runId)
1943
2011
  return false; // real foreground keeps SDK priority
1944
2012
  if (!activity.idleConfirmed && !activity.followupAdmissionEligible)
1945
2013
  return false;
1946
- const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId)).catch(() => null);
2014
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId)).catch(() => null);
1947
2015
  if (!unchanged() || !status || status.archived || status.sessionId !== saved.sessionId)
1948
2016
  return false;
1949
2017
  if (activity.activeRunId && activity.activeRunId !== saved.runId) {
@@ -1954,7 +2022,7 @@ export class FeishuBot {
1954
2022
  for (const item of this.store.listInsertions(this.config.name, chatId)) {
1955
2023
  if (item.state !== 'pending' || item.attempted || item.noEffectRunId || item.ordinaryEligible === false || item.ownerId !== saved.id || item.batchId !== saved.batchId ||
1956
2024
  item.stopEpoch !== epoch || item.sessionId !== saved.sessionId || !item.sessionKey ||
1957
- canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
2025
+ canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile))
1958
2026
  continue;
1959
2027
  const source = this.store.getMessageByMessageId(item.messageId);
1960
2028
  if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
@@ -1981,7 +2049,7 @@ export class FeishuBot {
1981
2049
  let followup;
1982
2050
  if (!idle || saved.followupAdmissionGuard) {
1983
2051
  const proof = this.observedFollowupGuard(chatId, status, activity);
1984
- if (!proof || !saved.sessionKey || canonicalPhysicalSessionKey(saved.sessionKey) !== proof.sessionKey)
2052
+ if (!proof || !saved.sessionKey || canonicalPhysicalSessionKey(saved.sessionKey, this.profile) !== proof.sessionKey)
1985
2053
  return false;
1986
2054
  const previous = saved.followupAdmissionGuard;
1987
2055
  if (previous && (previous.sessionKey !== proof.sessionKey || previous.sessionId !== proof.sessionId))
@@ -2014,7 +2082,12 @@ export class FeishuBot {
2014
2082
  }
2015
2083
  }
2016
2084
  stopGeneration(chatId) {
2017
- return this.stopEpoch.get(chatId) ?? this.store.getStopGeneration(this.config.name, chatId);
2085
+ // A peer bot's group-scoped /stop bumps this bot's durable epoch. The
2086
+ // in-memory value can then be stale and would mask the newer durable one,
2087
+ // so take the greater of the two instead of trusting the cache blindly.
2088
+ const cached = this.stopEpoch.get(chatId);
2089
+ const stored = this.store.getStopGeneration(this.config.name, chatId);
2090
+ return cached === undefined ? stored : Math.max(cached, stored);
2018
2091
  }
2019
2092
  reactionChecks = new Set();
2020
2093
  async receiptReaction(chatId, rowId, messageId, desired, runId = '') {
@@ -2249,7 +2322,7 @@ export class FeishuBot {
2249
2322
  }
2250
2323
  else {
2251
2324
  state = 'pending';
2252
- if (Date.now() - item.noEffectSince >= 60_000)
2325
+ if (Date.now() - item.noEffectSince >= this.unconfirmedGraceMs(chatId))
2253
2326
  this.explainUnconfirmedFollowup(chatId, item);
2254
2327
  }
2255
2328
  }
@@ -2267,7 +2340,7 @@ export class FeishuBot {
2267
2340
  ordinaryEligible: receipt.ordinaryEligible, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks,
2268
2341
  unknownSince: state === 'unknown' ? (item.state === 'unknown' && Number.isFinite(item.unknownSince) ? item.unknownSince : Date.now()) : undefined };
2269
2342
  this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, state, record, Date.now() + (state === 'unknown' ? 15_000 : 5000));
2270
- if (state === 'pending' && !record.attempted && Date.now() - (record.pendingSince || Date.now()) >= 60_000)
2343
+ if (state === 'pending' && !record.attempted && Date.now() - (record.pendingSince || Date.now()) >= this.unconfirmedGraceMs(chatId))
2271
2344
  this.explainUnconfirmedFollowup(chatId, record);
2272
2345
  if (state === 'ordinary') {
2273
2346
  this.store.deletePendingInsertionNotices(this.config.name, chatId, item.insertionId);
@@ -2323,7 +2396,7 @@ export class FeishuBot {
2323
2396
  return process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS !== "0";
2324
2397
  }
2325
2398
  async getUnhealthySessionStatus(chatId) {
2326
- const sessionKey = this.getSessionKey(chatId);
2399
+ const sessionKey = this.physicalSessionKey(chatId);
2327
2400
  try {
2328
2401
  const resp = await this.openclawClient.getSessionInfo(sessionKey);
2329
2402
  const status = String(resp?.session?.status || "").toLowerCase();
@@ -2505,11 +2578,20 @@ export class FeishuBot {
2505
2578
  */
2506
2579
  forceExpireQueueOwner(chatId, reason, preserveDispatch = false) {
2507
2580
  const owner = this.queueRuns.get(chatId);
2508
- if (!owner)
2581
+ if (!owner) {
2582
+ // No local owner left, but a persisted dispatch_custody row may still
2583
+ // exist for this chat (it is written before the owner is retired). The
2584
+ // old early return stranded that row forever: it never became visible
2585
+ // again, yet it also was never deleted. Clear it explicitly.
2586
+ if (!preserveDispatch && this.store.clearDispatchCustodyForChat(this.config.name, chatId))
2587
+ console.warn(`[${this.config.name}] cleared orphaned dispatch custody for ${chatId.slice(-8)}: ${reason}`);
2509
2588
  return false;
2589
+ }
2510
2590
  const context = this.queueOwners.get(chatId);
2511
2591
  if (context?.batchId && !preserveDispatch)
2512
2592
  this.store.clearDispatchCustody(this.config.name, chatId, context.id, context.batchId);
2593
+ else if (!preserveDispatch && !context)
2594
+ this.store.clearDispatchCustodyForChat(this.config.name, chatId);
2513
2595
  if (context)
2514
2596
  context.revoked = true;
2515
2597
  this.busyChats.set(chatId, 0);
@@ -2731,7 +2813,11 @@ export class FeishuBot {
2731
2813
  const expectedIdentitySessionId = identityTargets[0]?.target.sessionId;
2732
2814
  try {
2733
2815
  if (identityTargets.some(({ target }) => target.state !== 'bound' || !target.sessionId || target.sessionId !== expectedIdentitySessionId ||
2734
- target.sessionKey !== this.getSessionKey(chatId) || (target.expiresAt ?? 0) <= Date.now()))
2816
+ // An identity target may hold the physical key (current records) or the
2817
+ // logical key (records written before profiles existed). Both denote
2818
+ // the SAME session, so both are accepted; comparing against only one
2819
+ // form would silently drop the message.
2820
+ (target.sessionKey !== this.physicalSessionKey(chatId) && target.sessionKey !== this.getSessionKey(chatId)) || (target.expiresAt ?? 0) <= Date.now()))
2735
2821
  throw new SupersededObservation('retained original target unavailable');
2736
2822
  sessionKey = await (expectedIdentitySessionId ? this.ensureSession(chatId, { sessionId: expectedIdentitySessionId, guard }) : this.ensureSession(chatId));
2737
2823
  guard();
@@ -2893,7 +2979,10 @@ export class FeishuBot {
2893
2979
  if (target?.triggerId === triggerId) {
2894
2980
  target.runId = runId;
2895
2981
  this.bindRunDeliveryTarget(chatId, runId, target);
2896
- this.releaseRunDeliveryTargetAfter(chatId, runId, 30 * 60_000);
2982
+ // Result custody must outlive the real run. Observed GPT latency in a heavy
2983
+ // group reached ~59m40s, so a 30-minute release dropped a computed answer
2984
+ // that had no receiver left. Two hours covers the observed tail.
2985
+ this.releaseRunDeliveryTargetAfter(chatId, runId, 2 * 60 * 60_000);
2897
2986
  }
2898
2987
  // Refine state for logs/idempotent sync rows when OpenClaw returns
2899
2988
  // a runId. markSubmittedBatch is idempotent due to local flags and
@@ -2961,7 +3050,7 @@ export class FeishuBot {
2961
3050
  guard,
2962
3051
  onSendAttempt: submissionId => {
2963
3052
  guard();
2964
- this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey: `agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}` });
3053
+ this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey: canonicalPhysicalSessionKey(sessionKey.replace(/^agent:[^:]+:/, ''), this.profile) });
2965
3054
  },
2966
3055
  onSubmitted: async (runId) => { guard(); const previousRoot = owner.runId; owner.runId = runId; foregroundRunId = runId; void this.queuedPresentations.advance(liveStatus, previousRoot, runId, owner.id, owner.batchId).catch(() => { }); this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || "", liveStatus }); },
2967
3056
  });
@@ -3050,7 +3139,6 @@ export class FeishuBot {
3050
3139
  // Mark every merged trigger only after receipt confirmation.
3051
3140
  for (const id of mergedTriggerIds)
3052
3141
  this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
3053
- void this.maybeAlertHighContext(chatId);
3054
3142
  }
3055
3143
  // Go supervision: record the delivered final reply as evidence and
3056
3144
  // arm a debounced checker round (busy gates apply inside).
@@ -3115,6 +3203,10 @@ export class FeishuBot {
3115
3203
  this.goController?.notifyRunOutcome(chatId, "no_reply", `trigger:${triggerId}`, "(本轮无用户可见回复 NO_REPLY)");
3116
3204
  }
3117
3205
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
3206
+ // Context usage is re-checked after EVERY completed message (delivered
3207
+ // reply, NO_REPLY turn or runtime failure) and re-notifies each time while
3208
+ // usage stays above the threshold.
3209
+ await this.maybeAlertHighContext(chatId);
3118
3210
  guard();
3119
3211
  // Replace ack reactions with DONE only for trigger messages actually
3120
3212
  // processed in this run. Queued messages that arrived mid-run keep their
@@ -3256,9 +3348,16 @@ export class FeishuBot {
3256
3348
  await this.reconcileErrorNotices(chatId);
3257
3349
  errorOutcomeConfirmed = this.store.findRunErrorOwner(this.config.name, chatId, p)?.status === 'delivered';
3258
3350
  }
3351
+ else if (this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
3352
+ // This trigger already received its visible reply. A later terminal
3353
+ // error for the same run must NOT contradict a delivered answer:
3354
+ // the user would see "did not complete" under their own answer.
3355
+ errorOutcomeConfirmed = true;
3356
+ console.warn(`[${this.config.name}] provider_error suppressed for ${chatId.slice(-8)} trigger=${triggerId}: reply already delivered`);
3357
+ }
3259
3358
  else
3260
3359
  await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`, undefined, foregroundRunId ? (() => {
3261
- const p = this.scopeRunError(chatId, { sessionKey: `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}`, sessionId: owner.sessionId, runId: foregroundRunId, source: 'chat', state: 'error', detail: err instanceof Error ? err.message : undefined });
3360
+ const p = this.scopeRunError(chatId, { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile), sessionId: owner.sessionId, runId: foregroundRunId, source: 'chat', state: 'error', detail: err instanceof Error ? err.message : undefined });
3262
3361
  return { sessionKey: p.sessionKey, sessionId: p.sessionId, runId: p.runId, error: p };
3263
3362
  })() : undefined)
3264
3363
  .then(outcome => {
@@ -3516,7 +3615,7 @@ export class FeishuBot {
3516
3615
  }
3517
3616
  async readSessionStatus(chatId) {
3518
3617
  try {
3519
- return await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
3618
+ return await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId));
3520
3619
  }
3521
3620
  catch {
3522
3621
  return normalizeSessionRuntimeStatus(undefined);
@@ -3529,7 +3628,7 @@ export class FeishuBot {
3529
3628
  formatUserVisibleError(err, chatId) {
3530
3629
  if (err instanceof SessionWaitPaused)
3531
3630
  return err.message;
3532
- return formatProviderError(err, this.config.name, this.config.model, this.chatLocale(chatId));
3631
+ return formatProviderError(err, this.config.name, this.config.model || "", this.chatLocale(chatId));
3533
3632
  }
3534
3633
  isBridgeControlReply(text) {
3535
3634
  const t = text.trim();
@@ -3614,36 +3713,34 @@ export class FeishuBot {
3614
3713
  }
3615
3714
  }
3616
3715
  /**
3617
- * After a reply is delivered, alert the user once if context usage crossed the
3618
- * threshold (default 80%), so they can compact before a session bloats into the
3619
- * timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
3620
- * alerted we stay quiet until usage drops back below the threshold (e.g. after
3621
- * a compact), then re-arm.
3716
+ * After a message completes, alert the user when context usage is at or above
3717
+ * the threshold (default 80%), so they can compact before the session bloats
3718
+ * into the timeout/lock/overflow failure modes.
3719
+ *
3720
+ * The check runs after EVERY completed message and re-notifies on every message
3721
+ * while usage stays above the threshold. There is deliberately no per-chat
3722
+ * "already warned" latch: with one, a single failed send (or a stale latch) would
3723
+ * silently suppress every later warning, and the user would stop being told to
3724
+ * compact while their context keeps growing.
3622
3725
  */
3623
3726
  async maybeAlertHighContext(chatId) {
3624
3727
  const threshold = contextAlertPct();
3625
3728
  if (threshold <= 0)
3626
3729
  return;
3627
3730
  try {
3628
- const sessionKey = this.getSessionKey(chatId);
3731
+ const sessionKey = this.physicalSessionKey(chatId);
3629
3732
  const pct = await this.getContextUsagePercent(sessionKey);
3630
- if (pct <= 0)
3733
+ if (pct <= 0 || pct < threshold)
3631
3734
  return;
3632
- const alerted = this.contextAlerted.get(chatId) || false;
3633
- if (pct >= threshold) {
3634
- if (alerted)
3635
- return; // already warned this cycle
3636
- this.contextAlerted.set(chatId, true);
3637
- const en = this.isEn(chatId);
3638
- const text = en
3639
- ? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
3640
- : `\u26a0\ufe0f \u4e0a\u4e0b\u6587\u5df2\u8fbe ${pct}%\uff08\u2265${threshold}%\uff09\uff0c\u5efa\u8bae\u5c3d\u5feb /compact \u538b\u7f29\uff0c\u907f\u514d\u8d85\u65f6/\u7206\u4e0a\u4e0b\u6587\u3002`;
3641
- await this.sendMessage(chatId, text).catch(() => { });
3642
- }
3643
- else if (pct < threshold - 5) {
3644
- // Re-arm once usage clearly dropped (hysteresis to avoid flapping).
3645
- this.contextAlerted.set(chatId, false);
3646
- }
3735
+ const en = this.isEn(chatId);
3736
+ const text = en
3737
+ ? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
3738
+ : `\u26a0\ufe0f \u4e0a\u4e0b\u6587\u5df2\u8fbe ${pct}%\uff08\u2265${threshold}%\uff09\uff0c\u5efa\u8bae\u5c3d\u5feb /compact \u538b\u7f29\uff0c\u907f\u514d\u8d85\u65f6/\u7206\u4e0a\u4e0b\u6587\u3002`;
3739
+ // Best-effort: a failed alert must not block delivery, but it is logged so
3740
+ // a persistently undelivered warning stays visible instead of silent.
3741
+ await this.sendMessage(chatId, text).catch((err) => {
3742
+ console.warn(`[${this.config.name}] high-context alert send failed for ${chatId.slice(-8)}: ${this.errorSummary(err)}`);
3743
+ });
3647
3744
  }
3648
3745
  catch {
3649
3746
  // best-effort; never block delivery
@@ -3809,7 +3906,7 @@ export class FeishuBot {
3809
3906
  this.store.bindDeliveryRun(this.config.name, chatId, runId, { ...persisted, triggerId: target.triggerId, messageId: target.messageId, runId,
3810
3907
  sessionId: persisted?.sessionId || (owner?.runId === runId ? owner.sessionId : undefined),
3811
3908
  botIdentity: persisted?.botIdentity || this.config.appId, stopGeneration: persisted?.stopGeneration ?? this.stopGeneration(chatId),
3812
- sessionKey: persisted?.sessionKey || `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}` });
3909
+ sessionKey: persisted?.sessionKey || canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile) });
3813
3910
  this.deliveryTargetsByRun.set(key, {
3814
3911
  triggerId: target.triggerId,
3815
3912
  messageId: target.messageId,
@@ -3877,7 +3974,7 @@ export class FeishuBot {
3877
3974
  this.delayedFailureTimers.set(chatId, timer);
3878
3975
  }
3879
3976
  deliverySessionKey(chatId) {
3880
- return this.getSessionKey(chatId);
3977
+ return this.physicalSessionKey(chatId);
3881
3978
  }
3882
3979
  stableHash(text) {
3883
3980
  let hash = 2166136261;
@@ -3896,7 +3993,7 @@ export class FeishuBot {
3896
3993
  }
3897
3994
  async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId, completionTriggerIds) {
3898
3995
  if (provenance?.sessionKey && !/^agent:/.test(provenance.sessionKey))
3899
- provenance = { ...provenance, sessionKey: `agent:main:${provenance.sessionKey}` };
3996
+ provenance = { ...provenance, sessionKey: canonicalPhysicalSessionKey(provenance.sessionKey, this.profile) };
3900
3997
  if (provenance?.sessionKey && provenance.runId && this.store.isStoppedObservation(provenance.sessionKey, provenance.runId))
3901
3998
  return { state: "suppressed", outboxIds: [] };
3902
3999
  if (text.trim().toUpperCase() === 'NO_REPLY')
@@ -4066,7 +4163,7 @@ export class FeishuBot {
4066
4163
  return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
4067
4164
  }
4068
4165
  scopeRunError(chatId, p) {
4069
- const key = /^agent:[^:]+:/.test(p.sessionKey) ? p.sessionKey : `agent:main:${p.sessionKey}`;
4166
+ const key = canonicalPhysicalSessionKey(p.sessionKey, this.profile);
4070
4167
  const target = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
4071
4168
  const observation = this.store.getRunObservation(key, p.runId);
4072
4169
  // Pre-upgrade targets may lack app/physical fields. A known exact legacy
@@ -4091,7 +4188,7 @@ export class FeishuBot {
4091
4188
  const original = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
4092
4189
  if (original?.messageId && this.store.isMessageRecalled(original.messageId) ||
4093
4190
  original?.sessionId && p.sessionId && original.sessionId !== p.sessionId ||
4094
- original?.sessionKey && canonicalPhysicalSessionKey(original.sessionKey) !== canonicalPhysicalSessionKey(p.sessionKey))
4191
+ original?.sessionKey && canonicalPhysicalSessionKey(original.sessionKey, this.profile) !== canonicalPhysicalSessionKey(p.sessionKey, this.profile))
4095
4192
  return 'original_error_target_revoked';
4096
4193
  const epoch = this.stopGeneration(chatId);
4097
4194
  if (p.botIdentity && p.botIdentity !== this.config.appId || original?.botIdentity && original.botIdentity !== this.config.appId ||
@@ -4377,7 +4474,7 @@ export class FeishuBot {
4377
4474
  }
4378
4475
  const notice = JSON.parse(item.deliveryMetaJson || '{}').insertionNotice;
4379
4476
  if (notice) {
4380
- const info = await this.openclawClient.getSessionInfo(this.getSessionKey(chatId), 1500).catch(() => null);
4477
+ const info = await this.openclawClient.getSessionInfo(this.physicalSessionKey(chatId), 1500).catch(() => null);
4381
4478
  if (!this.insertionNoticeCurrent(chatId, notice) || info?.session?.archived === true ||
4382
4479
  info?.session?.sessionId && info.session.sessionId !== notice.sessionId) {
4383
4480
  this.store.markDeliveryFailed(item.id);
@@ -5901,7 +5998,7 @@ export class FeishuBot {
5901
5998
  return;
5902
5999
  }
5903
6000
  if (nextModel === this.config.model) {
5904
- await this.replyMessage(messageId, `🤖 ${this.config.name} 已经在使用:${this.config.model}`);
6001
+ await this.replyMessage(messageId, `🤖 ${this.config.name} 已经在使用:${this.config.model || `profile ${this.profile}`}`);
5905
6002
  return;
5906
6003
  }
5907
6004
  if (!this.configPath) {
@@ -5932,7 +6029,7 @@ export class FeishuBot {
5932
6029
  * Handle /status command: show current session info.
5933
6030
  */
5934
6031
  async handleStatusCommand(chatId, chatType, messageId) {
5935
- const sessionKey = this.getSessionKey(chatId);
6032
+ const sessionKey = this.physicalSessionKey(chatId);
5936
6033
  const chatInfo = this.store.getChatInfo(chatId);
5937
6034
  const msgCount = this.store.getMessageCount(chatId);
5938
6035
  let session = null;
@@ -6079,7 +6176,7 @@ export class FeishuBot {
6079
6176
  }
6080
6177
  }
6081
6178
  async handleCompactCommand(chatId, messageId, args = "") {
6082
- const sessionKey = this.getSessionKey(chatId);
6179
+ const sessionKey = this.physicalSessionKey(chatId);
6083
6180
  const en = this.isEn(chatId);
6084
6181
  // Argument parsing — modes are strictly separated:
6085
6182
  // /compact → transcript tail trim (default, model-free)
@@ -6180,7 +6277,7 @@ export class FeishuBot {
6180
6277
  * then verify via describe.
6181
6278
  */
6182
6279
  async handleResetCommand(chatId, messageId) {
6183
- const sessionKey = this.getSessionKey(chatId);
6280
+ const sessionKey = this.physicalSessionKey(chatId);
6184
6281
  try {
6185
6282
  await this.assertIdentityEffect();
6186
6283
  // Go supervision: a reset replaces the executor session generation; pause
@@ -6203,7 +6300,7 @@ export class FeishuBot {
6203
6300
  await new Promise((r) => setTimeout(r, 2000));
6204
6301
  this.assertIdentityCurrent();
6205
6302
  // A held reset must not initialize an unobserved successor generation.
6206
- this.initializedSessions.delete(sessionKey);
6303
+ this.initializedSessions.delete(this.getSessionKey(chatId));
6207
6304
  if (!held)
6208
6305
  await this.ensureSession(chatId);
6209
6306
  await this.replyMessage(messageId, `✅ Session 已重置\n模型: ${this.config.model}`);
@@ -6217,8 +6314,42 @@ export class FeishuBot {
6217
6314
  * runs for the session, clears the busy lock and every pending trigger, and
6218
6315
  * resets stuck reactions so new messages are processed normally again.
6219
6316
  */
6317
+ /** Release every peer bot's in-memory scheduling state for one chat.
6318
+ * Each bot instance keeps its own queueRuns / queueOwners / busyChats /
6319
+ * pendingAckMessages maps; a durable row cleanup cannot reach those. A
6320
+ * group-scoped /stop must retire them too, otherwise the peer's queue owner
6321
+ * survives and its next message is treated as "busy" forever. */
6322
+ releasePeerBotsForChat(chatId) {
6323
+ let released = 0;
6324
+ for (const peer of Array.from(FeishuBot.allBots.values())) {
6325
+ if (peer === this || peer.store !== this.store)
6326
+ continue;
6327
+ // Durable stop epoch was already bumped for this peer by the store-level
6328
+ // group cleanup; mirror it into the peer's in-memory cache.
6329
+ peer.stopEpoch.set(chatId, peer.store.getStopGeneration(peer.config.name, chatId));
6330
+ peer.forceExpireQueueOwner(chatId, 'group-scoped stop by peer bot');
6331
+ peer.busyChats.set(chatId, 0);
6332
+ peer.admittedSteerAcks.delete(chatId);
6333
+ peer.runFailureObservers.delete(chatId);
6334
+ peer.queueRunTokens.delete(chatId);
6335
+ const acks = peer.pendingAckMessages.get(chatId) || [];
6336
+ peer.pendingAckMessages.set(chatId, []);
6337
+ for (const ack of acks)
6338
+ void peer.receiptReaction(chatId, ack.rowId, ack.messageId, '').catch(() => { });
6339
+ // Fence the peer's stale collector so it cannot keep destructively
6340
+ // consuming the replacement batch's frames from the shared session bucket.
6341
+ try {
6342
+ this.openclawClient.revokeSessionCollectors(peer.getSessionKey(chatId), 'group-scoped stop');
6343
+ }
6344
+ catch { /* isolated */ }
6345
+ void peer.openclawClient.cancelSteerSession?.(peer.getSessionKey(chatId), peer.stopGeneration(chatId)).catch(() => { });
6346
+ void peer.openclawClient.abortChat(peer.getSessionKey(chatId)).catch(() => { });
6347
+ released++;
6348
+ }
6349
+ return released;
6350
+ }
6220
6351
  async handleStopCommand(chatId, messageId) {
6221
- const sessionKey = this.getSessionKey(chatId);
6352
+ const sessionKey = this.physicalSessionKey(chatId);
6222
6353
  // Go supervision: a user stop must also pause related Go, so the checker
6223
6354
  // cannot wake the just-stopped task back up with a follow-up.
6224
6355
  void this.goController?.pauseSilently(chatId, "用户执行了 /stop");
@@ -6243,14 +6374,29 @@ export class FeishuBot {
6243
6374
  this.pendingAckMessages.set(chatId, []);
6244
6375
  for (const ack of acks)
6245
6376
  void this.receiptReaction(chatId, ack.rowId, ack.messageId, '').catch(() => { });
6377
+ // Group scope: a bare /stop in a multi-bot group is executed by ONE bot,
6378
+ // but every bot keeps its own pending_triggers / insertion_custody /
6379
+ // dispatch_custody / reaction rows AND its own in-memory queue owner for
6380
+ // this same chat. Stopping only the executing bot left the others
6381
+ // believing the chat was busy, so their next message was queued and
6382
+ // never dispatched. Release the whole chat for every peer bot.
6383
+ //
6384
+ // This MUST run BEFORE the abort RPC await: a user message that arrives
6385
+ // while the abort is in flight has to survive the stop cutoff, exactly
6386
+ // like the executing bot's own late input above.
6387
+ const group = this.store.stopChatForAllBots(chatId, this.config.name);
6388
+ const peers = this.releasePeerBotsForChat(chatId);
6246
6389
  await this.openclawClient.abortChat(sessionKey).catch(() => { });
6247
6390
  if (!this.queueRuns.has(chatId))
6248
6391
  this.busyChats.set(chatId, 0);
6249
6392
  const locale = this.chatLocale(chatId);
6393
+ const scopeNote = peers > 0
6394
+ ? (locale === "en" ? ` Also released ${peers} peer bot(s) in this chat.` : ` 同时释放了本群其余 ${peers} 个 bot 的队列。`)
6395
+ : "";
6250
6396
  await this.replyMessage(messageId, locale === "en"
6251
- ? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now.`
6252
- : `⏹️ 已停止。已中止当前 run、解锁队列,并清掉 ${cleared} 条待处理消息。现在可以重新发消息了。`);
6253
- console.log(`[${this.config.name}] /stop force-cleared ${chatId.slice(-8)}: aborted run, cleared ${cleared} pending`);
6397
+ ? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now.${scopeNote}`
6398
+ : `⏹️ 已停止。已中止当前 run、解锁队列,并清掉 ${cleared} 条待处理消息。现在可以重新发消息了。${scopeNote}`);
6399
+ console.log(`[${this.config.name}] /stop force-cleared ${chatId.slice(-8)}: aborted run, cleared ${cleared} pending, group: insertions=${group.stoppedInsertions} triggers=${group.clearedTriggers} custody=${group.clearedCustody} peers=${peers}`);
6254
6400
  }
6255
6401
  catch (err) {
6256
6402
  await this.replyMessage(messageId, `❌ stop 失败: ${err.message}`);
@@ -75,6 +75,8 @@ export type GoClient = {
75
75
  export type GoControllerDeps = {
76
76
  botName: string;
77
77
  fallbackModel: string;
78
+ /** OpenClaw profile that owns this bot's sessions (default "main"). */
79
+ profile?: string;
78
80
  store: import("./message-store.js").MessageStore;
79
81
  client: GoClient;
80
82
  /** Executor session key for a chat (e.g. lma-<bot>-<chatId>). */
@@ -1,5 +1,14 @@
1
1
  /** Independent LMA supervision. MessageStore owns objective, criteria and completion. */
2
2
  import { goSnapshotBlocked } from "./go-runtime-gate.js";
3
+ /** Logical (profile-free) part of a session key. */
4
+ const withoutAgentPrefix = (key) => key.replace(/^agent:[^:]+:/, '');
5
+ /** Physical session key for a logical executor key under this bot's profile. */
6
+ const physicalKey = (key, profile) => {
7
+ if (/^agent:[^:]+:/.test(key))
8
+ return key;
9
+ const agent = String(profile || "main").trim() || "main";
10
+ return `agent:${agent}:${key}`;
11
+ };
3
12
  const CHECK_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_GO_CHECK_DELAY_MS || 20000);
4
13
  const CHECKER_TIMEOUT_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_GO_CHECKER_TIMEOUT_MS || 10 * 60 * 1000);
5
14
  const MAX_CONTINUES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_GO_MAX_CONTINUES || 10);
@@ -120,7 +129,7 @@ export class GoController {
120
129
  const executor = sessionKey === job.executorSessionKey;
121
130
  const result = executor ? await client.collectGoExecutorRun({ runId: receipt.runId, sessionKey, sessionId: expectedSessionId, timeoutMs: CHECKER_TIMEOUT_MS }) : null;
122
131
  if (result && (result.runId !== receipt.runId || result.sessionId !== expectedSessionId ||
123
- (result.sessionKey !== sessionKey && result.sessionKey !== `agent:main:${sessionKey}`)))
132
+ (result.sessionKey !== sessionKey && result.sessionKey !== physicalKey(sessionKey, this.d.profile))))
124
133
  throw new Error("Executor result identity mismatch");
125
134
  const text = result ? result.text : await client.collectGoRun({ runId: receipt.runId, sessionKey, sessionId: expectedSessionId, timeoutMs: CHECKER_TIMEOUT_MS });
126
135
  this.d.store.recordGoRunResult(jobId, actionKey, { state: "complete", runId: receipt.runId, text, ...(result ? { executorResult: result } : {}) });
@@ -204,7 +213,10 @@ export class GoController {
204
213
  await this.d.reply(messageId, `⚠️ 当前已有活动 Go #${existing.id}:\n目标:${existing.goal.slice(0, 200)}\n如需更换目标,请先 /go stop。`);
205
214
  return;
206
215
  }
207
- const executorKey = this.d.executorSessionKeyFor(chatId);
216
+ // The Go supervision path talks to the Gateway directly, so it uses the
217
+ // PHYSICAL key throughout (profile included) exactly like the executor
218
+ // session itself.
219
+ const executorKey = physicalKey(this.d.executorSessionKeyFor(chatId), this.d.profile);
208
220
  if (await this.safetyUnavailable(executorKey)) {
209
221
  await this.d.reply(messageId, "❌ Go 未启动:当前运行时尚未验证 Checker 工具隔离、精确子孙任务/投递门禁及条件发送。不能仅凭提示词安全开启自动监督。独立模式无需原生 Goal;普通聊天不受影响。");
210
222
  return;
@@ -230,7 +242,10 @@ export class GoController {
230
242
  || (typeof session.model === "string" && session.model
231
243
  ? (typeof session.modelProvider === "string" && session.modelProvider && !session.model.includes("/") ? `${session.modelProvider}/${session.model}` : session.model)
232
244
  : this.d.fallbackModel);
233
- const checkerKey = `${executorKey.replace(/^lma-/, "lma-go-")}-${crypto.randomUUID()}`;
245
+ // The checker session NAME stays profile-free (lma- -> lma-go-), exactly like
246
+ // every other logical key LMA stores. It is addressed under the bot's
247
+ // profile only when it is actually handed to the Gateway.
248
+ const checkerKey = physicalKey(`${withoutAgentPrefix(executorKey).replace(/^lma-/, "lma-go-")}-${crypto.randomUUID()}`, this.d.profile);
234
249
  try {
235
250
  await this.d.client.createSession({ key: checkerKey, model: resolvedModel, label: `Go Checker: ${this.d.botName} ${chatId.slice(-6)}` });
236
251
  }
@@ -349,7 +364,7 @@ export class GoController {
349
364
  if (saved?.state !== "complete") {
350
365
  const result = await this.d.client.readGoExecutorRunResult?.({ ...send, runId: receipt.runId });
351
366
  if (!result || result.runId !== receipt.runId || result.sessionId !== job.executorSessionId ||
352
- ![job.executorSessionKey, `agent:main:${job.executorSessionKey}`].includes(result.sessionKey)) {
367
+ ![job.executorSessionKey, physicalKey(job.executorSessionKey, this.d.profile)].includes(result.sessionKey)) {
353
368
  this.d.store.recordGoRunResult(job.id, send.actionKey, { state: "unknown", runId: receipt.runId });
354
369
  continue;
355
370
  }
@@ -510,16 +525,17 @@ export class GoController {
510
525
  // ---------------- check cycle ----------------
511
526
  async executorBusy(chatId, job) {
512
527
  try {
513
- if (await this.safetyUnavailable(job.executorSessionKey))
528
+ const executorPhysicalKey = job.executorSessionKey;
529
+ if (await this.safetyUnavailable(executorPhysicalKey))
514
530
  return "运行时安全能力未验证";
515
- const info = await (this.d.client.getGoSessionInfo?.bind(this.d.client) ?? this.d.client.getSessionInfo.bind(this.d.client))(job.executorSessionKey);
531
+ const info = await (this.d.client.getGoSessionInfo?.bind(this.d.client) ?? this.d.client.getSessionInfo.bind(this.d.client))(executorPhysicalKey);
516
532
  if (info?.session?.sessionId !== job.executorSessionId || info?.session?.archived)
517
533
  return "执行会话代际已改变或归档";
518
534
  const identity = this.d.store.getGoNativeIdentity(job.id);
519
535
  if (!identity)
520
536
  return "Missing independent run binding";
521
- const snapshot = await this.d.client.readGoRuntimeSnapshot(job.executorSessionKey, job.executorSessionId);
522
- const blocked = goSnapshotBlocked(snapshot, { sessionKey: job.executorSessionKey.startsWith("agent:") ? job.executorSessionKey : `agent:main:${job.executorSessionKey}`, sessionId: job.executorSessionId,
537
+ const snapshot = await this.d.client.readGoRuntimeSnapshot(executorPhysicalKey, job.executorSessionId);
538
+ const blocked = goSnapshotBlocked(snapshot, { sessionKey: executorPhysicalKey, sessionId: job.executorSessionId,
523
539
  goalId: identity.nativeGoalId, goalRevision: identity.goalRevision, executorRunIds: identity.executorRunIds });
524
540
  if (blocked) {
525
541
  if (blocked === "identity/version changed")
@@ -171,7 +171,7 @@ export declare class MessageStore {
171
171
  recoverUnsentReservations(bot: string): number;
172
172
  saveAdmission(sessionKey: string, submissionId: string, state: string, record: object): void;
173
173
  listAdmissions(sessionKey: string): any[];
174
- stopExactRun(sessionKey: string, runId: string): void;
174
+ stopExactRun(sessionKey: string, runId: string, userStopped?: boolean): void;
175
175
  isStoppedObservation(sessionKey: string, runId: string): boolean;
176
176
  hasUnconfirmedRunDelivery(bot: string, chat: string, runId: string): boolean;
177
177
  /** UI-only witness of an exact run's visible reply in the original Bot/chat.
@@ -190,6 +190,27 @@ export declare class MessageStore {
190
190
  hasYieldedObservation(sessionKey: string): boolean;
191
191
  getRunObservation(sessionKey: string, runId: string): any;
192
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. */
193
214
  stopSessionObservations(sessionKey: string): void;
194
215
  settleExactRunReactions(sessionKey: string, runId: string): void;
195
216
  finishRunObservation(sessionKey: string, runId: string, state?: string): void;
@@ -211,6 +232,23 @@ export declare class MessageStore {
211
232
  isInsertionReserved(bot: string, chat: string, row: number): boolean;
212
233
  stopInsertionAtEpoch(bot: string, chat: string, row: number, epoch: number): void;
213
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;
214
252
  hasUnknownDeliveryOperation(outbox: number): boolean;
215
253
  /** Includes non-visible uploads/doc creation: absence of a message_id is
216
254
  * not proof that no SDK effect has been attempted or accepted. */
@@ -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
- this.db.prepare(`INSERT INTO run_observations VALUES(?,?,'{}','stopped',0)
263
- ON CONFLICT(session_key,run_id) DO UPDATE SET state='stopped'`).run(sessionKey, runId);
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);
@@ -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. */
@@ -80,6 +80,15 @@ const terminalReason = (...values) => values.map(closedStopReason).sort((a, b) =
80
80
  * OpenClaw Gateway WebSocket client.
81
81
  * Full agent pipeline — tools, memory, skills, context management by OpenClaw.
82
82
  */
83
+ /** Physical session key for a logical key.
84
+ *
85
+ * A key that already carries an explicit `agent:<id>:` prefix keeps it, so a
86
+ * client shared by several bots can serve sessions owned by different
87
+ * OpenClaw profiles. A key without a prefix stays on the historical `main`
88
+ * profile, so existing configurations behave exactly as before. */
89
+ const withAgentPrefix = (key) => /^agent:[^:]+:/.test(key) ? key : `agent:main:${key}`;
90
+ /** Logical (profile-free) part of a session key. */
91
+ const withoutAgentPrefix = (key) => key.replace(/^agent:[^:]+:/, '');
83
92
  export class OpenClawClient {
84
93
  goEvidenceObservers = new Set();
85
94
  /** Raw full event observer. Observation is NOT a Feishu delivery receipt. */
@@ -153,7 +162,11 @@ export class OpenClawClient {
153
162
  isExplicitlyStoppedRun(sessionKey, runId) {
154
163
  if (!runId)
155
164
  return false;
156
- if (this.recoveryStore?.isStoppedObservation(this.canonicalSessionKey(sessionKey), runId))
165
+ // A retired observation is NOT the same as a user-requested stop. A bare
166
+ // /stop in a multi-bot group retires EVERY bot's observation on the shared
167
+ // session; treating that retirement as "the user stopped this run"
168
+ // discarded answers the Gateway had already computed.
169
+ if (this.recoveryStore?.isExplicitlyStoppedObservation(this.canonicalSessionKey(sessionKey), runId))
157
170
  return true;
158
171
  const key = JSON.stringify([this.canonicalSessionKey(sessionKey), runId]);
159
172
  const at = this.explicitlyStoppedRuns.get(key);
@@ -1255,7 +1268,7 @@ export class OpenClawClient {
1255
1268
  // tripping the disconnect path.
1256
1269
  const startedConnected = this.connected;
1257
1270
  let chatFinalText = "";
1258
- let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
1271
+ let sessionKey = targetSessionKey ? withAgentPrefix(targetSessionKey) : "";
1259
1272
  let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
1260
1273
  let chatFinalTimer = null;
1261
1274
  let lifecycleEndTimer = null;
@@ -2227,7 +2240,7 @@ export class OpenClawClient {
2227
2240
  return sessionKey.replace(/^agent:[^:]+:/, "");
2228
2241
  }
2229
2242
  canonicalSessionKey(sessionKey) {
2230
- return /^agent:[^:]+:/.test(sessionKey) ? sessionKey : `agent:main:${sessionKey}`;
2243
+ return withAgentPrefix(sessionKey);
2231
2244
  }
2232
2245
  registerPendingSteer(sessionKey, text, displayText) {
2233
2246
  const key = this.shortKey(sessionKey);
@@ -2533,7 +2546,7 @@ export class OpenClawClient {
2533
2546
  const message = firstErr.message || String(firstErr);
2534
2547
  if (!/session (?:not found|unknown)|unknown session|invalid session key/i.test(message))
2535
2548
  throw firstErr;
2536
- const fullKey = sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`;
2549
+ const fullKey = withAgentPrefix(sessionKey);
2537
2550
  held?.guard();
2538
2551
  await this.patchSession({ key: fullKey, model: expectedModel, ...(held ? { expectedSessionId: held.sessionId } : {}) });
2539
2552
  }
@@ -2743,7 +2756,7 @@ export class OpenClawClient {
2743
2756
  for (const stopped of result.runIds)
2744
2757
  if (typeof stopped === "string" && stopped) {
2745
2758
  this.explicitlyStoppedRuns.set(JSON.stringify([key, stopped]), Date.now());
2746
- this.recoveryStore?.stopExactRun(key, stopped);
2759
+ this.recoveryStore?.stopExactRun(key, stopped, true);
2747
2760
  }
2748
2761
  }
2749
2762
  return result;
@@ -2978,8 +2991,8 @@ export class OpenClawClient {
2978
2991
  }
2979
2992
  }
2980
2993
  muteProactiveDelivery(sessionKey) {
2981
- const shortKey = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
2982
- const fullKey = `agent:main:${shortKey}`;
2994
+ const shortKey = withoutAgentPrefix(sessionKey);
2995
+ const fullKey = withAgentPrefix(sessionKey);
2983
2996
  const keys = [shortKey, fullKey];
2984
2997
  for (const key of keys)
2985
2998
  this.addMutedProactiveKey(key);
@@ -3642,7 +3655,7 @@ export class OpenClawClient {
3642
3655
  if (onSettled)
3643
3656
  this.sessionSettlementCallbacks.set(this.canonicalSessionKey(sessionKey), onSettled);
3644
3657
  this.sessionMessageCallbacks.set(sessionKey, onMessage);
3645
- this.sessionMessageCallbacks.set(`agent:main:${sessionKey}`, onMessage);
3658
+ this.sessionMessageCallbacks.set(withAgentPrefix(sessionKey), onMessage);
3646
3659
  this.subscribedKeys.add(sessionKey);
3647
3660
  try {
3648
3661
  await this.rpc("sessions.messages.subscribe", { key: this.canonicalSessionKey(sessionKey) });
@@ -3652,7 +3665,7 @@ export class OpenClawClient {
3652
3665
  }
3653
3666
  }
3654
3667
  setVerboseTranscriptDelivery(sessionKey, enabled) {
3655
- const keys = [sessionKey, `agent:main:${sessionKey}`];
3668
+ const keys = [sessionKey, withAgentPrefix(sessionKey)];
3656
3669
  for (const key of keys) {
3657
3670
  if (enabled)
3658
3671
  this.verboseTranscriptSessions.add(key);
@@ -3665,9 +3678,9 @@ export class OpenClawClient {
3665
3678
  async unsubscribeSession(sessionKey) {
3666
3679
  this.sessionSettlementCallbacks.delete(this.canonicalSessionKey(sessionKey));
3667
3680
  this.sessionMessageCallbacks.delete(sessionKey);
3668
- this.sessionMessageCallbacks.delete(`agent:main:${sessionKey}`);
3681
+ this.sessionMessageCallbacks.delete(withAgentPrefix(sessionKey));
3669
3682
  this.toolEventCallbacks.delete(sessionKey);
3670
- this.toolEventCallbacks.delete(`agent:main:${sessionKey}`);
3683
+ this.toolEventCallbacks.delete(withAgentPrefix(sessionKey));
3671
3684
  this.setVerboseTranscriptDelivery(sessionKey, false);
3672
3685
  this.subscribedKeys.delete(sessionKey);
3673
3686
  try {
@@ -3682,6 +3695,6 @@ export class OpenClawClient {
3682
3695
  */
3683
3696
  onToolEvent(sessionKey, callback) {
3684
3697
  this.toolEventCallbacks.set(sessionKey, callback);
3685
- this.toolEventCallbacks.set(`agent:main:${sessionKey}`, callback);
3698
+ this.toolEventCallbacks.set(withAgentPrefix(sessionKey), callback);
3686
3699
  }
3687
3700
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.20",
3
+ "version": "1.8.21",
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": {
@@ -89,4 +89,4 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  }
92
- }
92
+ }