openclaw-lark-multi-agent 1.8.9 → 1.8.10

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/cli.js CHANGED
@@ -24,6 +24,7 @@ Usage:
24
24
  ${CLI_NAME} install-windows-service [--state-dir DIR] [--no-start]
25
25
  ${CLI_NAME} install-core-plugin [--no-force]
26
26
  ${CLI_NAME} install-steer-plugin [--no-force] (compatibility alias)
27
+ ${CLI_NAME} check-core-plugin (read-only runtime protocol check)
27
28
  ${CLI_NAME} doctor [--state-dir DIR]
28
29
  ${CLI_NAME} --help
29
30
 
@@ -250,6 +251,23 @@ function cmdInstallCorePlugin(args) {
250
251
  console.log(`Load/reload with the Gateway's supported plugin control path, then verify the read-only capability endpoint:`);
251
252
  console.log(` openclaw gateway call lma.steer.capabilities --params '{}'`);
252
253
  console.log(` (expect protocol:2, durableAttempts:true, acceptanceCallback:true, atomicRunBound:false)`);
254
+ console.log(` ${CLI_NAME} check-core-plugin (read-only validation; generic core capabilities alone are insufficient)`);
255
+ }
256
+ function cmdCheckCorePlugin(args) {
257
+ if (args.length)
258
+ throw new Error(`Unknown check-core-plugin arguments: ${args.join(' ')}`);
259
+ const bin = process.env.OPENCLAW_BIN || 'openclaw';
260
+ let capability;
261
+ try {
262
+ const raw = execFileSync(bin, ['gateway', 'call', 'lma.steer.capabilities', '--params', '{}', '--json'], { encoding: 'utf8', shell: process.platform === 'win32', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] });
263
+ capability = JSON.parse(raw);
264
+ }
265
+ catch {
266
+ throw new Error('lma.steer.capabilities unavailable or invalid: callback/durable insertion is NOT verified. Activate the coordinated lma-core plugin via supported Gateway controls, then recheck.');
267
+ }
268
+ if (capability?.protocol !== 2 || capability?.durableAttempts !== true || capability?.acceptanceCallback !== true || capability?.sessionGenerationBound !== true)
269
+ throw new Error('lma.steer.capabilities incompatible: require protocol=2, durableAttempts=true, acceptanceCallback=true, sessionGenerationBound=true; generic lma.core.capabilities is not activation proof.');
270
+ console.log('OK: runtime lma.steer.capabilities protocol=2; callback acceptance, durable attempts and session generation binding verified (not model consumption or final-effect CAS).');
253
271
  }
254
272
  function cmdDoctor(args) {
255
273
  const stateDir = resolve(takeOption(args, "--state-dir") || DEFAULT_STATE_DIR);
@@ -277,6 +295,8 @@ async function main() {
277
295
  return cmdInstallCorePlugin(args);
278
296
  if (cmd === "doctor")
279
297
  return cmdDoctor(args);
298
+ if (cmd === "check-core-plugin")
299
+ return cmdCheckCorePlugin(args);
280
300
  if (cmd === "start") {
281
301
  const configPath = args[0] ? resolve(args[0]) : DEFAULT_CONFIG_PATH;
282
302
  return startApp(configPath);
@@ -118,6 +118,13 @@ export declare class FeishuBot {
118
118
  static getByName(name: string): FeishuBot | undefined;
119
119
  static findByOpenId(openId: string): FeishuBot | undefined;
120
120
  private handleMessage;
121
+ /** With no local owner, the Gateway can still have externally admitted work.
122
+ * Read public activity and pin its exact root before choosing insertion. Missing
123
+ * identity is durable unbound custody, never a fabricated run or blind send. */
124
+ private reconcileCurrentInputActivity;
125
+ /** CAS includes the actual batch, not merely the queue Promise, and excludes
126
+ * local admission/RPC windows even if the host briefly reports idle. */
127
+ private reconcileDispatchCustody;
121
128
  private stopGeneration;
122
129
  private reactionChecks;
123
130
  private receiptReaction;
@@ -250,6 +257,7 @@ export declare class FeishuBot {
250
257
  /** Both initial and CONTINUE results enter the same ordinary LMA outbox.
251
258
  * No host proactive-send assumption, no fake platform receipt. */
252
259
  private deliverGoExecutorResult;
260
+ private insertionNoticeCurrent;
253
261
  private dispatchPendingDeliveries;
254
262
  private hasFreeModeBot;
255
263
  private mentionedBotNames;
@@ -1225,11 +1225,11 @@ export class FeishuBot {
1225
1225
  // Reserve BEFORE the first health/reaction await. Otherwise an ending
1226
1226
  // owner can ordinary-drain this row while its handler is still waiting,
1227
1227
  // and the later steer would submit the same message a second time.
1228
- const ingressOwner = this.queueOwners.get(chatId);
1229
- if (this.queueRuns.has(chatId) && !ingressOwner?.triggerIds?.has(insertedId)) {
1228
+ const ingressOwner = this.queueOwners.get(chatId) || this.store.getDispatchCustody(this.config.name, chatId);
1229
+ if ((this.queueRuns.has(chatId) || ingressOwner) && !ingressOwner?.triggerIds?.has(insertedId)) {
1230
1230
  this.store.saveInsertion(this.config.name, chatId, insertedId, JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1231
1231
  messageId, text: cleanText, stopEpoch: this.stopGeneration(chatId), expectedRunId: ingressOwner?.runId || '',
1232
- ownerId: ingressOwner?.id, batchId: ingressOwner?.batchId, sessionKey: this.getSessionKey(chatId)
1232
+ ownerId: ingressOwner?.id, batchId: ingressOwner?.batchId, sessionId: ingressOwner?.sessionId, sessionKey: this.getSessionKey(chatId)
1233
1233
  });
1234
1234
  reservedAtIngress = true;
1235
1235
  }
@@ -1249,6 +1249,20 @@ export class FeishuBot {
1249
1249
  if (unhealthy) {
1250
1250
  await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
1251
1251
  }
1252
+ // Inspect current public activity, not just the local promise. A yielded
1253
+ // task (or a settled/uncached old root with authoritative idle history)
1254
+ // keeps its result observer but cannot monopolize independent new input.
1255
+ const dispatchReleased = await this.reconcileDispatchCustody(chatId);
1256
+ if (this.stopGeneration(chatId) !== inputStopEpoch)
1257
+ return;
1258
+ if (!isBot && insertedId > 0 && !dispatchReleased && !this.queueRuns.has(chatId) && !this.store.getDispatchCustody(this.config.name, chatId))
1259
+ await this.reconcileCurrentInputActivity(chatId, insertedId, messageId, cleanText);
1260
+ if (this.stopGeneration(chatId) !== inputStopEpoch)
1261
+ return;
1262
+ if (this.store.isInsertionReserved(this.config.name, chatId, insertedId))
1263
+ reservedAtIngress = true;
1264
+ if (reservedAtIngress && !this.store.isInsertionReserved(this.config.name, chatId, insertedId))
1265
+ reservedAtIngress = false;
1252
1266
  // Another ingress handler may already have selected/admitted this row
1253
1267
  // as an ordinary batch while this handler awaited health. Do not re-steer
1254
1268
  // a message that already belongs to that batch.
@@ -1265,11 +1279,11 @@ export class FeishuBot {
1265
1279
  // --- Queue-based sending: if agent is busy, just accumulate ---
1266
1280
  // The actual queue owner, not elapsed wall time, decides whether input
1267
1281
  // should steer/queue. Long healthy runs must not lose their busy ownership.
1268
- const isBusy = reservedAtIngress || this.queueRuns.has(chatId);
1282
+ const isBusy = reservedAtIngress || this.queueRuns.has(chatId) || !!this.store.getDispatchCustody(this.config.name, chatId);
1269
1283
  if (isBusy) {
1270
- const owner = this.queueOwners.get(chatId);
1284
+ const owner = this.queueOwners.get(chatId) || this.store.getDispatchCustody(this.config.name, chatId);
1271
1285
  const record = { messageId, text: cleanText, stopEpoch: this.stopGeneration(chatId),
1272
- expectedRunId: owner?.runId || '', ownerId: owner?.id, batchId: owner?.batchId, sessionKey: this.getSessionKey(chatId) };
1286
+ expectedRunId: owner?.runId || '', ownerId: owner?.id, batchId: owner?.batchId, sessionId: owner?.sessionId, sessionKey: this.getSessionKey(chatId) };
1273
1287
  if (insertedId > 0 && !this.store.getInsertion(this.config.name, chatId, insertedId))
1274
1288
  this.store.saveInsertion(this.config.name, chatId, insertedId, JSON.stringify([this.config.appId, chatId, messageId]), 'pending', record);
1275
1289
  // Reservation is durable BEFORE any RPC. Ordinary drain cannot race a
@@ -1279,12 +1293,103 @@ export class FeishuBot {
1279
1293
  return;
1280
1294
  }
1281
1295
  // Not busy: admission callback, not local queueing, flips Typing -> Get.
1282
- await this.processQueue(chatId);
1296
+ await this.processQueue(chatId, true);
1283
1297
  }
1284
1298
  catch (err) {
1285
1299
  console.error(`[${this.config.name}] Error:`, safeErrorSummary(err));
1286
1300
  }
1287
1301
  }
1302
+ /** With no local owner, the Gateway can still have externally admitted work.
1303
+ * Read public activity and pin its exact root before choosing insertion. Missing
1304
+ * identity is durable unbound custody, never a fabricated run or blind send. */
1305
+ async reconcileCurrentInputActivity(chatId, rowId, messageId, text) {
1306
+ if (!this.openclawClient.getSessionRuntimeStatus || !this.openclawClient.getSessionDispatchStatus)
1307
+ return;
1308
+ const epoch = this.stopGeneration(chatId);
1309
+ // A different ingress handler can ordinary-admit AND finish this row while
1310
+ // a public lookup is in flight. Check message custody, not just an empty
1311
+ // owner map, at every boundary so a late lookup can never reinsert it.
1312
+ const unsubmitted = () => this.stopGeneration(chatId) === epoch &&
1313
+ this.store.getPendingTriggerIds(this.config.name, chatId).has(rowId) &&
1314
+ !this.queueOwners.get(chatId)?.triggerIds?.has(rowId);
1315
+ if (!unsubmitted())
1316
+ return;
1317
+ await this.openclawClient.waitForSessionStop?.(this.getSessionKey(chatId));
1318
+ if (!unsubmitted())
1319
+ return;
1320
+ // Session creation/model setup is not agent admission. Initialize a truly
1321
+ // new chat before asking for its live activity; never infer idle from a
1322
+ // missing describe response. Setup errors keep the existing queue error path.
1323
+ try {
1324
+ await this.ensureSession(chatId);
1325
+ }
1326
+ catch {
1327
+ return;
1328
+ }
1329
+ if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1330
+ return;
1331
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
1332
+ if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1333
+ return;
1334
+ const current = this.store.getInsertion(this.config.name, chatId, rowId);
1335
+ if (current && (!current.activityUnbound || current.attempted))
1336
+ return;
1337
+ const activity = await this.openclawClient.getSessionDispatchStatus(this.getSessionKey(chatId));
1338
+ if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1339
+ return;
1340
+ // Another recovery/ingress lookup may have identified the root and begun
1341
+ // dispatch while this one awaited history. Never restore its older
1342
+ // never-attempted snapshot over an attempted/unknown/accepted journal.
1343
+ const fresh = this.store.getInsertion(this.config.name, chatId, rowId);
1344
+ if (current ? fresh?.insertionId !== current.insertionId || fresh.state !== 'pending' || fresh.attempted === true || !fresh.activityUnbound || !!fresh.expectedRunId : !!fresh)
1345
+ return;
1346
+ const compatible = !activity.sessionId || !status.sessionId || activity.sessionId === status.sessionId;
1347
+ const idle = compatible && !status.archived && !activity.archived && !activity.busyConfirmed &&
1348
+ (status.idleConfirmed && !!status.sessionId || activity.idleConfirmed && !!activity.sessionId);
1349
+ if (idle) {
1350
+ if (current)
1351
+ this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId, 'ordinary', { ...current, activityUnbound: false });
1352
+ return;
1353
+ }
1354
+ const identified = compatible && !!activity.activeRunId && !!activity.sessionId && !activity.archived && !status.archived;
1355
+ this.store.saveInsertion(this.config.name, chatId, rowId, current?.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1356
+ messageId, text, stopEpoch: epoch, sessionKey: this.getSessionKey(chatId), sessionId: identified ? activity.sessionId : status.sessionId,
1357
+ expectedRunId: identified ? activity.activeRunId : '', ownerId: identified ? `external:${activity.activeRunId}` : undefined,
1358
+ batchId: identified ? activity.activeRunId : undefined, activityUnbound: !identified, attempted: false,
1359
+ reason: identified ? 'current Gateway run observed; insertion custody only' : 'current Gateway activity/run identity unavailable; retained without admission',
1360
+ }, Date.now() + (identified ? 0 : 5000));
1361
+ }
1362
+ /** CAS includes the actual batch, not merely the queue Promise, and excludes
1363
+ * local admission/RPC windows even if the host briefly reports idle. */
1364
+ async reconcileDispatchCustody(chatId) {
1365
+ const local = this.queueOwners.get(chatId);
1366
+ const saved = this.store.getDispatchCustody(this.config.name, chatId);
1367
+ if (!saved?.runId || !saved.batchId || !saved.sessionId || !saved.admitted && !saved.yielded)
1368
+ return false;
1369
+ if (local && (local.id !== saved.id || local.batchId !== saved.batchId || !local.admitted && !local.yielded || !local.collecting))
1370
+ return false;
1371
+ const batch = local?.batch, runId = local?.runId, epoch = this.stopGeneration(chatId);
1372
+ const unchanged = () => {
1373
+ const now = this.store.getDispatchCustody(this.config.name, chatId);
1374
+ return this.stopGeneration(chatId) === epoch && now?.id === saved.id && now?.batchId === saved.batchId && now?.runId === saved.runId &&
1375
+ this.queueOwners.get(chatId) === local && (!local || local.batch === batch && local.runId === runId && (local.admitted === true || local.yielded === true) && local.collecting === true);
1376
+ };
1377
+ if (!saved.yielded) {
1378
+ const activity = await this.openclawClient.getSessionDispatchStatus?.(this.getSessionKey(chatId)).catch(() => null);
1379
+ if (!unchanged() || !activity?.idleConfirmed || activity.archived || activity.sessionId !== saved.sessionId)
1380
+ return false;
1381
+ }
1382
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId)).catch(() => null);
1383
+ if (!unchanged() || !status?.idleConfirmed || status.archived || status.sessionId !== saved.sessionId)
1384
+ return false;
1385
+ if (!this.store.releaseDispatchCustody(this.config.name, chatId, saved.id, saved.batchId))
1386
+ return false;
1387
+ // The target was committed on admission and survives this local cleanup.
1388
+ // Only this exact old owner can be revoked; never a replacement batch.
1389
+ if (local)
1390
+ this.forceExpireQueueOwner(chatId, 'current session confirmed schedulable; exact result observation retained');
1391
+ return true;
1392
+ }
1288
1393
  stopGeneration(chatId) {
1289
1394
  return this.stopEpoch.get(chatId) ?? this.store.getStopGeneration(this.config.name, chatId);
1290
1395
  }
@@ -1350,7 +1455,7 @@ export class FeishuBot {
1350
1455
  this.insertionTimer = setTimeout(() => {
1351
1456
  this.insertionTimer = undefined;
1352
1457
  const chats = [...new Set([...this.store.listInsertions(this.config.name), ...this.store.listReactionIntents(this.config.name)].map(x => x.chatId))];
1353
- void Promise.allSettled(chats.map(async (chat) => { await this.reconcileReactions(chat); await this.reconcileInsertions(chat); })).finally(() => this.scheduleInsertionRecovery());
1458
+ void Promise.allSettled(chats.flatMap(chat => [this.reconcileReactions(chat), this.reconcileInsertions(chat)])).finally(() => this.scheduleInsertionRecovery());
1354
1459
  }, 5000);
1355
1460
  this.insertionTimer.unref?.();
1356
1461
  }
@@ -1359,14 +1464,30 @@ export class FeishuBot {
1359
1464
  return;
1360
1465
  this.insertionChecks.add(chatId);
1361
1466
  try {
1467
+ const released = await this.reconcileDispatchCustody(chatId);
1468
+ if (released)
1469
+ void this.processQueue(chatId, true).catch(() => { });
1362
1470
  for (const item of this.store.listInsertions(this.config.name, chatId)) {
1363
1471
  if (item.nextCheckAt > Date.now())
1364
1472
  continue;
1365
- const stillCurrent = () => this.stopGeneration(chatId) === item.stopEpoch && this.store.getInsertion(this.config.name, chatId, item.rowId)?.state !== 'stopped';
1473
+ const stillCurrent = () => this.stopGeneration(chatId) === item.stopEpoch && !['stopped', 'ordinary', 'committed'].includes(this.store.getInsertion(this.config.name, chatId, item.rowId)?.state);
1366
1474
  if (!stillCurrent()) {
1367
- this.store.stopInsertions(this.config.name, chatId);
1475
+ if (this.stopGeneration(chatId) !== item.stopEpoch)
1476
+ this.store.stopInsertionAtEpoch(this.config.name, chatId, item.rowId, item.stopEpoch);
1368
1477
  continue;
1369
1478
  }
1479
+ if (item.activityUnbound) {
1480
+ await this.reconcileCurrentInputActivity(chatId, item.rowId, item.messageId, item.text);
1481
+ if (!stillCurrent()) {
1482
+ if (this.store.getInsertion(this.config.name, chatId, item.rowId)?.state === 'ordinary')
1483
+ void this.processQueue(chatId, true).catch(() => { });
1484
+ continue;
1485
+ }
1486
+ const refreshed = this.store.getInsertion(this.config.name, chatId, item.rowId);
1487
+ if (!refreshed?.expectedRunId)
1488
+ continue;
1489
+ Object.assign(item, refreshed);
1490
+ }
1370
1491
  const owner = this.queueOwners.get(chatId);
1371
1492
  if (!item.expectedRunId && owner?.id === item.ownerId && owner?.batchId === item.batchId && owner?.runId)
1372
1493
  item.expectedRunId = owner.runId;
@@ -1377,7 +1498,7 @@ export class FeishuBot {
1377
1498
  }
1378
1499
  // Pin the real session generation BEFORE the dispatching journal. A
1379
1500
  // crash/lost response can then query using precisely the same identity.
1380
- const info = await this.openclawClient.getSessionInfo(item.sessionKey).catch(() => null);
1501
+ const info = await this.openclawClient.getSessionInfo(item.sessionKey, 1500).catch(() => null);
1381
1502
  if (!stillCurrent())
1382
1503
  continue;
1383
1504
  const sessionId = info?.session?.sessionId;
@@ -1427,9 +1548,9 @@ export class FeishuBot {
1427
1548
  continue;
1428
1549
  const valid = receipt.insertionId === item.insertionId && receipt.expectedRunId === item.expectedRunId && (!receipt.sessionId || receipt.sessionId === item.sessionId);
1429
1550
  let state = valid ? receipt.status : 'unknown';
1430
- // Explicit no-effect means a real old-run terminal can hand this SAME
1431
- // input back to the ordinary queue once. Time elapsed never authorizes
1432
- // this handoff. An attempted unknown can never take this branch.
1551
+ // Explicit no-effect plus confirmed current schedulability can hand
1552
+ // this SAME input back once without claiming old task settlement.
1553
+ // Time elapsed and attempted-unknown receipts never authorize replay.
1433
1554
  if (valid && receipt.attempted === false && receipt.ordinaryEligible !== false && ['pending', 'target_ended'].includes(state) &&
1434
1555
  ['target_not_ready', 'backend_rejected_no_effect', 'original run ended without an insertion attempt'].includes(receipt.reason || '')) {
1435
1556
  const status = await this.openclawClient.getSessionRuntimeStatus(item.sessionKey).catch(() => null);
@@ -1438,8 +1559,15 @@ export class FeishuBot {
1438
1559
  const rootSettled = await this.openclawClient.exactRunSettled?.(item.expectedRunId);
1439
1560
  if (!stillCurrent())
1440
1561
  continue;
1562
+ const activity = !owner && !rootSettled && status?.idleConfirmed ? await this.openclawClient.getSessionDispatchStatus?.(item.sessionKey).catch(() => null) : null;
1563
+ if (!stillCurrent())
1564
+ continue;
1565
+ const currentIdle = activity?.idleConfirmed === true && activity.sessionId === item.sessionId && !activity.archived && status?.sessionId === item.sessionId;
1441
1566
  const unchanged = this.queueOwners.get(chatId) === owner && owner?.batch === batchAtCheck && owner?.runId === runAtCheck;
1442
- if (rootSettled === true && status?.idleConfirmed === true && unchanged) {
1567
+ const target = this.store.getDeliveryRunTarget(this.config.name, chatId, item.expectedRunId);
1568
+ const dispatchReleased = target?.dispatchReleased === true && target.dispatchOwnerId === item.ownerId && target.dispatchBatchId === item.batchId &&
1569
+ target.sessionId === item.sessionId && status?.sessionId === item.sessionId && !status?.archived;
1570
+ if ((rootSettled === true || dispatchReleased || currentIdle) && status?.idleConfirmed === true && unchanged) {
1443
1571
  state = 'ordinary';
1444
1572
  // Never expire a successor merely to schedule the old input.
1445
1573
  if (runAtCheck === item.expectedRunId)
@@ -1454,16 +1582,16 @@ export class FeishuBot {
1454
1582
  state = 'pending';
1455
1583
  // Unknown is the NORMAL transient state right after a steer attempt
1456
1584
  // (the backend acceptance receipt has not landed yet); surfacing it on
1457
- // every insertion spams the chat (user report 2026-10-16: "每次插入消
1458
- // 息都发个这个"). Stay silent until it persists through several
1459
- // re-checks (~45s), then notify exactly once.
1585
+ // every insertion spams the chat. Use real elapsed time from the first
1586
+ // unknown observation, not query count, and commit one reminder intent.
1460
1587
  const unknownChecks = state === 'unknown' ? (Number(item.unknownChecks) || 0) + 1 : 0;
1461
- const record = { ...item, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks };
1588
+ const record = { ...item, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks,
1589
+ unknownSince: state === 'unknown' ? (item.state === 'unknown' && Number.isFinite(item.unknownSince) ? item.unknownSince : Date.now()) : undefined };
1462
1590
  this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, state, record, Date.now() + (state === 'unknown' ? 15_000 : 5000));
1463
1591
  if (state === 'ordinary') {
1464
1592
  this.store.deletePendingInsertionNotices(this.config.name, chatId, item.insertionId);
1465
- void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:ordinary`, 'ℹ️ 原 run 已明确结束,且这条消息确定未被插入;现已交回普通队列,只执行一次。', [], item.messageId, `insertion:${item.insertionId}:ordinary`).catch(() => { });
1466
- void this.processQueue(chatId).catch(() => { });
1593
+ void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:ordinary`, 'ℹ️ 当前会话已确认可接收新轮次,且这条消息确定未被插入;现已交回普通队列,只执行一次。', [], item.messageId, `insertion:${item.insertionId}:ordinary`).catch(() => { });
1594
+ void this.processQueue(chatId, true).catch(() => { });
1467
1595
  }
1468
1596
  else if (state === 'accepted') {
1469
1597
  this.store.deletePendingInsertionNotices(this.config.name, chatId, item.insertionId);
@@ -1487,17 +1615,15 @@ export class FeishuBot {
1487
1615
  if (item.state !== state)
1488
1616
  void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:${state}`, `ℹ️ 这条插入消息的状态为 ${state},仍保留本地记录,未确认接收前不会显示 Get,也不会自动重复执行。${receipt.reason || ''}`, [], item.messageId, `insertion:${item.insertionId}:${state}`).catch(() => { });
1489
1617
  }
1490
- else if (state === 'unknown' && unknownChecks >= 4 && !item.unknownNotified) {
1491
- // ~45s (attempt + 3 re-checks). Mark unknownNotified ONLY after the
1492
- // outbox insert succeeds, so a failed enqueue retries next check
1493
- // instead of silencing the notice forever.
1494
- void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:unknown-prolonged`, `ℹ️ 这条插入消息约 45 秒仍未确认接收:本地记录保留中,不会自动重复执行;确认后会自动显示 Get。`, [], item.messageId, `insertion:${item.insertionId}:unknown-prolonged`)
1495
- .then(() => {
1496
- const cur = this.store.getInsertion(this.config.name, chatId, item.rowId);
1497
- const curRecord = (cur && cur.record) || record;
1498
- this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, (cur && cur.state) || state, { ...curRecord, unknownNotified: true }, Date.now() + 15_000);
1499
- })
1500
- .catch(() => { });
1618
+ else if (state === 'unknown' && Date.now() - record.unknownSince >= 45_000 && !item.unknownNotified) {
1619
+ const text = 'ℹ️ 这条插入消息约 45 秒仍未确认接收:本地记录保留中,不会自动重复执行;确认后会自动显示 Get。';
1620
+ const key = `insertion:${item.insertionId}:unknown-prolonged`;
1621
+ if (this.store.queueInsertionUnknownNotice(this.config.name, chatId, item.rowId, {
1622
+ sessionKey: this.getSessionKey(chatId), chatId, botName: this.config.name, sourceType: 'insertion_status', sourceId: key, deliveryKey: key,
1623
+ contentHash: this.stableHash(`${text}|[]`), content: text, attachmentsJson: '[]', replyToMessageId: item.messageId,
1624
+ deliveryMetaJson: JSON.stringify({ botIdentity: this.config.appId, insertionNotice: { rowId: item.rowId, insertionId: item.insertionId, sessionId: item.sessionId, stopEpoch: item.stopEpoch } }),
1625
+ }))
1626
+ void this.dispatchPendingDeliveries(chatId).catch(() => { });
1501
1627
  }
1502
1628
  }
1503
1629
  }
@@ -1624,12 +1750,46 @@ export class FeishuBot {
1624
1750
  catch { /* observer failures are isolated */ }
1625
1751
  }
1626
1752
  }
1627
- async processQueue(chatId) {
1753
+ async processQueue(chatId, activityChecked = false) {
1628
1754
  const existing = this.queueRuns.get(chatId);
1629
1755
  if (existing) {
1630
1756
  console.log(`[${this.config.name}] processQueue already running for ${chatId.slice(-8)}, joining existing run`);
1631
1757
  return existing;
1632
1758
  }
1759
+ const detached = this.store.getDispatchCustody(this.config.name, chatId);
1760
+ if (detached) {
1761
+ // Startup/recovery can reach the queue without an ingress handler. Pin
1762
+ // unsent triggers synchronously before consulting live host activity.
1763
+ for (const message of this.store.getPendingTriggerMessages(this.config.name, chatId)) {
1764
+ if (!message.id || message.senderType !== 'human' || this.store.getInsertion(this.config.name, chatId, message.id))
1765
+ continue;
1766
+ this.store.saveInsertion(this.config.name, chatId, message.id, JSON.stringify([this.config.appId, chatId, message.messageId]), 'pending', {
1767
+ messageId: message.messageId, text: message.content, stopEpoch: this.stopGeneration(chatId), expectedRunId: detached.runId,
1768
+ ownerId: detached.id, batchId: detached.batchId, sessionId: detached.sessionId, sessionKey: this.getSessionKey(chatId),
1769
+ });
1770
+ }
1771
+ if (await this.reconcileDispatchCustody(chatId))
1772
+ return this.processQueue(chatId, true);
1773
+ void this.reconcileInsertions(chatId).catch(() => { });
1774
+ this.scheduleInsertionRecovery();
1775
+ return;
1776
+ }
1777
+ if (!activityChecked && typeof this.openclawClient.getSessionRuntimeStatus === 'function' && typeof this.openclawClient.getSessionDispatchStatus === 'function') {
1778
+ const candidates = this.store.getPendingTriggerMessages(this.config.name, chatId)
1779
+ .filter(message => message.id && message.senderType === 'human' && !this.store.isInsertionReserved(this.config.name, chatId, message.id));
1780
+ if (!candidates.length)
1781
+ return;
1782
+ for (const message of candidates)
1783
+ await this.reconcileCurrentInputActivity(chatId, message.id, message.messageId, message.content);
1784
+ // Re-enter through the ordinary owner CAS after all awaits. Any concurrent
1785
+ // admission now owns its batch; never submit in parallel with that owner.
1786
+ const eligible = this.store.getPendingTriggerMessages(this.config.name, chatId).some(message => message.id && message.senderType === 'human' && !this.store.isInsertionReserved(this.config.name, chatId, message.id));
1787
+ if (eligible)
1788
+ return this.processQueue(chatId, true);
1789
+ void this.reconcileInsertions(chatId).catch(() => { });
1790
+ this.scheduleInsertionRecovery();
1791
+ return;
1792
+ }
1633
1793
  const owner = { id: randomUUID(), token: {}, revoked: false, stopEpoch: this.stopGeneration(chatId) };
1634
1794
  this.queueOwners.set(chatId, owner);
1635
1795
  const run = this.processQueueInner(chatId, owner).finally(() => {
@@ -1655,13 +1815,16 @@ export class FeishuBot {
1655
1815
  * the proactive path are invisible to them. Steer-fallback triggers are
1656
1816
  * restored via the failure observers so their input drains normally.
1657
1817
  */
1658
- forceExpireQueueOwner(chatId, reason) {
1818
+ forceExpireQueueOwner(chatId, reason, preserveDispatch = false) {
1659
1819
  const owner = this.queueRuns.get(chatId);
1660
1820
  if (!owner)
1661
1821
  return false;
1662
1822
  const context = this.queueOwners.get(chatId);
1823
+ if (context?.batchId && !preserveDispatch)
1824
+ this.store.clearDispatchCustody(this.config.name, chatId, context.id, context.batchId);
1663
1825
  if (context)
1664
1826
  context.revoked = true;
1827
+ this.busyChats.set(chatId, 0);
1665
1828
  console.warn(`[${this.config.name}] force-expiring zombie queue owner for ${chatId.slice(-8)}: ${reason}`);
1666
1829
  // Fence the stale batch's local observation BEFORE releasing custody: an
1667
1830
  // alive old poller would keep destructively consuming the replacement
@@ -1689,6 +1852,10 @@ export class FeishuBot {
1689
1852
  owner.batchId = randomUUID();
1690
1853
  owner.triggerIds = undefined;
1691
1854
  owner.runId = undefined;
1855
+ owner.admitted = false;
1856
+ owner.collecting = false;
1857
+ owner.yielded = false;
1858
+ owner.sessionId = undefined;
1692
1859
  this.queueRunTokens.set(chatId, batchToken);
1693
1860
  // A fresh batch starts: steered inputs admitted into the PREVIOUS batch
1694
1861
  // completed with it (consumed or not); their failure observers are stale.
@@ -1846,6 +2013,9 @@ export class FeishuBot {
1846
2013
  try {
1847
2014
  sessionKey = await this.ensureSession(chatId);
1848
2015
  guard();
2016
+ const generation = await this.openclawClient.getSessionInfo(sessionKey, 1500).catch(() => null);
2017
+ guard();
2018
+ owner.sessionId = generation?.session?.sessionId;
1849
2019
  }
1850
2020
  catch (err) {
1851
2021
  if (!current())
@@ -1933,11 +2103,14 @@ export class FeishuBot {
1933
2103
  // OpenClaw accepts it, retrying would duplicate the same message.
1934
2104
  markSubmittedBatch("send-attempt");
1935
2105
  },
1936
- onRunIdentified: (runId) => { guard(); owner.runId = runId; foregroundRunId = runId; },
2106
+ onRunIdentified: (runId, identity) => { guard(); owner.runId = runId; owner.sessionId = identity?.sessionId || owner.sessionId; foregroundRunId = runId; },
1937
2107
  onSubmitted: async (runId) => {
1938
2108
  guard();
1939
2109
  owner.runId = runId;
2110
+ owner.admitted = true;
2111
+ this.store.saveDispatchCustody(this.config.name, chatId, { id: owner.id, batchId: owner.batchId, runId, sessionId: owner.sessionId, admitted: true, stopEpoch: owner.stopEpoch, sessionKey });
1940
2112
  void this.reconcileInsertions(chatId).catch(() => { });
2113
+ this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || '', liveStatus });
1941
2114
  for (const ack of this.pendingAckMessages.get(chatId) || []) {
1942
2115
  if (!mergedTriggerIdSet.has(ack.rowId))
1943
2116
  continue;
@@ -1960,6 +2133,21 @@ export class FeishuBot {
1960
2133
  markSubmittedBatch(`submitted:${runId}`);
1961
2134
  },
1962
2135
  onProgress: (event) => current() ? liveStatus?.progress(event) : undefined,
2136
+ onCollectionStarted: runId => { guard(); if (owner.runId === runId)
2137
+ owner.collecting = true; },
2138
+ onYieldHandoff: runId => {
2139
+ guard();
2140
+ if (owner.runId !== runId || !owner.batchId)
2141
+ throw new Error('Yield root/batch identity mismatch');
2142
+ // All durable result routing commits synchronously before the
2143
+ // collector is fenced and before another input may take custody.
2144
+ this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || '', liveStatus });
2145
+ owner.yielded = true;
2146
+ this.store.saveDispatchCustody(this.config.name, chatId, { id: owner.id, batchId: owner.batchId, runId, sessionId: owner.sessionId, admitted: owner.admitted === true, yielded: true, stopEpoch: owner.stopEpoch, sessionKey });
2147
+ this.forceExpireQueueOwner(chatId, 'trusted exact-root yield; background result custody retained', true);
2148
+ void this.reconcileInsertions(chatId).catch(() => { });
2149
+ this.scheduleInsertionRecovery();
2150
+ },
1963
2151
  onWaitPaused: async (notice) => {
1964
2152
  guard();
1965
2153
  await liveStatus?.showWaitingForResult(notice.message);
@@ -2199,6 +2387,10 @@ export class FeishuBot {
2199
2387
  }
2200
2388
  catch (err) {
2201
2389
  if (!current() || err instanceof SupersededObservation) {
2390
+ if (current()) {
2391
+ this.busyChats.set(chatId, 0);
2392
+ owner.revoked = true;
2393
+ }
2202
2394
  // This batch's local observation was fenced by a newer batch (or a
2203
2395
  // custody revocation). The new owner handles delivery from here; end
2204
2396
  // quietly — no error notice, no replay, no Gateway abort. The old
@@ -2305,8 +2497,11 @@ export class FeishuBot {
2305
2497
  break;
2306
2498
  }
2307
2499
  finally {
2308
- if (current())
2500
+ if (current()) {
2309
2501
  this.busyChats.set(chatId, 0);
2502
+ if (owner.batchId && !owner.yielded)
2503
+ this.store.clearDispatchCustody(this.config.name, chatId, owner.id, owner.batchId);
2504
+ }
2310
2505
  }
2311
2506
  // Check if more messages arrived while we were busy
2312
2507
  // Small delay to let any in-flight messages settle
@@ -2803,7 +2998,7 @@ export class FeishuBot {
2803
2998
  const previous = this.deliveryTargetsByRun.get(key);
2804
2999
  if (previous?.timer)
2805
3000
  clearTimeout(previous.timer);
2806
- this.store.bindDeliveryRun(this.config.name, chatId, runId, { triggerId: target.triggerId, messageId: target.messageId, runId, sessionKey: `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}` });
3001
+ this.store.bindDeliveryRun(this.config.name, chatId, runId, { ...this.store.getDeliveryRunTarget(this.config.name, chatId, runId), triggerId: target.triggerId, messageId: target.messageId, runId, stopGeneration: this.stopGeneration(chatId), sessionKey: `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}` });
2807
3002
  this.deliveryTargetsByRun.set(key, {
2808
3003
  triggerId: target.triggerId,
2809
3004
  messageId: target.messageId,
@@ -3162,7 +3357,7 @@ export class FeishuBot {
3162
3357
  this.store.saveErrorNotice(record);
3163
3358
  if (verdict.state === "terminal" && verdict.terminalKey && verdict.text) {
3164
3359
  // Independent identity: never take the result's trigger/final key.
3165
- await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
3360
+ await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", sessionKey: record.provenance.sessionKey, runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
3166
3361
  // The send-time recheck may have parked or suppressed the observation.
3167
3362
  const refreshed = this.store.getErrorNotice(this.config.name, chatId, record.noticeId);
3168
3363
  if (refreshed?.verdict && ["pending", "suppressed", "stopped"].includes(refreshed.verdict.state))
@@ -3212,6 +3407,14 @@ export class FeishuBot {
3212
3407
  if (this.store.getDeliveryByKey(this.config.name, chatId, source)?.status !== "delivered")
3213
3408
  throw new Error("Go executor outbox delivery pending/unconfirmed");
3214
3409
  }
3410
+ insertionNoticeCurrent(chatId, notice) {
3411
+ if (!notice)
3412
+ return true;
3413
+ const current = this.store.getInsertion(this.config.name, chatId, notice.rowId);
3414
+ return current?.insertionId === notice.insertionId && current.state === 'unknown' && current.stopEpoch === notice.stopEpoch &&
3415
+ this.stopGeneration(chatId) === notice.stopEpoch && current.sessionId === notice.sessionId &&
3416
+ Number.isFinite(current.unknownSince) && Date.now() - current.unknownSince >= 45_000;
3417
+ }
3215
3418
  async dispatchPendingDeliveries(chatId, replyToMessageId) {
3216
3419
  this.installGoReceiptCapture();
3217
3420
  for (const id of this.localDeliveryRetryCustody.get(chatId) || []) {
@@ -3230,6 +3433,20 @@ export class FeishuBot {
3230
3433
  if (pending.length === 0)
3231
3434
  break;
3232
3435
  for (const item of pending) {
3436
+ const notice = JSON.parse(item.deliveryMetaJson || '{}').insertionNotice;
3437
+ if (notice) {
3438
+ const info = await this.openclawClient.getSessionInfo(this.getSessionKey(chatId), 1500).catch(() => null);
3439
+ if (!this.insertionNoticeCurrent(chatId, notice) || info?.session?.archived === true ||
3440
+ info?.session?.sessionId && info.session.sessionId !== notice.sessionId) {
3441
+ this.store.markDeliveryFailed(item.id);
3442
+ continue;
3443
+ }
3444
+ if (!info?.session?.sessionId) {
3445
+ this.scheduleDeliveryRetry(chatId, 1);
3446
+ retryScheduled = true;
3447
+ continue;
3448
+ }
3449
+ }
3233
3450
  const ownerId = JSON.parse(item.deliveryMetaJson || "{}").provenance?.goJobId;
3234
3451
  const deliveryJob = ownerId ? this.store.getGoJobById(ownerId) : this.store.getActiveGoJob(this.config.name, chatId);
3235
3452
  const receipts = [
@@ -3238,6 +3455,8 @@ export class FeishuBot {
3238
3455
  ];
3239
3456
  await this.sendOrdered(chatId, () => this.goDeliveryReceipts.run({ receipts, goId: deliveryJob?.id, outboxId: item.id, chatId, stage: "body", ordinals: new Map(), effectGuard: () => {
3240
3457
  const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
3458
+ if (!this.insertionNoticeCurrent(chatId, meta.insertionNotice))
3459
+ throw Object.assign(new Error('Insertion notice no longer current'), { deliveryStopped: true });
3241
3460
  if ((p?.final || item.sourceType === 'run_error') &&
3242
3461
  (typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
3243
3462
  p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
@@ -3380,6 +3599,12 @@ export class FeishuBot {
3380
3599
  }
3381
3600
  }
3382
3601
  this.store.markDeliveryDelivered(item.id);
3602
+ if (item.sourceType === 'run_error' && provenance?.terminalEvidence?.state === 'terminal' && provenance.runId && !provenance.runId.startsWith('announce:')) {
3603
+ this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3604
+ if (provenance.sessionKey)
3605
+ this.store.finishRunObservation(provenance.sessionKey, provenance.runId);
3606
+ void this.reconcileReactions(chatId).catch(() => { });
3607
+ }
3383
3608
  if (provenance?.final && provenance.runId) {
3384
3609
  this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3385
3610
  void this.reconcileReactions(chatId).catch(() => { });
@@ -4887,7 +5112,10 @@ export class FeishuBot {
4887
5112
  this.store.stopInsertions(this.config.name, chatId);
4888
5113
  this.store.cancelReactionIntents(this.config.name, chatId, this.stopGeneration(chatId));
4889
5114
  void this.openclawClient.cancelSteerSession?.(sessionKey, this.stopGeneration(chatId)).catch(() => { });
4890
- // Fire reset (no response expected)
5115
+ this.forceExpireQueueOwner(chatId, 'explicit session reset');
5116
+ this.store.clearAllPendingTriggers(this.config.name, chatId);
5117
+ // Fire reset (no response expected). Client fences old exact observations
5118
+ // and exposes its reset barrier to new post-cutoff human inputs.
4891
5119
  this.openclawClient.resetSession(sessionKey).catch(() => { });
4892
5120
  // Wait a moment for it to take effect
4893
5121
  await new Promise((r) => setTimeout(r, 2000));
@@ -114,6 +114,13 @@ export declare class MessageStore {
114
114
  listReactionIntents(bot: string, chat?: string): any[];
115
115
  bindDeliveryRun(bot: string, chat: string, runId: string, record: object): void;
116
116
  getDeliveryRunTarget(bot: string, chat: string, runId: string): any;
117
+ /** Scheduling custody is independent of exact-run result custody. */
118
+ saveDispatchCustody(bot: string, chat: string, record: object): void;
119
+ getDispatchCustody(bot: string, chat: string): any;
120
+ clearDispatchCustody(bot: string, chat: string, ownerId: string, batchId: string): boolean;
121
+ /** Atomic release of proven never-attempted reservations only. Unknown and
122
+ * accepted inputs retain their exact target and never enter ordinary drain. */
123
+ releaseDispatchCustody(bot: string, chat: string, ownerId: string, batchId: string): boolean;
117
124
  /** Reserved is persisted before the send-attempt callback; dispatching is
118
125
  * persisted before the RPC. Only reserved + unchanged stop generation proves
119
126
  * no business effect and may be restored on startup. Unknown never replays. */
@@ -125,6 +132,8 @@ export declare class MessageStore {
125
132
  settleRunReactionIntents(bot: string, chat: string, runId: string): void;
126
133
  cancelReactionIntents(bot: string, chat: string, epoch: number): void;
127
134
  saveRunObservation(sessionKey: string, runId: string, record: object, nextCheckAt?: number): void;
135
+ hasYieldedObservation(sessionKey: string): boolean;
136
+ getRunObservation(sessionKey: string, runId: string): any;
128
137
  runObservationState(sessionKey: string, runId: string): string | undefined;
129
138
  stopSessionObservations(sessionKey: string): void;
130
139
  settleExactRunReactions(sessionKey: string, runId: string): void;
@@ -134,10 +143,14 @@ export declare class MessageStore {
134
143
  /** Purge still-pending insertion status notices once the insertion resolves
135
144
  * (accepted/ordinary): a stale "unknown" notice must not fire after Get. */
136
145
  deletePendingInsertionNotices(bot: string, chat: string, insertionId: string): number;
146
+ /** Reminder intent and its outbox row commit together. Never write a stale
147
+ * reconciliation snapshot over a later accepted/stopped insertion. */
148
+ queueInsertionUnknownNotice(bot: string, chat: string, row: number, delivery: Omit<DeliveryOutboxItem, 'id' | 'status' | 'attempts' | 'createdAt' | 'updatedAt'>): boolean;
137
149
  claimInsertionAttempt(bot: string, chat: string, row: number): boolean;
138
150
  getInsertion(bot: string, chat: string, row: number): any;
139
151
  listInsertions(bot: string, chat?: string): any[];
140
152
  isInsertionReserved(bot: string, chat: string, row: number): boolean;
153
+ stopInsertionAtEpoch(bot: string, chat: string, row: number, epoch: number): void;
141
154
  stopInsertions(bot: string, chat: string): void;
142
155
  hasUnknownDeliveryOperation(outbox: number): boolean;
143
156
  /** Retain the full known result BEFORE the fallible main-journal checkpoint.