openclaw-lark-multi-agent 1.8.15 → 1.8.16

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.
@@ -38,6 +38,9 @@ export declare class FeishuBot {
38
38
  /** Per-chat processQueue lock to avoid duplicate concurrent chat.send runs */
39
39
  private queueRuns;
40
40
  private queueOwners;
41
+ /** Monotone local batch epoch: an empty owner map must not hide an
42
+ * intervening admission/finish (ABA) from an asynchronous public lookup. */
43
+ private queueAdmissionEpoch;
41
44
  /** Per-chat serial send queue to guarantee message order */
42
45
  private sendQueue;
43
46
  /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
@@ -128,10 +131,23 @@ export declare class FeishuBot {
128
131
  static getByName(name: string): FeishuBot | undefined;
129
132
  static findByOpenId(openId: string): FeishuBot | undefined;
130
133
  private handleMessage;
134
+ /** Separate guarded-admission proof. Aggregate busy never becomes idle. */
135
+ private observedFollowupGuard;
136
+ /** A positive refusal must be durable AND explained to the sender. */
137
+ private retainChangedFollowup;
138
+ private explainUnconfirmedFollowup;
139
+ /** Keep each input's first observed leaf: a fresh arrival must not borrow
140
+ * an obsolete scheduler leaf from before it was received. */
141
+ private pinDispatchFollowupProof;
131
142
  /** With no local owner, the Gateway can still have externally admitted work.
132
143
  * Read public activity and pin its exact root before choosing insertion. Missing
133
144
  * identity is durable unbound custody, never a fabricated run or blind send. */
134
145
  private reconcileCurrentInputActivity;
146
+ /** An ingress reservation can outlive an ACK-window owner which finished
147
+ * before learning its runId. It is still never-attempted custody, not a
148
+ * licence to retarget/steer a successor. Require the same guarded proof and
149
+ * bounded observation with NO replacement local scheduler at every await. */
150
+ private reconcileRetainedFollowup;
135
151
  /** CAS includes the actual batch, not merely the queue Promise, and excludes
136
152
  * local admission/RPC windows even if the host briefly reports idle. */
137
153
  private reconcileDispatchCustody;
@@ -171,11 +187,10 @@ export declare class FeishuBot {
171
187
  private fireRunFailureObservers;
172
188
  private processQueue;
173
189
  /**
174
- * Force-release a stuck queue owner after the gateway authoritatively
175
- * reported the session idle (no active run). The zombie owner's deep waits
176
- * can otherwise hold chat custody forever: completion events routed through
177
- * the proactive path are invisible to them. Steer-fallback triggers are
178
- * restored via the failure observers so their input drains normally.
190
+ * Retire only the local queue scheduler after positive idle, trusted yield,
191
+ * or bounded guarded-followup proof. Never stop Gateway execution or infer
192
+ * a terminal outcome. Exact result recovery/target custody remains durable;
193
+ * attempted-unknown and accepted insertion records never enter replay.
179
194
  */
180
195
  private forceExpireQueueOwner;
181
196
  private processQueueInner;
@@ -4,7 +4,7 @@ import * as lark from "@larksuiteoapi/node-sdk";
4
4
  import { createRequire } from "module";
5
5
  import { persistBotModel } from "./config.js";
6
6
  import { getI18n, normalizeLocale } from "./i18n.js";
7
- import { InactiveRunObservation, AdmissionUnconfirmed, CollectorStalledError } from "./openclaw-client.js";
7
+ import { InactiveRunObservation, AdmissionUnconfirmed, CollectorStalledError, FollowupAdmissionRefused } from "./openclaw-client.js";
8
8
  import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
9
9
  import { SupersededObservation } from "./openclaw-client.js";
10
10
  import { LiveStatusController, LiveStatusEffectRejectedError, LiveStatusEffectUnknownError } from "./live-status.js";
@@ -30,6 +30,7 @@ export class SendDeadlineError extends Error {
30
30
  }
31
31
  }
32
32
  const nonemptyReceipt = (value) => typeof value === 'string' && !!value.trim();
33
+ const canonicalPhysicalSessionKey = (key) => /^agent:[^:]+:/.test(key) ? key : `agent:main:${key}`;
33
34
  const require = createRequire(import.meta.url);
34
35
  const LMA_VERSION = require("../package.json").version;
35
36
  const MAX_BOT_STREAK = 10;
@@ -148,6 +149,9 @@ export class FeishuBot {
148
149
  /** Per-chat processQueue lock to avoid duplicate concurrent chat.send runs */
149
150
  queueRuns = new Map();
150
151
  queueOwners = new Map();
152
+ /** Monotone local batch epoch: an empty owner map must not hide an
153
+ * intervening admission/finish (ABA) from an asynchronous public lookup. */
154
+ queueAdmissionEpoch = new Map();
151
155
  /** Per-chat serial send queue to guarantee message order */
152
156
  sendQueue = new Map();
153
157
  /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
@@ -1283,9 +1287,9 @@ export class FeishuBot {
1283
1287
  if (unhealthy) {
1284
1288
  await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
1285
1289
  }
1286
- // Inspect current public activity, not just the local promise. A yielded
1287
- // task (or a settled/uncached old root with authoritative idle history)
1288
- // keeps its result observer but cannot monopolize independent new input.
1290
+ // Inspect public activity, not just the local promise. Trusted yield,
1291
+ // positive idle, or bounded guarded-followup proof may hand off ONLY the
1292
+ // local scheduler; the original exact result observer/target survives.
1289
1293
  const dispatchReleased = await this.reconcileDispatchCustody(chatId);
1290
1294
  if (this.stopGeneration(chatId) !== inputStopEpoch)
1291
1295
  return;
@@ -1311,8 +1315,8 @@ export class FeishuBot {
1311
1315
  return;
1312
1316
  }
1313
1317
  // --- Queue-based sending: if agent is busy, just accumulate ---
1314
- // The actual queue owner, not elapsed wall time, decides whether input
1315
- // should steer/queue. Long healthy runs must not lose their busy ownership.
1318
+ // No task-lifetime cutoff: long healthy runs retain execution/result
1319
+ // custody. Local scheduling handoff requires independent public proof.
1316
1320
  const isBusy = reservedAtIngress || this.queueRuns.has(chatId) || !!this.store.getDispatchCustody(this.config.name, chatId);
1317
1321
  if (isBusy) {
1318
1322
  const owner = this.queueOwners.get(chatId) || this.store.getDispatchCustody(this.config.name, chatId);
@@ -1333,17 +1337,49 @@ export class FeishuBot {
1333
1337
  console.error(`[${this.config.name}] Error:`, safeErrorSummary(err));
1334
1338
  }
1335
1339
  }
1340
+ /** Separate guarded-admission proof. Aggregate busy never becomes idle. */
1341
+ observedFollowupGuard(chatId, status, activity, noEffectRunId) {
1342
+ if (!activity.followupAdmissionEligible || activity.identityConflict || activity.activeRunId && !noEffectRunId || activity.archived || status.archived ||
1343
+ !status.sessionId || status.sessionId !== activity.sessionId || !activity.expectedLeafEntryId)
1344
+ return;
1345
+ return { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId)), sessionId: status.sessionId, expectedLeafEntryId: activity.expectedLeafEntryId,
1346
+ ...(noEffectRunId ? { nonInjectableRunId: noEffectRunId } : {}) };
1347
+ }
1348
+ /** A positive refusal must be durable AND explained to the sender. */
1349
+ retainChangedFollowup(chatId, item, reason) {
1350
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'target_changed', { ...item, reason });
1351
+ const key = `followup:${item.insertionId}:refused`;
1352
+ void this.enqueueAndDispatchDelivery(chatId, 'admission_status', key, 'ℹ️ 这条消息仍保留在本地,尚未确认 Gateway 接收:会话或准入保护条件已变化。不会迁移或自动重发;请检查会话后决定是否另发新消息。', [], item.messageId, key).catch(() => { });
1353
+ }
1354
+ explainUnconfirmedFollowup(chatId, item) {
1355
+ const key = `followup:${item.insertionId}:guard-unavailable`;
1356
+ void this.enqueueAndDispatchDelivery(chatId, 'admission_status', key, 'ℹ️ 这条消息仍在本地保管,尚未确认 Gateway 接收:目前缺少可核验的会话/分支准入证据,或插入能力不可确认。不会显示已接收,也不会盲目重发;证据恢复后继续核验。', [], item.messageId, key).catch(() => { });
1357
+ }
1358
+ /** Keep each input's first observed leaf: a fresh arrival must not borrow
1359
+ * an obsolete scheduler leaf from before it was received. */
1360
+ pinDispatchFollowupProof(chatId, saved, proof) {
1361
+ for (const item of this.store.listInsertions(this.config.name, chatId)) {
1362
+ if (item.state !== 'pending' || item.attempted || item.followupAdmissionGuard || item.ordinaryEligible === false ||
1363
+ item.ownerId !== saved.id || item.batchId !== saved.batchId || item.stopEpoch !== saved.stopEpoch || item.sessionId !== proof.sessionId ||
1364
+ !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== proof.sessionKey)
1365
+ continue;
1366
+ const source = this.store.getMessageByMessageId(item.messageId);
1367
+ if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
1368
+ continue;
1369
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'pending', { ...item, followupAdmissionGuard: proof, followupObservedAt: Date.now() }, item.nextCheckAt);
1370
+ }
1371
+ }
1336
1372
  /** With no local owner, the Gateway can still have externally admitted work.
1337
1373
  * Read public activity and pin its exact root before choosing insertion. Missing
1338
1374
  * identity is durable unbound custody, never a fabricated run or blind send. */
1339
1375
  async reconcileCurrentInputActivity(chatId, rowId, messageId, text) {
1340
1376
  if (!this.openclawClient.getSessionRuntimeStatus || !this.openclawClient.getSessionDispatchStatus)
1341
1377
  return;
1342
- const epoch = this.stopGeneration(chatId);
1378
+ const epoch = this.stopGeneration(chatId), admissionEpoch = this.queueAdmissionEpoch.get(chatId) || 0;
1343
1379
  // A different ingress handler can ordinary-admit AND finish this row while
1344
1380
  // a public lookup is in flight. Check message custody, not just an empty
1345
1381
  // owner map, at every boundary so a late lookup can never reinsert it.
1346
- const unsubmitted = () => this.stopGeneration(chatId) === epoch &&
1382
+ const unsubmitted = () => this.stopGeneration(chatId) === epoch && (this.queueAdmissionEpoch.get(chatId) || 0) === admissionEpoch &&
1347
1383
  this.store.getPendingTriggerIds(this.config.name, chatId).has(rowId) &&
1348
1384
  !this.queueOwners.get(chatId)?.triggerIds?.has(rowId);
1349
1385
  if (!unsubmitted())
@@ -1377,6 +1413,8 @@ export class FeishuBot {
1377
1413
  const current = this.store.getInsertion(this.config.name, chatId, rowId);
1378
1414
  if (current && (!current.activityUnbound || current.attempted))
1379
1415
  return;
1416
+ if (current && (current.stopEpoch !== epoch || !current.sessionKey || canonicalPhysicalSessionKey(current.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId))))
1417
+ return;
1380
1418
  const activity = await this.openclawClient.getSessionDispatchStatus(this.getSessionKey(chatId));
1381
1419
  if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1382
1420
  return;
@@ -1384,46 +1422,111 @@ export class FeishuBot {
1384
1422
  // dispatch while this one awaited history. Never restore its older
1385
1423
  // never-attempted snapshot over an attempted/unknown/accepted journal.
1386
1424
  const fresh = this.store.getInsertion(this.config.name, chatId, rowId);
1387
- if (current ? fresh?.insertionId !== current.insertionId || fresh.state !== 'pending' || fresh.attempted === true || !fresh.activityUnbound || !!fresh.expectedRunId : !!fresh)
1425
+ if (current ? JSON.stringify(fresh) !== JSON.stringify(current) || fresh?.insertionId !== current.insertionId || fresh.state !== 'pending' || fresh.attempted === true || !fresh.activityUnbound || !!fresh.expectedRunId : !!fresh)
1426
+ return;
1427
+ const compatible = !!status.sessionId && status.sessionId === activity.sessionId && (!current?.sessionId || current.sessionId === status.sessionId);
1428
+ const archived = !!status.archived || !!activity.archived;
1429
+ const generationConflict = activity.identityConflict === true || !!activity.sessionId && !!status.sessionId && activity.sessionId !== status.sessionId ||
1430
+ !!current?.sessionId && !!status.sessionId && current.sessionId !== status.sessionId;
1431
+ const source = this.store.getMessageByMessageId(messageId);
1432
+ const ordinaryHuman = source?.id === rowId && source.chatId === chatId && source.senderType === 'human' && source.triggerKind === 'normal';
1433
+ const proof = ordinaryHuman && compatible && !current?.followupBlocked && !archived ? this.observedFollowupGuard(chatId, status, activity) : undefined;
1434
+ const previous = current?.followupAdmissionGuard;
1435
+ if (previous && (archived || generationConflict)) {
1436
+ this.retainChangedFollowup(chatId, { ...current, rowId }, 'observed followup session/archive changed; input retained without migration');
1388
1437
  return;
1389
- const compatible = !activity.sessionId || !status.sessionId || activity.sessionId === status.sessionId;
1390
- const idle = compatible && !status.running && !status.archived && !activity.archived && !activity.busyConfirmed &&
1391
- activity.idleConfirmed === true && !!activity.sessionId;
1392
- if (idle) {
1438
+ }
1439
+ const idle = compatible && !status.running && !archived && !activity.busyConfirmed && activity.idleConfirmed === true;
1440
+ if (idle && !previous && !current?.followupBlocked) {
1393
1441
  if (current)
1394
1442
  this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId, 'ordinary', { ...current, activityUnbound: false });
1395
1443
  return;
1396
1444
  }
1397
- const identified = compatible && !!activity.activeRunId && !!activity.sessionId && !activity.archived && !status.archived;
1398
- // A4 (2026-10-17 protocol audit): positive blockers are gateway assertions,
1399
- // not unknown states. An archived session, an incompatible session
1400
- // generation, or confirmed busy-without-injectable-owner each positively
1401
- // forbids fresh ordinary admission, so the bounded fallback below must
1402
- // never hand such an input to the ordinary queue; it stays retained.
1403
- const archived = !!status.archived || !!activity.archived;
1404
- const generationConflict = !!activity.sessionId && !!status.sessionId && activity.sessionId !== status.sessionId;
1405
- const positivelyBusy = activity.busyConfirmed === true;
1406
- // Bounded retention (2026-10-17 deadlock): an input that has stayed
1407
- // activity-unbound for over 60s despite re-checks must not be held forever.
1408
- // Hand it to the ordinary queue — the gateway's own chat.send admission is
1409
- // the authoritative answer and admission custody already dedups. This
1410
- // rescue is reserved for GENUINELY unknown states; positive blockers are
1411
- // excluded above and keep the never-attempted input retained.
1412
- const unboundSince = current?.activityUnbound ? (current.unboundSince || 0) : 0;
1413
- if (!identified && !archived && !generationConflict && !positivelyBusy && unboundSince && Date.now() - unboundSince > 60_000) {
1414
- this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId, 'ordinary', { ...current, activityUnbound: false, reason: 'gateway state unconfirmable over 60s; ordinary admission is authoritative' });
1445
+ const identified = compatible && !!activity.activeRunId && !archived;
1446
+ // Explicit followup no longer inherits stored steer/interrupt. After two
1447
+ // matching physical-session observations separated by 60s, busy-without-a-target may
1448
+ // admit ONE guarded queued turn. Unknown/missing leaf is NOT a timeout CAS.
1449
+ // Keep the EARLIEST genuinely observed leaf. The public RPC accepts an
1450
+ // ancestor on the active path with sessionId, but rejects an off-path leaf.
1451
+ // Ordinary transcript advancement is NOT a local generation conflict.
1452
+ const observedAt = previous && proof ? current.followupObservedAt : Date.now();
1453
+ if (!identified && proof && previous && Number.isFinite(observedAt) && Date.now() - observedAt >= 60_000) {
1454
+ this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId, 'ordinary', { ...current, activityUnbound: false, followupAdmissionGuard: previous, reason: 'bounded matching public proof; explicit guarded followup admission only' });
1415
1455
  return;
1416
1456
  }
1417
- const blockerReason = archived ? 'retained: archived (gateway positively reported archived session; 60s ordinary fallback suppressed)'
1418
- : generationConflict ? 'retained: generation-conflict (activity session generation differs from runtime status; 60s ordinary fallback suppressed)'
1419
- : positivelyBusy ? 'retained: positively-busy (gateway confirmed busy without an injectable owner; 60s ordinary fallback suppressed)' : '';
1457
+ // Preserve the deployed ordinary rescue for a fresh NEVER-ATTEMPTED input
1458
+ // whose ACTIVITY observation is genuinely unknown. That is not an unknown
1459
+ // submission receipt, nor an escape from this restricted/positive proof.
1460
+ const unboundSince = current?.activityUnbound ? current.unboundSince : undefined;
1461
+ if (!identified && !archived && !generationConflict && activity.busyConfirmed !== true && !previous && !current?.followupBlocked && !current?.restrictedObservation &&
1462
+ current?.state === 'pending' && !current.attempted && !current.admissionSubmissionId && current.ordinaryEligible !== false &&
1463
+ unboundSince && Date.now() - unboundSince > 60_000) {
1464
+ this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId, 'ordinary', { ...current, activityUnbound: false, reason: 'fresh never-attempted input; ordinary unknown-state bounded followup rescue' });
1465
+ return;
1466
+ }
1467
+ const blockerReason = archived ? 'retained: archived'
1468
+ : generationConflict ? 'retained: generation-conflict'
1469
+ : activity.busyConfirmed ? 'retained: positively-busy; guarded followup proof absent or observation window pending'
1470
+ : 'retained: genuinely unknown activity; fresh ordinary rescue observation pending';
1420
1471
  this.store.saveInsertion(this.config.name, chatId, rowId, current?.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1421
- messageId, text, stopEpoch: epoch, sessionKey: this.getSessionKey(chatId), sessionId: identified ? activity.sessionId : status.sessionId,
1472
+ ...current, messageId, text, stopEpoch: epoch, sessionKey: this.getSessionKey(chatId), sessionId: current?.sessionId || activity.sessionId || status.sessionId,
1422
1473
  expectedRunId: identified ? activity.activeRunId : '', ownerId: identified ? `external:${activity.activeRunId}` : undefined,
1423
1474
  batchId: identified ? activity.activeRunId : undefined, activityUnbound: !identified, attempted: false,
1424
- ...(identified ? {} : { unboundSince: unboundSince || Date.now() }),
1425
- reason: identified ? 'current Gateway run observed; insertion custody only' : (blockerReason || 'current Gateway activity/run identity unavailable; retained without admission'),
1475
+ ...(!identified ? { unboundSince: current?.unboundSince || Date.now() } : {}),
1476
+ ...(proof ? { followupAdmissionGuard: previous || proof, followupObservedAt: observedAt } : {}),
1477
+ ...(activity.busyConfirmed ? { restrictedObservation: true } : {}),
1478
+ ...((archived || generationConflict) ? { followupBlocked: true } : {}),
1479
+ reason: identified ? 'current Gateway run observed; insertion custody only' : blockerReason,
1426
1480
  }, Date.now() + (identified ? 0 : 5000));
1481
+ if (!identified && !proof && current?.unboundSince && Date.now() - current.unboundSince >= 60_000 &&
1482
+ (archived || generationConflict || activity.busyConfirmed || current.restrictedObservation))
1483
+ this.explainUnconfirmedFollowup(chatId, { ...current, rowId, messageId });
1484
+ }
1485
+ /** An ingress reservation can outlive an ACK-window owner which finished
1486
+ * before learning its runId. It is still never-attempted custody, not a
1487
+ * licence to retarget/steer a successor. Require the same guarded proof and
1488
+ * bounded observation with NO replacement local scheduler at every await. */
1489
+ async reconcileRetainedFollowup(chatId, item) {
1490
+ if (!this.openclawClient.getSessionRuntimeStatus || !this.openclawClient.getSessionDispatchStatus ||
1491
+ this.queueOwners.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1492
+ return false;
1493
+ const source = this.store.getMessageByMessageId(item.messageId);
1494
+ if (item.state !== 'pending' || item.attempted || item.ordinaryEligible === false || !item.sessionId ||
1495
+ !source || source.id !== item.rowId || source.chatId !== chatId || source.senderType !== 'human' || source.triggerKind !== 'normal' ||
1496
+ !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
1497
+ return false;
1498
+ const admissionEpoch = this.queueAdmissionEpoch.get(chatId) || 0;
1499
+ const snapshot = this.store.getInsertion(this.config.name, chatId, item.rowId);
1500
+ const unchanged = () => {
1501
+ const row = this.store.getInsertion(this.config.name, chatId, item.rowId);
1502
+ return (this.queueAdmissionEpoch.get(chatId) || 0) === admissionEpoch && JSON.stringify(row) === JSON.stringify(snapshot) && this.stopGeneration(chatId) === item.stopEpoch && !this.queueOwners.has(chatId) && !this.store.getDispatchCustody(this.config.name, chatId) &&
1503
+ row?.state === 'pending' && !row.attempted && row.insertionId === item.insertionId && row.ownerId === item.ownerId && row.batchId === item.batchId;
1504
+ };
1505
+ if (!unchanged())
1506
+ return false;
1507
+ const status = await this.openclawClient.getSessionRuntimeStatus(item.sessionKey).catch(() => null);
1508
+ if (!unchanged())
1509
+ return true;
1510
+ const activity = await this.openclawClient.getSessionDispatchStatus?.(item.sessionKey).catch(() => null);
1511
+ if (!unchanged())
1512
+ return true;
1513
+ if (status?.archived || activity?.archived || status?.sessionId && status.sessionId !== item.sessionId || activity?.sessionId && activity.sessionId !== item.sessionId) {
1514
+ this.retainChangedFollowup(chatId, item, 'retained input session/archive changed; no migration');
1515
+ return true;
1516
+ }
1517
+ const proof = status && activity ? this.observedFollowupGuard(chatId, status, activity, item.noEffectRunId) : undefined;
1518
+ if (!proof)
1519
+ return false;
1520
+ if (item.followupAdmissionGuard && (item.followupAdmissionGuard.sessionKey !== proof.sessionKey || item.followupAdmissionGuard.sessionId !== proof.sessionId)) {
1521
+ this.retainChangedFollowup(chatId, item, 'retained followup physical identity changed; no migration');
1522
+ return true;
1523
+ }
1524
+ const since = item.followupAdmissionGuard ? item.followupObservedAt : Date.now();
1525
+ const eligible = Number.isFinite(since) && Date.now() - since >= 60_000;
1526
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, eligible ? 'ordinary' : 'pending', { ...item, followupAdmissionGuard: item.followupAdmissionGuard || proof, followupObservedAt: since, reason: 'retained never-attempted input; bounded guarded followup observation' }, Date.now() + 5000);
1527
+ if (eligible)
1528
+ void this.processQueue(chatId, true).catch(() => { });
1529
+ return true;
1427
1530
  }
1428
1531
  /** CAS includes the actual batch, not merely the queue Promise, and excludes
1429
1532
  * local admission/RPC windows even if the host briefly reports idle. */
@@ -1434,24 +1537,78 @@ export class FeishuBot {
1434
1537
  return false;
1435
1538
  if (local && (local.id !== saved.id || local.batchId !== saved.batchId || !local.admitted && !local.yielded || !local.collecting))
1436
1539
  return false;
1437
- const batch = local?.batch, runId = local?.runId, epoch = this.stopGeneration(chatId);
1540
+ const batch = local?.batch, runId = local?.runId, epoch = this.stopGeneration(chatId), admissionEpoch = this.queueAdmissionEpoch.get(chatId) || 0;
1438
1541
  const unchanged = () => {
1439
1542
  const now = this.store.getDispatchCustody(this.config.name, chatId);
1440
- return this.stopGeneration(chatId) === epoch && now?.id === saved.id && now?.batchId === saved.batchId && now?.runId === saved.runId &&
1543
+ return (this.queueAdmissionEpoch.get(chatId) || 0) === admissionEpoch && this.stopGeneration(chatId) === epoch && saved.stopEpoch === epoch && JSON.stringify(now) === JSON.stringify(saved) &&
1441
1544
  this.queueOwners.get(chatId) === local && (!local || local.batch === batch && local.runId === runId && (local.admitted === true || local.yielded === true) && local.collecting === true);
1442
1545
  };
1443
1546
  const activity = await this.openclawClient.getSessionDispatchStatus?.(this.getSessionKey(chatId)).catch(() => null);
1444
- if (!unchanged() || !activity?.idleConfirmed || activity.busyConfirmed || activity.archived || activity.sessionId !== saved.sessionId)
1547
+ if (!unchanged() || !activity || activity.archived || activity.sessionId !== saved.sessionId)
1548
+ return false;
1549
+ if (activity.activeRunId === saved.runId)
1550
+ return false; // real foreground keeps SDK priority
1551
+ if (!activity.idleConfirmed && !activity.followupAdmissionEligible)
1445
1552
  return false;
1446
1553
  const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId)).catch(() => null);
1447
- if (!unchanged() || !status?.idleConfirmed || status.archived || status.sessionId !== saved.sessionId)
1554
+ if (!unchanged() || !status || status.archived || status.sessionId !== saved.sessionId)
1448
1555
  return false;
1449
- if (!this.store.releaseDispatchCustody(this.config.name, chatId, saved.id, saved.batchId))
1556
+ if (activity.activeRunId && activity.activeRunId !== saved.runId) {
1557
+ // An accepted local followup can itself be queued behind a different
1558
+ // public foreground root. Do not steer a new input into that queued ID.
1559
+ // Rebind ONLY same-generation never-attempted custody to the genuinely
1560
+ // observed foreground for SDK priority; accepted/unknown inputs stay put.
1561
+ for (const item of this.store.listInsertions(this.config.name, chatId)) {
1562
+ if (item.state !== 'pending' || item.attempted || item.noEffectRunId || item.ordinaryEligible === false || item.ownerId !== saved.id || item.batchId !== saved.batchId ||
1563
+ item.stopEpoch !== epoch || item.sessionId !== saved.sessionId || !item.sessionKey ||
1564
+ canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
1565
+ continue;
1566
+ const source = this.store.getMessageByMessageId(item.messageId);
1567
+ if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
1568
+ continue;
1569
+ const earlier = item.followupAdmissionGuard;
1570
+ this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'pending', {
1571
+ ...item, expectedRunId: activity.activeRunId, ownerId: `external:${activity.activeRunId}`, batchId: activity.activeRunId,
1572
+ noEffectRunId: undefined, followupAdmissionGuard: earlier ? {
1573
+ sessionKey: earlier.sessionKey, sessionId: earlier.sessionId, expectedLeafEntryId: earlier.expectedLeafEntryId,
1574
+ } : undefined,
1575
+ reason: 'public foreground observed; never-attempted input retains SDK priority; queued original result kept',
1576
+ });
1577
+ }
1578
+ if (!this.store.clearDispatchCustody(this.config.name, chatId, saved.id, saved.batchId))
1579
+ return false;
1580
+ const target = this.store.getDeliveryRunTarget(this.config.name, chatId, saved.runId);
1581
+ if (target)
1582
+ this.store.bindDeliveryRun(this.config.name, chatId, saved.runId, { ...target, dispatchReleased: true });
1583
+ if (local)
1584
+ this.forceExpireQueueOwner(chatId, 'public foreground differs from locally queued original; exact result custody retained');
1585
+ return true;
1586
+ }
1587
+ const idle = activity.idleConfirmed && !activity.busyConfirmed && status.idleConfirmed;
1588
+ let followup;
1589
+ if (!idle || saved.followupAdmissionGuard) {
1590
+ const proof = this.observedFollowupGuard(chatId, status, activity);
1591
+ if (!proof || !saved.sessionKey || canonicalPhysicalSessionKey(saved.sessionKey) !== proof.sessionKey)
1592
+ return false;
1593
+ const previous = saved.followupAdmissionGuard;
1594
+ if (previous && (previous.sessionKey !== proof.sessionKey || previous.sessionId !== proof.sessionId))
1595
+ return false;
1596
+ this.pinDispatchFollowupProof(chatId, saved, proof);
1597
+ followup = previous || proof; // host CAS distinguishes ancestor from off-path
1598
+ if (!previous) {
1599
+ if (unchanged())
1600
+ this.store.saveDispatchCustody(this.config.name, chatId, { ...saved, followupAdmissionGuard: followup, followupObservedAt: Date.now() });
1601
+ return false;
1602
+ }
1603
+ if (!Number.isFinite(saved.followupObservedAt) || Date.now() - saved.followupObservedAt < 60_000)
1604
+ return false;
1605
+ }
1606
+ if (!unchanged() || !this.store.releaseDispatchCustody(this.config.name, chatId, saved.id, saved.batchId, followup))
1450
1607
  return false;
1451
1608
  // The target was committed on admission and survives this local cleanup.
1452
1609
  // Only this exact old owner can be revoked; never a replacement batch.
1453
1610
  if (local)
1454
- this.forceExpireQueueOwner(chatId, 'current session confirmed schedulable; exact result observation retained');
1611
+ this.forceExpireQueueOwner(chatId, followup ? 'guarded followup schedulable (NOT idle); exact result observation retained' : 'current session confirmed schedulable; exact result observation retained');
1455
1612
  return true;
1456
1613
  }
1457
1614
  presentation(work) {
@@ -1545,6 +1702,11 @@ export class FeishuBot {
1545
1702
  for (const item of this.store.listInsertions(this.config.name, chatId)) {
1546
1703
  if (item.nextCheckAt > Date.now())
1547
1704
  continue;
1705
+ // This is an ordinary chat.send identity, NOT an SDK insertion. The
1706
+ // client's exact-run recovery queries its original ID; never steer or
1707
+ // resend it with an insertion/new submission identity on restart.
1708
+ if (item.admissionSubmissionId)
1709
+ continue;
1548
1710
  const stillCurrent = () => this.stopGeneration(chatId) === item.stopEpoch && !['stopped', 'ordinary', 'committed'].includes(this.store.getInsertion(this.config.name, chatId, item.rowId)?.state);
1549
1711
  if (!stillCurrent()) {
1550
1712
  if (this.stopGeneration(chatId) !== item.stopEpoch)
@@ -1563,6 +1725,8 @@ export class FeishuBot {
1563
1725
  continue;
1564
1726
  Object.assign(item, refreshed);
1565
1727
  }
1728
+ if (await this.reconcileRetainedFollowup(chatId, item))
1729
+ continue;
1566
1730
  const owner = this.queueOwners.get(chatId);
1567
1731
  if (!item.expectedRunId && owner?.id === item.ownerId && owner?.batchId === item.batchId && owner?.runId)
1568
1732
  item.expectedRunId = owner.runId;
@@ -1628,6 +1792,8 @@ export class FeishuBot {
1628
1792
  // Time elapsed and attempted-unknown receipts never authorize replay.
1629
1793
  if (valid && receipt.attempted === false && receipt.ordinaryEligible !== false && ['pending', 'target_ended'].includes(state) &&
1630
1794
  ['target_not_ready', 'backend_rejected_no_effect', 'original run ended without an insertion attempt'].includes(receipt.reason || '')) {
1795
+ item.noEffectRunId = item.expectedRunId;
1796
+ item.noEffectSince = item.noEffectSince || Date.now();
1631
1797
  const status = await this.openclawClient.getSessionRuntimeStatus(item.sessionKey).catch(() => null);
1632
1798
  if (!stillCurrent())
1633
1799
  continue;
@@ -1642,22 +1808,38 @@ export class FeishuBot {
1642
1808
  if (runAtCheck === item.expectedRunId)
1643
1809
  this.forceExpireQueueOwner(chatId, 'confirmed idle exact batch; insertion never accepted/unknown');
1644
1810
  }
1645
- else if (state === 'target_ended')
1646
- state = 'pending';
1647
- else if (state === 'pending') {
1648
- // Safe recovery for a NON-INSERTABLE target (audit P1 #2
1649
- // 2026-10-17): this branch only runs for EXPLICIT no-effect
1650
- // receipts (attempted===false, definitively not inserted — the
1651
- // outer guard). If the exact target run has settled, or the
1652
- // target has refused insertion for over 60s (e.g. a long-lived
1653
- // maintenance run), hand the input to the ordinary queue: the
1654
- // gateway queues it behind whatever currently holds the session
1655
- // instead of parking it in bridge-side limbo forever. Unknown
1656
- // receipts never enter this branch and are never replayed.
1657
- const boundSince = Number.isFinite(item.boundSince) ? item.boundSince : Date.now();
1658
- item.boundSince = boundSince;
1659
- if (originalRunEnded || Date.now() - boundSince > 60_000)
1660
- state = 'ordinary';
1811
+ else {
1812
+ // A definitive SDK no-effect receipt is replay authority ONLY with
1813
+ // fresh matching public followup proof + local owner/batch CAS.
1814
+ // Root end or elapsed time alone does not prove safe admission.
1815
+ const live = activity || await this.openclawClient.getSessionDispatchStatus?.(item.sessionKey).catch(() => null);
1816
+ if (!stillCurrent())
1817
+ continue;
1818
+ const source = this.store.getMessageByMessageId(item.messageId);
1819
+ const proof = live && status && source && source.id === item.rowId && source.chatId === chatId && source.senderType === 'human' && source.triggerKind === 'normal'
1820
+ ? this.observedFollowupGuard(chatId, status, live, item.noEffectRunId) : undefined;
1821
+ const sameOwner = this.queueOwners.get(chatId) === owner && owner?.batch === batchAtCheck && owner?.runId === runAtCheck;
1822
+ if (proof && proof.sessionId === item.sessionId && sameOwner) {
1823
+ const previous = item.followupAdmissionGuard;
1824
+ if (previous && (previous.sessionKey !== proof.sessionKey || previous.sessionId !== proof.sessionId))
1825
+ state = 'target_changed';
1826
+ else {
1827
+ item.followupAdmissionGuard = previous ? { ...previous, nonInjectableRunId: item.noEffectRunId } : proof;
1828
+ item.followupObservedAt = previous ? item.followupObservedAt : Date.now();
1829
+ if (previous && Number.isFinite(item.followupObservedAt) && Date.now() - item.followupObservedAt >= 60_000) {
1830
+ state = 'ordinary';
1831
+ if (owner?.runId === item.expectedRunId)
1832
+ this.forceExpireQueueOwner(chatId, 'definitive no-effect input; guarded queued followup, result custody retained');
1833
+ }
1834
+ else
1835
+ state = 'pending';
1836
+ }
1837
+ }
1838
+ else {
1839
+ state = 'pending';
1840
+ if (Date.now() - item.noEffectSince >= 60_000)
1841
+ this.explainUnconfirmedFollowup(chatId, item);
1842
+ }
1661
1843
  }
1662
1844
  }
1663
1845
  if (state === 'target_ended')
@@ -1669,9 +1851,12 @@ export class FeishuBot {
1669
1851
  // every insertion spams the chat. Use real elapsed time from the first
1670
1852
  // unknown observation, not query count, and commit one reminder intent.
1671
1853
  const unknownChecks = state === 'unknown' ? (Number(item.unknownChecks) || 0) + 1 : 0;
1672
- const record = { ...item, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks,
1854
+ const record = { ...item, pendingSince: state === 'pending' ? (item.pendingSince || Date.now()) : undefined,
1855
+ ordinaryEligible: receipt.ordinaryEligible, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks,
1673
1856
  unknownSince: state === 'unknown' ? (item.state === 'unknown' && Number.isFinite(item.unknownSince) ? item.unknownSince : Date.now()) : undefined };
1674
1857
  this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, state, record, Date.now() + (state === 'unknown' ? 15_000 : 5000));
1858
+ if (state === 'pending' && !record.attempted && Date.now() - (record.pendingSince || Date.now()) >= 60_000)
1859
+ this.explainUnconfirmedFollowup(chatId, record);
1675
1860
  if (state === 'ordinary') {
1676
1861
  this.store.deletePendingInsertionNotices(this.config.name, chatId, item.insertionId);
1677
1862
  void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:ordinary`, 'ℹ️ 当前会话已确认可接收新轮次,且这条消息确定未被插入;现已交回普通队列,只执行一次。', [], item.messageId, `insertion:${item.insertionId}:ordinary`).catch(() => { });
@@ -1888,17 +2073,18 @@ export class FeishuBot {
1888
2073
  this.queueRunTokens.delete(chatId);
1889
2074
  if (this.queueOwners.get(chatId) === owner)
1890
2075
  this.queueOwners.delete(chatId);
2076
+ if (owner.continuePending)
2077
+ void this.processQueue(chatId, true).catch(() => { });
1891
2078
  }
1892
2079
  });
1893
2080
  this.queueRuns.set(chatId, run);
1894
2081
  return run;
1895
2082
  }
1896
2083
  /**
1897
- * Force-release a stuck queue owner after the gateway authoritatively
1898
- * reported the session idle (no active run). The zombie owner's deep waits
1899
- * can otherwise hold chat custody forever: completion events routed through
1900
- * the proactive path are invisible to them. Steer-fallback triggers are
1901
- * restored via the failure observers so their input drains normally.
2084
+ * Retire only the local queue scheduler after positive idle, trusted yield,
2085
+ * or bounded guarded-followup proof. Never stop Gateway execution or infer
2086
+ * a terminal outcome. Exact result recovery/target custody remains durable;
2087
+ * attempted-unknown and accepted insertion records never enter replay.
1902
2088
  */
1903
2089
  forceExpireQueueOwner(chatId, reason, preserveDispatch = false) {
1904
2090
  const owner = this.queueRuns.get(chatId);
@@ -1910,7 +2096,7 @@ export class FeishuBot {
1910
2096
  if (context)
1911
2097
  context.revoked = true;
1912
2098
  this.busyChats.set(chatId, 0);
1913
- console.warn(`[${this.config.name}] force-expiring zombie queue owner for ${chatId.slice(-8)}: ${reason}`);
2099
+ console.warn(`[${this.config.name}] retiring local queue scheduler for ${chatId.slice(-8)}: ${reason}`);
1914
2100
  // Fence the stale batch's local observation BEFORE releasing custody: an
1915
2101
  // alive old poller would keep destructively consuming the replacement
1916
2102
  // batch's frames from the shared session bucket (2026-10-16 regression).
@@ -1934,7 +2120,10 @@ export class FeishuBot {
1934
2120
  while (current()) {
1935
2121
  const batchToken = {};
1936
2122
  owner.batch = batchToken;
2123
+ const admissionEpoch = (this.queueAdmissionEpoch.get(chatId) || 0) + 1;
2124
+ this.queueAdmissionEpoch.set(chatId, admissionEpoch);
1937
2125
  owner.batchId = randomUUID();
2126
+ const batchId = owner.batchId;
1938
2127
  owner.triggerIds = undefined;
1939
2128
  owner.runId = undefined;
1940
2129
  owner.admitted = false;
@@ -1968,6 +2157,7 @@ export class FeishuBot {
1968
2157
  // pass-through requests that must not receive catch-up context.
1969
2158
  const firstPending = pendingHumanTriggers[0];
1970
2159
  const firstIsNative = firstPending.triggerKind === "native_command";
2160
+ const batchProof = this.store.getInsertion(this.config.name, chatId, firstPending.id)?.followupAdmissionGuard;
1971
2161
  let mergedTriggers;
1972
2162
  if (firstIsNative) {
1973
2163
  // One native command at a time.
@@ -1979,9 +2169,27 @@ export class FeishuBot {
1979
2169
  for (const m of pendingHumanTriggers) {
1980
2170
  if (m.triggerKind === "native_command")
1981
2171
  break;
2172
+ const proof = this.store.getInsertion(this.config.name, chatId, m.id)?.followupAdmissionGuard;
2173
+ if (JSON.stringify(proof) !== JSON.stringify(batchProof))
2174
+ break;
1982
2175
  mergedTriggers.push(m);
1983
2176
  }
1984
2177
  }
2178
+ if (!firstIsNative && batchProof) {
2179
+ // Guarded, not-yet-submitted inputs must not be dropped to meet a
2180
+ // payload budget. Submit a bounded prefix and leave the remainder in
2181
+ // durable pending custody for the next batch (with its own proof).
2182
+ const selected = [];
2183
+ let bytes = 0;
2184
+ for (const message of mergedTriggers) {
2185
+ const addBytes = Buffer.byteLength((selected.length ? "\n" : "") + message.content, "utf8");
2186
+ if (selected.length > 0 && (selected.length >= MAX_MERGED_TRIGGER_MESSAGES || bytes + addBytes > MAX_MERGED_TRIGGER_BYTES))
2187
+ break;
2188
+ selected.push(message);
2189
+ bytes += addBytes;
2190
+ }
2191
+ mergedTriggers = selected;
2192
+ }
1985
2193
  // Bound the merged current message payload. When a bot is stuck, many
1986
2194
  // human triggers can accumulate; sending all of them can exceed OpenClaw's
1987
2195
  // single-message/payload limit. Keep the most recent messages and drop the
@@ -1989,7 +2197,7 @@ export class FeishuBot {
1989
2197
  // not clog the queue forever).
1990
2198
  const originalMergedTriggers = mergedTriggers;
1991
2199
  const droppedMergedTriggers = [];
1992
- if (!firstIsNative) {
2200
+ if (!firstIsNative && !batchProof) {
1993
2201
  const selected = [];
1994
2202
  let bytes = 0;
1995
2203
  for (let i = originalMergedTriggers.length - 1; i >= 0; i--) {
@@ -2112,6 +2320,14 @@ export class FeishuBot {
2112
2320
  if (!current())
2113
2321
  break;
2114
2322
  await liveStatus?.pauseForUnconfirmedResult(this.isEn(chatId) ? "Session setup failed before admission" : "会话准备失败,尚未提交 Gateway", this.isEn(chatId) ? `${this.config.name} setup unavailable` : `${this.config.name} 会话准备未完成`).catch(() => { });
2323
+ if (batchProof) {
2324
+ for (const id of mergedTriggerIds) {
2325
+ const item = this.store.getInsertion(this.config.name, chatId, id);
2326
+ if (item?.state === 'ordinary' && !item.attempted)
2327
+ this.store.saveInsertion(this.config.name, chatId, id, item.insertionId, 'target_changed', { ...item, reason: 'guarded followup session setup failed before effect; input retained' });
2328
+ }
2329
+ break;
2330
+ }
2115
2331
  // Model policy/capability failures happen before chat.send. Clear the
2116
2332
  // busy marker, report the actionable error, and retire only this batch
2117
2333
  // so the chat cannot remain wedged indefinitely.
@@ -2135,6 +2351,7 @@ export class FeishuBot {
2135
2351
  let initialPresentationRunId;
2136
2352
  const foregroundSessionId = this.store.getActiveGoJob(this.config.name, chatId)?.executorSessionId;
2137
2353
  let runAcceptedByOpenClaw = false;
2354
+ let guardedSubmissionId;
2138
2355
  try {
2139
2356
  const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
2140
2357
  let reply;
@@ -2175,6 +2392,22 @@ export class FeishuBot {
2175
2392
  timeoutMs: FOREGROUND_WAIT_MS,
2176
2393
  includeContext: !isNativeCommandTrigger,
2177
2394
  includeBridgeAttachmentHint: !isNativeCommandTrigger,
2395
+ followupAdmissionGuard: batchProof,
2396
+ admissionGuard: () => {
2397
+ guard();
2398
+ if (owner.batch !== batchToken || owner.batchId !== batchId || this.queueAdmissionEpoch.get(chatId) !== admissionEpoch)
2399
+ throw new SupersededObservation('batch admission epoch changed');
2400
+ if (!batchProof)
2401
+ return;
2402
+ for (const id of mergedTriggerIds) {
2403
+ const row = this.store.getInsertion(this.config.name, chatId, id);
2404
+ if (!row || row.ordinaryEligible === false || row.stopEpoch !== owner.stopEpoch || JSON.stringify(row.followupAdmissionGuard) !== JSON.stringify(batchProof) ||
2405
+ !['ordinary', 'unknown'].includes(row.state) ||
2406
+ batchProof.nonInjectableRunId && row.noEffectRunId !== batchProof.nonInjectableRunId ||
2407
+ (guardedSubmissionId ? row.admissionSubmissionId !== guardedSubmissionId : row.attempted === true))
2408
+ throw new SupersededObservation('followup batch/admission CAS changed');
2409
+ }
2410
+ },
2178
2411
  onSendAttempt: (submissionId) => {
2179
2412
  guard();
2180
2413
  this.presentation(() => this.queuedPresentations.attempted(liveStatus, submissionId, owner.id, owner.batchId));
@@ -2187,7 +2420,15 @@ export class FeishuBot {
2187
2420
  // At-most-once: as soon as we begin issuing chat.send, do not let
2188
2421
  // this batch auto-replay. If the RPC response is lost after
2189
2422
  // OpenClaw accepts it, retrying would duplicate the same message.
2190
- markSubmittedBatch("send-attempt");
2423
+ if (batchProof) {
2424
+ guardedSubmissionId = submissionId;
2425
+ for (const id of mergedTriggerIds) {
2426
+ const row = this.store.getInsertion(this.config.name, chatId, id);
2427
+ this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'unknown', { ...row, attempted: true, admissionSubmissionId: submissionId, reason: 'original guarded followup submission only; no new-ID replay' });
2428
+ }
2429
+ }
2430
+ else
2431
+ markSubmittedBatch("send-attempt");
2191
2432
  },
2192
2433
  onRunIdentified: (runId, identity) => { guard(); owner.runId = runId; owner.sessionId = identity?.sessionId || owner.sessionId; foregroundRunId = runId; },
2193
2434
  onSubmitted: async (runId) => {
@@ -2218,6 +2459,10 @@ export class FeishuBot {
2218
2459
  // Refine state for logs/idempotent sync rows when OpenClaw returns
2219
2460
  // a runId. markSubmittedBatch is idempotent due to local flags and
2220
2461
  // INSERT OR IGNORE in message_sync.
2462
+ if (batchProof)
2463
+ this.store.acceptGuardedFollowup(this.config.name, chatId, mergedTriggerIds, guardedSubmissionId, runId, `${this.config.name}:${chatId}:${triggerId}:guarded-accepted:${runId}`);
2464
+ // The guarded inputs must already be ordinary before this helper's
2465
+ // ordinary-only sync filter runs, or a later batch replays them as context.
2221
2466
  markSubmittedBatch(`submitted:${runId}`);
2222
2467
  },
2223
2468
  onProgress: (event) => current() && runAcceptedByOpenClaw && !!initialPresentationRunId && owner.runId === initialPresentationRunId ? liveStatus?.progress(event) : undefined,
@@ -2471,6 +2716,20 @@ export class FeishuBot {
2471
2716
  console.log(`[${this.config.name}] batch observation superseded for ${chatId.slice(-8)}; ending quietly`);
2472
2717
  break;
2473
2718
  }
2719
+ if (err instanceof FollowupAdmissionRefused && batchProof) {
2720
+ // onSendAttempt is only an uncertainty fence, not acceptance/sync.
2721
+ // Named pre-effect refusal parks ONLY this new batch, visibly. Never
2722
+ // restore accepted originals, and never migrate to a different leaf.
2723
+ for (const id of mergedTriggerIds) {
2724
+ const row = this.store.getInsertion(this.config.name, chatId, id);
2725
+ if (row && !row.admittedRunId)
2726
+ this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'target_changed', { ...row, attempted: false, reason: err.message });
2727
+ }
2728
+ await this.enqueueAndDispatchDelivery(chatId, 'admission_status', `admission:${err.submissionId}:refused`, err.message, [], lastHuman.messageId, `admission:${err.submissionId}:refused`).catch(() => { });
2729
+ await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
2730
+ owner.continuePending = true; // independently proved newer cohorts may still be sent
2731
+ break;
2732
+ }
2474
2733
  if (err instanceof AdmissionUnconfirmed) {
2475
2734
  await this.enqueueAndDispatchDelivery(chatId, 'admission_status', `admission:${err.submissionId}:unknown`, err.message, [], lastHuman.messageId, `admission:${err.submissionId}:unknown`).catch(() => { });
2476
2735
  await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
@@ -2536,6 +2795,15 @@ export class FeishuBot {
2536
2795
  console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
2537
2796
  break;
2538
2797
  }
2798
+ if (batchProof && !runAcceptedByOpenClaw) {
2799
+ for (const id of mergedTriggerIds) {
2800
+ const row = this.store.getInsertion(this.config.name, chatId, id);
2801
+ if (row && !row.admissionSubmissionId && !row.admittedRunId)
2802
+ this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'target_changed', { ...row, attempted: false, reason: 'guarded pre-admission failure; input retained without sync or new-ID retry' });
2803
+ }
2804
+ await liveStatus?.pauseForUnconfirmedResult('Guarded admission unavailable; input custody retained without replay').catch(() => { });
2805
+ break;
2806
+ }
2539
2807
  console.error(`[${this.config.name}] processQueue error:`, safeErrorSummary(err));
2540
2808
  this.fireRunFailureObservers(chatId);
2541
2809
  const errorText = this.formatUserVisibleError(err, chatId);
@@ -126,9 +126,16 @@ export declare class MessageStore {
126
126
  saveDispatchCustody(bot: string, chat: string, record: object): void;
127
127
  getDispatchCustody(bot: string, chat: string): any;
128
128
  clearDispatchCustody(bot: string, chat: string, ownerId: string, batchId: string): boolean;
129
+ /** A Gateway-accepted guarded ordinary batch becomes ordinary/synced in one
130
+ * transaction. Until then its unknown input custody remains non-replayable. */
131
+ acceptGuardedFollowup(bot: string, chat: string, rows: number[], submissionId: string, runId: string, syncBatchId: string): void;
129
132
  /** Atomic release of proven never-attempted reservations only. Unknown and
130
133
  * accepted inputs retain their exact target and never enter ordinary drain. */
131
- releaseDispatchCustody(bot: string, chat: string, ownerId: string, batchId: string): boolean;
134
+ releaseDispatchCustody(bot: string, chat: string, ownerId: string, batchId: string, followup?: {
135
+ sessionKey: string;
136
+ sessionId: string;
137
+ expectedLeafEntryId: string;
138
+ }): boolean;
132
139
  /** Reserved is persisted before the send-attempt callback; dispatching is
133
140
  * persisted before the RPC. Only reserved + unchanged stop generation proves
134
141
  * no business effect and may be restored on startup. Unknown never replays. */
@@ -111,9 +111,28 @@ export class MessageStore {
111
111
  return this.db.prepare(`DELETE FROM dispatch_custody WHERE bot_name=? AND chat_id=?
112
112
  AND json_extract(record_json,'$.id')=? AND json_extract(record_json,'$.batchId')=?`).run(bot, chat, ownerId, batchId).changes === 1;
113
113
  }
114
+ /** A Gateway-accepted guarded ordinary batch becomes ordinary/synced in one
115
+ * transaction. Until then its unknown input custody remains non-replayable. */
116
+ acceptGuardedFollowup(bot, chat, rows, submissionId, runId, syncBatchId) {
117
+ if (!submissionId || !runId || rows.length === 0)
118
+ throw new Error('Missing guarded followup acceptance identity');
119
+ this.db.transaction(() => {
120
+ for (const id of rows) {
121
+ const item = this.getInsertion(bot, chat, id);
122
+ if (!item || item.admissionSubmissionId !== submissionId || item.attempted !== true ||
123
+ !['unknown', 'ordinary'].includes(item.state) || item.stopEpoch !== this.getStopGeneration(bot, chat)) {
124
+ throw new Error('Guarded followup acceptance custody changed');
125
+ }
126
+ this.saveInsertion(bot, chat, id, item.insertionId, 'ordinary', { ...item, attempted: true, admittedRunId: runId });
127
+ }
128
+ this.markMessagesSynced(bot, chat, rows, syncBatchId);
129
+ for (const id of rows)
130
+ this.clearPendingTrigger(bot, chat, id);
131
+ })();
132
+ }
114
133
  /** Atomic release of proven never-attempted reservations only. Unknown and
115
134
  * accepted inputs retain their exact target and never enter ordinary drain. */
116
- releaseDispatchCustody(bot, chat, ownerId, batchId) {
135
+ releaseDispatchCustody(bot, chat, ownerId, batchId, followup) {
117
136
  return this.db.transaction(() => {
118
137
  const custody = this.getDispatchCustody(bot, chat);
119
138
  if (!custody || !this.clearDispatchCustody(bot, chat, ownerId, batchId))
@@ -122,11 +141,17 @@ export class MessageStore {
122
141
  '$.dispatchOwnerId',?,'$.dispatchBatchId',?,'$.sessionId',?) WHERE bot_name=? AND chat_id=? AND run_id=?`)
123
142
  .run(ownerId, batchId, custody.sessionId, bot, chat, custody.runId);
124
143
  this.db.prepare(`UPDATE insertion_custody SET state='ordinary',next_check_at=0,
125
- record_json=json_set(record_json,'$.reason','current session confirmed schedulable; no backend attempt')
144
+ record_json=json_set(record_json,'$.reason','current session confirmed schedulable; no backend attempt'
145
+ ${followup ? ", '$.followupAdmissionGuard',json(COALESCE(json_extract(record_json,'$.followupAdmissionGuard'),?))" : ''})
126
146
  WHERE bot_name=? AND chat_id=? AND state='pending'
127
147
  AND COALESCE(json_extract(record_json,'$.attempted'),0)=0
128
148
  AND json_extract(record_json,'$.ownerId')=? AND json_extract(record_json,'$.batchId')=?
129
- AND json_extract(record_json,'$.stopEpoch')=?`).run(bot, chat, ownerId, batchId, this.getStopGeneration(bot, chat));
149
+ AND json_extract(record_json,'$.stopEpoch')=?
150
+ ${followup ? `AND json_extract(record_json,'$.sessionId')=?
151
+ AND COALESCE(json_extract(record_json,'$.ordinaryEligible'),1)!=0
152
+ AND json_extract(record_json,'$.sessionKey') IN (?,?)
153
+ AND message_row_id IN (SELECT id FROM messages WHERE sender_type='human' AND trigger_kind='normal')` : ''}`)
154
+ .run(...(followup ? [JSON.stringify(followup)] : []), bot, chat, ownerId, batchId, this.getStopGeneration(bot, chat), ...(followup ? [followup.sessionId, followup.sessionKey, followup.sessionKey.replace(/^agent:[^:]+:/, '')] : []));
130
155
  return true;
131
156
  })();
132
157
  }
@@ -276,10 +301,10 @@ export class MessageStore {
276
301
  }
277
302
  stopInsertionAtEpoch(bot, chat, row, epoch) {
278
303
  this.db.prepare(`UPDATE insertion_custody SET state='stopped' WHERE bot_name=? AND chat_id=? AND message_row_id=?
279
- AND json_extract(record_json,'$.stopEpoch')=? AND state NOT IN ('committed','ordinary')`).run(bot, chat, row, epoch);
304
+ AND json_extract(record_json,'$.stopEpoch')=? AND (state NOT IN ('committed','ordinary') OR state='ordinary' AND json_type(record_json,'$.followupAdmissionGuard')='object')`).run(bot, chat, row, epoch);
280
305
  }
281
306
  stopInsertions(bot, chat) {
282
- this.db.prepare("UPDATE insertion_custody SET state='stopped' WHERE bot_name=? AND chat_id=? AND state NOT IN ('committed','ordinary')").run(bot, chat);
307
+ 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);
283
308
  }
284
309
  hasUnknownDeliveryOperation(outbox) {
285
310
  return !!this.db.prepare(`SELECT 1 FROM delivery_operations o WHERE outbox_id=? AND state IN ('attempted','unknown')
@@ -39,6 +39,31 @@ export declare class AdmissionUnconfirmed extends Error {
39
39
  readonly submissionId: string;
40
40
  constructor(submissionId: string, cause?: unknown);
41
41
  }
42
+ /** A genuinely observed physical session + nonempty transcript leaf. The
43
+ * public non-steer chat.send branch CAS uses BOTH; sessionId alone is not CAS. */
44
+ export type FollowupAdmissionGuard = {
45
+ sessionKey: string;
46
+ sessionId: string;
47
+ expectedLeafEntryId: string; /** Prior verified SDK no-effect target, NOT a current receiver identity. */
48
+ nonInjectableRunId?: string;
49
+ };
50
+ export declare class FollowupAdmissionRefused extends Error {
51
+ readonly reason: string;
52
+ readonly submissionId: string;
53
+ constructor(reason: string, submissionId: string);
54
+ }
55
+ export type SessionDispatchStatus = {
56
+ idleConfirmed: boolean;
57
+ sessionId?: string;
58
+ archived?: boolean;
59
+ activeRunId?: string;
60
+ busyConfirmed?: boolean;
61
+ /** Explicit incompatible identity is not a genuinely unknown observation. */
62
+ identityConflict?: boolean;
63
+ /** Independent admission proof, NOT an assertion that aggregate work ended. */
64
+ followupAdmissionEligible?: boolean;
65
+ expectedLeafEntryId?: string;
66
+ };
42
67
  export type ChatAttachment = {
43
68
  type?: string;
44
69
  mimeType?: string;
@@ -292,15 +317,10 @@ export declare class OpenClawClient {
292
317
  revokeSessionCollectors(sessionKey: string, reason: string): number;
293
318
  waitForSessionStop(sessionKey: string): Promise<void>;
294
319
  getSessionRuntimeStatus(sessionKey: string): Promise<SessionRuntimeStatus>;
295
- /** Strong public current-session activity, independent of expired root
296
- * terminal caches. Unknown/missing fields never authorize scheduling. */
297
- getSessionDispatchStatus(sessionKey: string): Promise<{
298
- idleConfirmed: boolean;
299
- sessionId?: string;
300
- archived?: boolean;
301
- activeRunId?: string;
302
- busyConfirmed?: boolean;
303
- }>;
320
+ /** Execution-idle proof remains a complete pending scan. Independently,
321
+ * receiving a serialized followup does not require idle or empty pending
322
+ * work. A positive admission proof never turns an incomplete scan idle. */
323
+ getSessionDispatchStatus(sessionKey: string): Promise<SessionDispatchStatus>;
304
324
  private classifyWaitTimeout;
305
325
  getSessionInfo(sessionKey: string, timeoutMs?: number): Promise<any>;
306
326
  /**
@@ -464,6 +484,10 @@ export declare class OpenClawClient {
464
484
  /** Consecutive effective-activity silence after admission/collector start; default 10 minutes, not total run time. */
465
485
  timeoutMs?: number;
466
486
  emptyFinalAsNoReply?: boolean;
487
+ /** Restricted busy/no-target admission. No archive restore or unguarded retry. */
488
+ followupAdmissionGuard?: FollowupAdmissionGuard;
489
+ /** Local stop/owner/batch/admission CAS, checked around every async boundary. */
490
+ admissionGuard?: () => void;
467
491
  /** Called immediately before issuing chat.send RPC; use for at-most-once bookkeeping. */
468
492
  onSendAttempt?: (submissionId: string) => void | Promise<void>;
469
493
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
@@ -506,6 +530,10 @@ export declare class OpenClawClient {
506
530
  includeContext?: boolean;
507
531
  /** Disable bridge attachment hints for native commands and other exact pass-through messages. */
508
532
  includeBridgeAttachmentHint?: boolean;
533
+ /** Restricted busy/no-target admission. No archive restore or unguarded retry. */
534
+ followupAdmissionGuard?: FollowupAdmissionGuard;
535
+ /** Local stop/owner/batch/admission CAS, checked around every async boundary. */
536
+ admissionGuard?: () => void;
509
537
  /** Called immediately before issuing chat.send RPC; use for at-most-once bookkeeping. */
510
538
  onSendAttempt?: (submissionId: string) => void | Promise<void>;
511
539
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
@@ -37,6 +37,16 @@ export class AdmissionUnconfirmed extends Error {
37
37
  this.name = 'AdmissionUnconfirmed';
38
38
  }
39
39
  }
40
+ export class FollowupAdmissionRefused extends Error {
41
+ reason;
42
+ submissionId;
43
+ constructor(reason, submissionId) {
44
+ super(`Followup admission refused before effect; input retained: ${reason}`);
45
+ this.reason = reason;
46
+ this.submissionId = submissionId;
47
+ this.name = 'FollowupAdmissionRefused';
48
+ }
49
+ }
40
50
  export const GATEWAY_PROTOCOL_MIN = 4;
41
51
  export const GATEWAY_PROTOCOL_MAX = 4;
42
52
  const REQUIRED_GATEWAY_METHODS = [
@@ -2259,11 +2269,19 @@ export class OpenClawClient {
2259
2269
  clearTimeout(timer);
2260
2270
  }
2261
2271
  }
2262
- /** Strong public current-session activity, independent of expired root
2263
- * terminal caches. Unknown/missing fields never authorize scheduling. */
2272
+ /** Execution-idle proof remains a complete pending scan. Independently,
2273
+ * receiving a serialized followup does not require idle or empty pending
2274
+ * work. A positive admission proof never turns an incomplete scan idle. */
2264
2275
  async getSessionDispatchStatus(sessionKey) {
2265
2276
  let timer;
2266
2277
  const key = this.canonicalSessionKey(sessionKey), deadline = Date.now() + 8000;
2278
+ let admissionProof = {};
2279
+ const admissionFrom = (history) => {
2280
+ const info = history?.sessionInfo, run = history?.inFlightRun;
2281
+ const knownTarget = run === undefined || run === null || typeof run?.runId === 'string' && !!run.runId.trim();
2282
+ return info?.archived === false && knownTarget && typeof info?.activeLeafEntryId === 'string' && !!info.activeLeafEntryId.trim()
2283
+ ? { followupAdmissionEligible: true, expectedLeafEntryId: info.activeLeafEntryId } : {};
2284
+ };
2267
2285
  // One budget covers the entire public pending scan, not 1500ms per page.
2268
2286
  // Retained interrupted/cancelled inputs are recovery records, NOT runnable
2269
2287
  // queue entries. Never delete, mutate or replay them to prove schedulability.
@@ -2274,11 +2292,16 @@ export class OpenClawClient {
2274
2292
  const unknown = () => ({ idleConfirmed: false, sessionId });
2275
2293
  const validId = (value) => value === undefined || typeof value === 'string' && !!value.trim();
2276
2294
  for (let page = 0; page < 32; page++) {
2295
+ admissionProof = {};
2277
2296
  const history = await this.rpc('chat.history', { sessionKey: key, limit: 20, ...(before ? { pendingBefore: before } : {}) }, 1500);
2278
2297
  if (Date.now() >= deadline)
2279
2298
  return unknown();
2280
2299
  const info = history?.sessionInfo;
2281
2300
  const id = info?.sessionId || history?.sessionId;
2301
+ if (typeof history?.sessionKey === 'string' && !!history.sessionKey && history.sessionKey !== key ||
2302
+ typeof info?.sessionId === 'string' && !!info.sessionId && typeof history?.sessionId === 'string' && !!history.sessionId && info.sessionId !== history.sessionId ||
2303
+ sessionId && typeof id === 'string' && !!id && sessionId !== id)
2304
+ return { ...unknown(), identityConflict: true };
2282
2305
  if (!validId(info?.sessionId) || !validId(history?.sessionId) || history?.sessionKey !== key || typeof id !== 'string' || !id.trim() ||
2283
2306
  info?.sessionId && history?.sessionId && info.sessionId !== history.sessionId || sessionId && sessionId !== id)
2284
2307
  return unknown();
@@ -2286,10 +2309,13 @@ export class OpenClawClient {
2286
2309
  const base = { sessionId, archived: info?.archived === true };
2287
2310
  if (base.archived)
2288
2311
  return { ...base, idleConfirmed: false };
2312
+ admissionProof = admissionFrom(history);
2313
+ const pending = history?.pendingInputs;
2314
+ // Existing queued/interrupted/hidden inputs are not a receiving veto.
2315
+ // Core owns FIFO/cap/drop. Below, idle still requires complete evidence.
2289
2316
  if (info?.hasActiveRun === true || history?.inFlightRun)
2290
2317
  return { ...base, idleConfirmed: false, busyConfirmed: true,
2291
2318
  activeRunId: typeof history?.inFlightRun?.runId === 'string' ? history.inFlightRun.runId : undefined };
2292
- const pending = history?.pendingInputs;
2293
2319
  if (!Number.isSafeInteger(pending?.total) || pending.total < 0 || total !== undefined && total !== pending.total)
2294
2320
  return unknown();
2295
2321
  total = pending.total;
@@ -2331,6 +2357,7 @@ export class OpenClawClient {
2331
2357
  if (page > 0) {
2332
2358
  // Pagination is not an atomic snapshot. Re-read the current head and
2333
2359
  // identity/activity before returning positive evidence to local CAS.
2360
+ admissionProof = {};
2334
2361
  const head = await this.rpc('chat.history', { sessionKey: key, limit: 20 }, 1500);
2335
2362
  if (Date.now() >= deadline)
2336
2363
  return unknown();
@@ -2339,6 +2366,7 @@ export class OpenClawClient {
2339
2366
  return unknown();
2340
2367
  if (hi?.archived === true)
2341
2368
  return { ...base, archived: true, idleConfirmed: false };
2369
+ admissionProof = admissionFrom(head);
2342
2370
  if (hi?.hasActiveRun === true || head?.inFlightRun || Number.isSafeInteger(hp?.queuedCount) && hp.queuedCount > 0)
2343
2371
  return { ...base, idleConfirmed: false, busyConfirmed: true, activeRunId: typeof head?.inFlightRun?.runId === 'string' ? head.inFlightRun.runId : undefined };
2344
2372
  if (hi?.hasActiveRun !== false)
@@ -2358,9 +2386,10 @@ export class OpenClawClient {
2358
2386
  // The outer race must honor the SAME total budget as the scan itself
2359
2387
  // (audit P1 2026-10-17: an 8000ms scan raced against a 1500ms timer was
2360
2388
  // still killed at 1500ms — three real 650ms pages never got to finish).
2361
- return await Promise.race([scan(), new Promise((_, reject) => {
2389
+ const result = await Promise.race([scan(), new Promise((_, reject) => {
2362
2390
  timer = setTimeout(() => reject(new Error('activity lookup timeout')), 8000);
2363
2391
  })]);
2392
+ return { ...result, ...admissionProof };
2364
2393
  }
2365
2394
  catch {
2366
2395
  return { idleConfirmed: false };
@@ -3037,7 +3066,7 @@ export class OpenClawClient {
3037
3066
  const stopGeneration = this.stopGenerations.get(fullSessionKey) || 0;
3038
3067
  const isCurrent = () => this.observationEpoch.get(fullSessionKey) === generation && (this.stopGenerations.get(fullSessionKey) || 0) === stopGeneration;
3039
3068
  const assertCurrent = () => { if (!isCurrent())
3040
- throw new SupersededObservation('admission generation revoked'); };
3069
+ throw new SupersededObservation('admission generation revoked'); params.admissionGuard?.(); };
3041
3070
  const submissionId = randomUUID();
3042
3071
  let ownedRunId = "";
3043
3072
  let eventReservationRunId = '';
@@ -3062,7 +3091,21 @@ export class OpenClawClient {
3062
3091
  const sendStartedAt = Date.now();
3063
3092
  try {
3064
3093
  assertCurrent();
3065
- this.recoveryStore?.saveAdmission(fullSessionKey, submissionId, 'reserved', { generation, stopGeneration });
3094
+ const restricted = params.followupAdmissionGuard;
3095
+ if (restricted) {
3096
+ const refuse = (reason) => { throw new FollowupAdmissionRefused(reason, submissionId); };
3097
+ if (restricted.sessionKey !== fullSessionKey || !restricted.sessionId?.trim() || !restricted.expectedLeafEntryId?.trim())
3098
+ refuse('missing or incompatible observed generation/leaf');
3099
+ const activity = await this.getSessionDispatchStatus(fullSessionKey);
3100
+ assertCurrent();
3101
+ const runtime = await this.getSessionRuntimeStatus(fullSessionKey);
3102
+ assertCurrent();
3103
+ if (!activity.followupAdmissionEligible || activity.identityConflict || activity.archived || runtime.archived ||
3104
+ activity.activeRunId && !restricted.nonInjectableRunId ||
3105
+ activity.sessionId !== restricted.sessionId || runtime.sessionId !== restricted.sessionId)
3106
+ refuse('current session/leaf/archive/admission evidence changed');
3107
+ }
3108
+ this.recoveryStore?.saveAdmission(fullSessionKey, submissionId, 'reserved', { generation, stopGeneration, ...(restricted ? { followupAdmissionGuard: restricted } : {}) });
3066
3109
  await params.onSendAttempt?.(submissionId);
3067
3110
  assertCurrent();
3068
3111
  const idempotencyKey = submissionId;
@@ -3087,6 +3130,7 @@ export class OpenClawClient {
3087
3130
  // Ordinary admission is a separate serialized turn, not a request to
3088
3131
  // inherit stored interrupt/steer policy or its commit-wait finalizer.
3089
3132
  queueMode: "followup",
3133
+ ...(restricted ? { sessionId: restricted.sessionId, expectedLeafEntryId: restricted.expectedLeafEntryId, suppressCommandInterpretation: true } : {}),
3090
3134
  deliver: params.deliver ?? false,
3091
3135
  idempotencyKey,
3092
3136
  });
@@ -3095,6 +3139,25 @@ export class OpenClawClient {
3095
3139
  result = await sendChat();
3096
3140
  }
3097
3141
  catch (err) {
3142
+ if (restricted) {
3143
+ // Only named public pre-effect guard refusals prove zero effect.
3144
+ // Other RPC errors/transport failures retain the ORIGINAL identity;
3145
+ // an error string alone must never authorize a new-ID replay.
3146
+ let detail;
3147
+ if (err instanceof Error && err.message.startsWith('RPC error:')) {
3148
+ try {
3149
+ detail = JSON.parse(err.message.slice('RPC error:'.length));
3150
+ }
3151
+ catch { /* unknown */ }
3152
+ }
3153
+ const noEffect = detail?.code === 'INVALID_REQUEST' && ['active-leaf-changed', 'session-changed'].includes(detail?.details?.reason);
3154
+ this.recoveryStore?.saveAdmission(fullSessionKey, idempotencyKey, noEffect ? 'rejected' : 'unknown', { generation, stopGeneration, followupAdmissionGuard: restricted });
3155
+ if (noEffect) {
3156
+ this.recoveryStore?.finishRunObservation(fullSessionKey, submissionId, 'rejected');
3157
+ throw new FollowupAdmissionRefused(detail.details.reason, submissionId);
3158
+ }
3159
+ throw new AdmissionUnconfirmed(idempotencyKey, err);
3160
+ }
3098
3161
  // The Gateway auto-archives sessions past the active-session cap; an
3099
3162
  // archived session refuses new work. Restore it once and retry — the
3100
3163
  // user should never have to fix this by hand.
@@ -3254,6 +3317,8 @@ export class OpenClawClient {
3254
3317
  deliver: params.deliver,
3255
3318
  timeoutMs: params.timeoutMs,
3256
3319
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
3320
+ followupAdmissionGuard: params.followupAdmissionGuard,
3321
+ admissionGuard: params.admissionGuard,
3257
3322
  onSendAttempt: params.onSendAttempt,
3258
3323
  onSubmitted: params.onSubmitted,
3259
3324
  onRunIdentified: params.onRunIdentified,
@@ -3294,6 +3359,8 @@ export class OpenClawClient {
3294
3359
  deliver: params.deliver,
3295
3360
  timeoutMs: params.timeoutMs,
3296
3361
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
3362
+ followupAdmissionGuard: params.followupAdmissionGuard,
3363
+ admissionGuard: params.admissionGuard,
3297
3364
  onSendAttempt: params.onSendAttempt,
3298
3365
  onSubmitted: params.onSubmitted,
3299
3366
  onRunIdentified: params.onRunIdentified,
@@ -0,0 +1,26 @@
1
+ # 1.8.16 — accept new input independently of background activity
2
+
3
+ ## User-facing repair
4
+
5
+ Receiving input is different from starting execution. Ordinary sends already explicitly select noninterrupting `followup`; this release adds an independent guarded admission path rather than requiring the entire session to become globally idle.
6
+
7
+ - Aggregate activity, retained/queued/hidden pending records and maintenance work no longer act as a blanket receiving veto when the bridge can establish matching physical-session, observed-leaf and nonarchive evidence.
8
+ - Genuine injectable foreground targets retain SDK priority. A verified zero-effect/not-ready input can take one guarded followup path even when an observed foreground ID remains. Accepted/unknown/stopped or explicitly ineligible/target-changed input is never blindly replayed.
9
+ - Preserve the earliest genuinely observed leaf and let the public host CAS accept exact/ancestor progress or refuse an off-path branch. Normal background transcript advance is not treated as permanent session-generation failure.
10
+ - Preserve the existing bounded ordinary rescue for a fresh, never-attempted input whose activity state is genuinely unknown. An unknown submission receipt remains a different no-replay state.
11
+ - Guard refusal retains the latest input and gives a visible explanation. Restricted sends never auto-unarchive and retry. Stop, owner, actual batch, admission epoch, identity and source checks fence late reads/ACKs and concurrent handlers.
12
+ - Local scheduler retirement retains the original exact-run recovery, delivery target and honest Get state; it does not abort a business tool, invent a terminal outcome or infer input consumption.
13
+
14
+ ## Parent final-review corrections
15
+
16
+ - Guarded same-proof backlogs respect configured message-count and byte bounds. Unselected inputs stay durably queued for subsequent bounded batches instead of being dropped or packed into an oversized request. One individually oversized input is not silently discarded.
17
+ - Gateway-accepted guarded inputs transition to ordinary, synchronize and clear their pending trigger in one SQLite transaction. This prevents an accepted input reappearing in later group catch-up context and preserves unknown custody if the local checkpoint fails.
18
+
19
+ ## Verification and operational boundaries
20
+
21
+ - Parent exact-source full offline suite: **65 files / 1763 tests passed**. Includes 51 new candidate admission cases plus parent backlog byte/count and atomic checkpoint rollback controls, all prior collector/privacy/presentation tests, and the original six A4 tests with unchanged assertions.
22
+ - Bridge/all bundled-plugin builds and public SDK type contract passed. Package contents inspected; runtime dependencies and coordinated lma-core protocol/package remain unchanged (protocol 2 / 0.2.0).
23
+ - Static parent verification of the actual Windows OpenClaw **2026.9.6** public leaf helper confirmed the same requested-session exact-or-ancestor semantics as Linux **2026.9.9**. No private helper was imported/executed and no Core/config file was changed.
24
+ - Gateway acceptance is not immediate execution, per-input consumption or a platform receipt. Native FIFO, capacity/overflow, permissions, disconnect and stop behavior remain host constraints. Missing physical/leaf/nonarchive proof gets honest local custody, not a fabricated successful admission.
25
+ - Update bridge runtime only, with a coherent bridge-state backup and verified rollback. No session reset/history deletion, queue clearing, automatic revival of stopped work or Gateway restart is required.
26
+ - Original-group post-activation human input -> Gateway admission -> platform-accepted reply remains a separate live acceptance gate. Green offline tests, a version string and service readiness do not prove that gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.15",
3
+ "version": "1.8.16",
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": {
@@ -76,7 +76,8 @@
76
76
  "docs/releases/1.8.5.md",
77
77
  "docs/releases/1.8.10.md",
78
78
  "docs/releases/1.8.11.md",
79
- "docs/releases/1.8.15.md"
79
+ "docs/releases/1.8.15.md",
80
+ "docs/releases/1.8.16.md"
80
81
  ],
81
82
  "engines": {
82
83
  "node": ">=22"