openclaw-lark-multi-agent 1.8.9 → 1.8.11

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,110 @@ 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. A failed setup is not dispatch-idle evidence;
1323
+ // retain this never-attempted input for paced reconciliation as well.
1324
+ try {
1325
+ await this.ensureSession(chatId);
1326
+ }
1327
+ catch {
1328
+ if (unsubmitted() && !this.queueRuns.has(chatId) && !this.store.getDispatchCustody(this.config.name, chatId)) {
1329
+ const row = this.store.getInsertion(this.config.name, chatId, rowId);
1330
+ if (!row || row.state === 'pending' && row.activityUnbound && !row.attempted && !row.expectedRunId)
1331
+ this.store.saveInsertion(this.config.name, chatId, rowId, row?.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1332
+ messageId, text, stopEpoch: epoch, sessionKey: this.getSessionKey(chatId), activityUnbound: true, attempted: false, expectedRunId: '',
1333
+ reason: 'session setup unavailable; retained without dispatch-idle proof',
1334
+ }, Date.now() + 5000);
1335
+ }
1336
+ return;
1337
+ }
1338
+ if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1339
+ return;
1340
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
1341
+ if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1342
+ return;
1343
+ const current = this.store.getInsertion(this.config.name, chatId, rowId);
1344
+ if (current && (!current.activityUnbound || current.attempted))
1345
+ return;
1346
+ const activity = await this.openclawClient.getSessionDispatchStatus(this.getSessionKey(chatId));
1347
+ if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1348
+ return;
1349
+ // Another recovery/ingress lookup may have identified the root and begun
1350
+ // dispatch while this one awaited history. Never restore its older
1351
+ // never-attempted snapshot over an attempted/unknown/accepted journal.
1352
+ const fresh = this.store.getInsertion(this.config.name, chatId, rowId);
1353
+ if (current ? fresh?.insertionId !== current.insertionId || fresh.state !== 'pending' || fresh.attempted === true || !fresh.activityUnbound || !!fresh.expectedRunId : !!fresh)
1354
+ return;
1355
+ const compatible = !activity.sessionId || !status.sessionId || activity.sessionId === status.sessionId;
1356
+ const idle = compatible && !status.running && !status.archived && !activity.archived && !activity.busyConfirmed &&
1357
+ activity.idleConfirmed === true && !!activity.sessionId;
1358
+ if (idle) {
1359
+ if (current)
1360
+ this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId, 'ordinary', { ...current, activityUnbound: false });
1361
+ return;
1362
+ }
1363
+ const identified = compatible && !!activity.activeRunId && !!activity.sessionId && !activity.archived && !status.archived;
1364
+ this.store.saveInsertion(this.config.name, chatId, rowId, current?.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1365
+ messageId, text, stopEpoch: epoch, sessionKey: this.getSessionKey(chatId), sessionId: identified ? activity.sessionId : status.sessionId,
1366
+ expectedRunId: identified ? activity.activeRunId : '', ownerId: identified ? `external:${activity.activeRunId}` : undefined,
1367
+ batchId: identified ? activity.activeRunId : undefined, activityUnbound: !identified, attempted: false,
1368
+ reason: identified ? 'current Gateway run observed; insertion custody only' : 'current Gateway activity/run identity unavailable; retained without admission',
1369
+ }, Date.now() + (identified ? 0 : 5000));
1370
+ }
1371
+ /** CAS includes the actual batch, not merely the queue Promise, and excludes
1372
+ * local admission/RPC windows even if the host briefly reports idle. */
1373
+ async reconcileDispatchCustody(chatId) {
1374
+ const local = this.queueOwners.get(chatId);
1375
+ const saved = this.store.getDispatchCustody(this.config.name, chatId);
1376
+ if (!saved?.runId || !saved.batchId || !saved.sessionId || !saved.admitted && !saved.yielded)
1377
+ return false;
1378
+ if (local && (local.id !== saved.id || local.batchId !== saved.batchId || !local.admitted && !local.yielded || !local.collecting))
1379
+ return false;
1380
+ const batch = local?.batch, runId = local?.runId, epoch = this.stopGeneration(chatId);
1381
+ const unchanged = () => {
1382
+ const now = this.store.getDispatchCustody(this.config.name, chatId);
1383
+ return this.stopGeneration(chatId) === epoch && now?.id === saved.id && now?.batchId === saved.batchId && now?.runId === saved.runId &&
1384
+ this.queueOwners.get(chatId) === local && (!local || local.batch === batch && local.runId === runId && (local.admitted === true || local.yielded === true) && local.collecting === true);
1385
+ };
1386
+ const activity = await this.openclawClient.getSessionDispatchStatus?.(this.getSessionKey(chatId)).catch(() => null);
1387
+ if (!unchanged() || !activity?.idleConfirmed || activity.busyConfirmed || activity.archived || activity.sessionId !== saved.sessionId)
1388
+ return false;
1389
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId)).catch(() => null);
1390
+ if (!unchanged() || !status?.idleConfirmed || status.archived || status.sessionId !== saved.sessionId)
1391
+ return false;
1392
+ if (!this.store.releaseDispatchCustody(this.config.name, chatId, saved.id, saved.batchId))
1393
+ return false;
1394
+ // The target was committed on admission and survives this local cleanup.
1395
+ // Only this exact old owner can be revoked; never a replacement batch.
1396
+ if (local)
1397
+ this.forceExpireQueueOwner(chatId, 'current session confirmed schedulable; exact result observation retained');
1398
+ return true;
1399
+ }
1288
1400
  stopGeneration(chatId) {
1289
1401
  return this.stopEpoch.get(chatId) ?? this.store.getStopGeneration(this.config.name, chatId);
1290
1402
  }
@@ -1350,7 +1462,7 @@ export class FeishuBot {
1350
1462
  this.insertionTimer = setTimeout(() => {
1351
1463
  this.insertionTimer = undefined;
1352
1464
  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());
1465
+ void Promise.allSettled(chats.flatMap(chat => [this.reconcileReactions(chat), this.reconcileInsertions(chat)])).finally(() => this.scheduleInsertionRecovery());
1354
1466
  }, 5000);
1355
1467
  this.insertionTimer.unref?.();
1356
1468
  }
@@ -1359,14 +1471,30 @@ export class FeishuBot {
1359
1471
  return;
1360
1472
  this.insertionChecks.add(chatId);
1361
1473
  try {
1474
+ const released = await this.reconcileDispatchCustody(chatId);
1475
+ if (released)
1476
+ void this.processQueue(chatId, true).catch(() => { });
1362
1477
  for (const item of this.store.listInsertions(this.config.name, chatId)) {
1363
1478
  if (item.nextCheckAt > Date.now())
1364
1479
  continue;
1365
- const stillCurrent = () => this.stopGeneration(chatId) === item.stopEpoch && this.store.getInsertion(this.config.name, chatId, item.rowId)?.state !== 'stopped';
1480
+ const stillCurrent = () => this.stopGeneration(chatId) === item.stopEpoch && !['stopped', 'ordinary', 'committed'].includes(this.store.getInsertion(this.config.name, chatId, item.rowId)?.state);
1366
1481
  if (!stillCurrent()) {
1367
- this.store.stopInsertions(this.config.name, chatId);
1482
+ if (this.stopGeneration(chatId) !== item.stopEpoch)
1483
+ this.store.stopInsertionAtEpoch(this.config.name, chatId, item.rowId, item.stopEpoch);
1368
1484
  continue;
1369
1485
  }
1486
+ if (item.activityUnbound) {
1487
+ await this.reconcileCurrentInputActivity(chatId, item.rowId, item.messageId, item.text);
1488
+ if (!stillCurrent()) {
1489
+ if (this.store.getInsertion(this.config.name, chatId, item.rowId)?.state === 'ordinary')
1490
+ void this.processQueue(chatId, true).catch(() => { });
1491
+ continue;
1492
+ }
1493
+ const refreshed = this.store.getInsertion(this.config.name, chatId, item.rowId);
1494
+ if (!refreshed?.expectedRunId)
1495
+ continue;
1496
+ Object.assign(item, refreshed);
1497
+ }
1370
1498
  const owner = this.queueOwners.get(chatId);
1371
1499
  if (!item.expectedRunId && owner?.id === item.ownerId && owner?.batchId === item.batchId && owner?.runId)
1372
1500
  item.expectedRunId = owner.runId;
@@ -1377,7 +1505,7 @@ export class FeishuBot {
1377
1505
  }
1378
1506
  // Pin the real session generation BEFORE the dispatching journal. A
1379
1507
  // crash/lost response can then query using precisely the same identity.
1380
- const info = await this.openclawClient.getSessionInfo(item.sessionKey).catch(() => null);
1508
+ const info = await this.openclawClient.getSessionInfo(item.sessionKey, 1500).catch(() => null);
1381
1509
  if (!stillCurrent())
1382
1510
  continue;
1383
1511
  const sessionId = info?.session?.sessionId;
@@ -1427,19 +1555,20 @@ export class FeishuBot {
1427
1555
  continue;
1428
1556
  const valid = receipt.insertionId === item.insertionId && receipt.expectedRunId === item.expectedRunId && (!receipt.sessionId || receipt.sessionId === item.sessionId);
1429
1557
  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.
1558
+ // Explicit no-effect plus confirmed current schedulability can hand
1559
+ // this SAME input back once without claiming old task settlement.
1560
+ // Time elapsed and attempted-unknown receipts never authorize replay.
1433
1561
  if (valid && receipt.attempted === false && receipt.ordinaryEligible !== false && ['pending', 'target_ended'].includes(state) &&
1434
1562
  ['target_not_ready', 'backend_rejected_no_effect', 'original run ended without an insertion attempt'].includes(receipt.reason || '')) {
1435
1563
  const status = await this.openclawClient.getSessionRuntimeStatus(item.sessionKey).catch(() => null);
1436
1564
  if (!stillCurrent())
1437
1565
  continue;
1438
- const rootSettled = await this.openclawClient.exactRunSettled?.(item.expectedRunId);
1566
+ const activity = status?.idleConfirmed ? await this.openclawClient.getSessionDispatchStatus?.(item.sessionKey).catch(() => null) : null;
1439
1567
  if (!stillCurrent())
1440
1568
  continue;
1569
+ const currentIdle = activity?.idleConfirmed === true && !activity.busyConfirmed && activity.sessionId === item.sessionId && !activity.archived && status?.sessionId === item.sessionId;
1441
1570
  const unchanged = this.queueOwners.get(chatId) === owner && owner?.batch === batchAtCheck && owner?.runId === runAtCheck;
1442
- if (rootSettled === true && status?.idleConfirmed === true && unchanged) {
1571
+ if (currentIdle && status?.idleConfirmed === true && unchanged) {
1443
1572
  state = 'ordinary';
1444
1573
  // Never expire a successor merely to schedule the old input.
1445
1574
  if (runAtCheck === item.expectedRunId)
@@ -1454,16 +1583,16 @@ export class FeishuBot {
1454
1583
  state = 'pending';
1455
1584
  // Unknown is the NORMAL transient state right after a steer attempt
1456
1585
  // (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.
1586
+ // every insertion spams the chat. Use real elapsed time from the first
1587
+ // unknown observation, not query count, and commit one reminder intent.
1460
1588
  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 };
1589
+ const record = { ...item, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks,
1590
+ unknownSince: state === 'unknown' ? (item.state === 'unknown' && Number.isFinite(item.unknownSince) ? item.unknownSince : Date.now()) : undefined };
1462
1591
  this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, state, record, Date.now() + (state === 'unknown' ? 15_000 : 5000));
1463
1592
  if (state === 'ordinary') {
1464
1593
  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(() => { });
1594
+ void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:ordinary`, 'ℹ️ 当前会话已确认可接收新轮次,且这条消息确定未被插入;现已交回普通队列,只执行一次。', [], item.messageId, `insertion:${item.insertionId}:ordinary`).catch(() => { });
1595
+ void this.processQueue(chatId, true).catch(() => { });
1467
1596
  }
1468
1597
  else if (state === 'accepted') {
1469
1598
  this.store.deletePendingInsertionNotices(this.config.name, chatId, item.insertionId);
@@ -1487,17 +1616,15 @@ export class FeishuBot {
1487
1616
  if (item.state !== state)
1488
1617
  void this.enqueueAndDispatchDelivery(chatId, 'insertion_status', `insertion:${item.insertionId}:${state}`, `ℹ️ 这条插入消息的状态为 ${state},仍保留本地记录,未确认接收前不会显示 Get,也不会自动重复执行。${receipt.reason || ''}`, [], item.messageId, `insertion:${item.insertionId}:${state}`).catch(() => { });
1489
1618
  }
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(() => { });
1619
+ else if (state === 'unknown' && Date.now() - record.unknownSince >= 45_000 && !item.unknownNotified) {
1620
+ const text = 'ℹ️ 这条插入消息约 45 秒仍未确认接收:本地记录保留中,不会自动重复执行;确认后会自动显示 Get。';
1621
+ const key = `insertion:${item.insertionId}:unknown-prolonged`;
1622
+ if (this.store.queueInsertionUnknownNotice(this.config.name, chatId, item.rowId, {
1623
+ sessionKey: this.getSessionKey(chatId), chatId, botName: this.config.name, sourceType: 'insertion_status', sourceId: key, deliveryKey: key,
1624
+ contentHash: this.stableHash(`${text}|[]`), content: text, attachmentsJson: '[]', replyToMessageId: item.messageId,
1625
+ deliveryMetaJson: JSON.stringify({ botIdentity: this.config.appId, insertionNotice: { rowId: item.rowId, insertionId: item.insertionId, sessionId: item.sessionId, stopEpoch: item.stopEpoch } }),
1626
+ }))
1627
+ void this.dispatchPendingDeliveries(chatId).catch(() => { });
1501
1628
  }
1502
1629
  }
1503
1630
  }
@@ -1624,12 +1751,46 @@ export class FeishuBot {
1624
1751
  catch { /* observer failures are isolated */ }
1625
1752
  }
1626
1753
  }
1627
- async processQueue(chatId) {
1754
+ async processQueue(chatId, activityChecked = false) {
1628
1755
  const existing = this.queueRuns.get(chatId);
1629
1756
  if (existing) {
1630
1757
  console.log(`[${this.config.name}] processQueue already running for ${chatId.slice(-8)}, joining existing run`);
1631
1758
  return existing;
1632
1759
  }
1760
+ const detached = this.store.getDispatchCustody(this.config.name, chatId);
1761
+ if (detached) {
1762
+ // Startup/recovery can reach the queue without an ingress handler. Pin
1763
+ // unsent triggers synchronously before consulting live host activity.
1764
+ for (const message of this.store.getPendingTriggerMessages(this.config.name, chatId)) {
1765
+ if (!message.id || message.senderType !== 'human' || this.store.getInsertion(this.config.name, chatId, message.id))
1766
+ continue;
1767
+ this.store.saveInsertion(this.config.name, chatId, message.id, JSON.stringify([this.config.appId, chatId, message.messageId]), 'pending', {
1768
+ messageId: message.messageId, text: message.content, stopEpoch: this.stopGeneration(chatId), expectedRunId: detached.runId,
1769
+ ownerId: detached.id, batchId: detached.batchId, sessionId: detached.sessionId, sessionKey: this.getSessionKey(chatId),
1770
+ });
1771
+ }
1772
+ if (await this.reconcileDispatchCustody(chatId))
1773
+ return this.processQueue(chatId, true);
1774
+ void this.reconcileInsertions(chatId).catch(() => { });
1775
+ this.scheduleInsertionRecovery();
1776
+ return;
1777
+ }
1778
+ if (!activityChecked && typeof this.openclawClient.getSessionRuntimeStatus === 'function' && typeof this.openclawClient.getSessionDispatchStatus === 'function') {
1779
+ const candidates = this.store.getPendingTriggerMessages(this.config.name, chatId)
1780
+ .filter(message => message.id && message.senderType === 'human' && !this.store.isInsertionReserved(this.config.name, chatId, message.id));
1781
+ if (!candidates.length)
1782
+ return;
1783
+ for (const message of candidates)
1784
+ await this.reconcileCurrentInputActivity(chatId, message.id, message.messageId, message.content);
1785
+ // Re-enter through the ordinary owner CAS after all awaits. Any concurrent
1786
+ // admission now owns its batch; never submit in parallel with that owner.
1787
+ 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));
1788
+ if (eligible)
1789
+ return this.processQueue(chatId, true);
1790
+ void this.reconcileInsertions(chatId).catch(() => { });
1791
+ this.scheduleInsertionRecovery();
1792
+ return;
1793
+ }
1633
1794
  const owner = { id: randomUUID(), token: {}, revoked: false, stopEpoch: this.stopGeneration(chatId) };
1634
1795
  this.queueOwners.set(chatId, owner);
1635
1796
  const run = this.processQueueInner(chatId, owner).finally(() => {
@@ -1655,13 +1816,16 @@ export class FeishuBot {
1655
1816
  * the proactive path are invisible to them. Steer-fallback triggers are
1656
1817
  * restored via the failure observers so their input drains normally.
1657
1818
  */
1658
- forceExpireQueueOwner(chatId, reason) {
1819
+ forceExpireQueueOwner(chatId, reason, preserveDispatch = false) {
1659
1820
  const owner = this.queueRuns.get(chatId);
1660
1821
  if (!owner)
1661
1822
  return false;
1662
1823
  const context = this.queueOwners.get(chatId);
1824
+ if (context?.batchId && !preserveDispatch)
1825
+ this.store.clearDispatchCustody(this.config.name, chatId, context.id, context.batchId);
1663
1826
  if (context)
1664
1827
  context.revoked = true;
1828
+ this.busyChats.set(chatId, 0);
1665
1829
  console.warn(`[${this.config.name}] force-expiring zombie queue owner for ${chatId.slice(-8)}: ${reason}`);
1666
1830
  // Fence the stale batch's local observation BEFORE releasing custody: an
1667
1831
  // alive old poller would keep destructively consuming the replacement
@@ -1689,6 +1853,10 @@ export class FeishuBot {
1689
1853
  owner.batchId = randomUUID();
1690
1854
  owner.triggerIds = undefined;
1691
1855
  owner.runId = undefined;
1856
+ owner.admitted = false;
1857
+ owner.collecting = false;
1858
+ owner.yielded = false;
1859
+ owner.sessionId = undefined;
1692
1860
  this.queueRunTokens.set(chatId, batchToken);
1693
1861
  // A fresh batch starts: steered inputs admitted into the PREVIOUS batch
1694
1862
  // completed with it (consumed or not); their failure observers are stale.
@@ -1846,6 +2014,9 @@ export class FeishuBot {
1846
2014
  try {
1847
2015
  sessionKey = await this.ensureSession(chatId);
1848
2016
  guard();
2017
+ const generation = await this.openclawClient.getSessionInfo(sessionKey, 1500).catch(() => null);
2018
+ guard();
2019
+ owner.sessionId = generation?.session?.sessionId;
1849
2020
  }
1850
2021
  catch (err) {
1851
2022
  if (!current())
@@ -1933,11 +2104,14 @@ export class FeishuBot {
1933
2104
  // OpenClaw accepts it, retrying would duplicate the same message.
1934
2105
  markSubmittedBatch("send-attempt");
1935
2106
  },
1936
- onRunIdentified: (runId) => { guard(); owner.runId = runId; foregroundRunId = runId; },
2107
+ onRunIdentified: (runId, identity) => { guard(); owner.runId = runId; owner.sessionId = identity?.sessionId || owner.sessionId; foregroundRunId = runId; },
1937
2108
  onSubmitted: async (runId) => {
1938
2109
  guard();
1939
2110
  owner.runId = runId;
2111
+ owner.admitted = true;
2112
+ this.store.saveDispatchCustody(this.config.name, chatId, { id: owner.id, batchId: owner.batchId, runId, sessionId: owner.sessionId, admitted: true, stopEpoch: owner.stopEpoch, sessionKey });
1940
2113
  void this.reconcileInsertions(chatId).catch(() => { });
2114
+ this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || '', liveStatus });
1941
2115
  for (const ack of this.pendingAckMessages.get(chatId) || []) {
1942
2116
  if (!mergedTriggerIdSet.has(ack.rowId))
1943
2117
  continue;
@@ -1960,6 +2134,21 @@ export class FeishuBot {
1960
2134
  markSubmittedBatch(`submitted:${runId}`);
1961
2135
  },
1962
2136
  onProgress: (event) => current() ? liveStatus?.progress(event) : undefined,
2137
+ onCollectionStarted: runId => { guard(); if (owner.runId === runId)
2138
+ owner.collecting = true; },
2139
+ onYieldHandoff: runId => {
2140
+ guard();
2141
+ if (owner.runId !== runId || !owner.batchId)
2142
+ throw new Error('Yield root/batch identity mismatch');
2143
+ // All durable result routing commits synchronously before the
2144
+ // collector is fenced and before another input may take custody.
2145
+ this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || '', liveStatus });
2146
+ owner.yielded = true;
2147
+ 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 });
2148
+ this.forceExpireQueueOwner(chatId, 'trusted exact-root yield; background result custody retained', true);
2149
+ void this.reconcileInsertions(chatId).catch(() => { });
2150
+ this.scheduleInsertionRecovery();
2151
+ },
1963
2152
  onWaitPaused: async (notice) => {
1964
2153
  guard();
1965
2154
  await liveStatus?.showWaitingForResult(notice.message);
@@ -2199,6 +2388,10 @@ export class FeishuBot {
2199
2388
  }
2200
2389
  catch (err) {
2201
2390
  if (!current() || err instanceof SupersededObservation) {
2391
+ if (current()) {
2392
+ this.busyChats.set(chatId, 0);
2393
+ owner.revoked = true;
2394
+ }
2202
2395
  // This batch's local observation was fenced by a newer batch (or a
2203
2396
  // custody revocation). The new owner handles delivery from here; end
2204
2397
  // quietly — no error notice, no replay, no Gateway abort. The old
@@ -2305,8 +2498,11 @@ export class FeishuBot {
2305
2498
  break;
2306
2499
  }
2307
2500
  finally {
2308
- if (current())
2501
+ if (current()) {
2309
2502
  this.busyChats.set(chatId, 0);
2503
+ if (owner.batchId && !owner.yielded)
2504
+ this.store.clearDispatchCustody(this.config.name, chatId, owner.id, owner.batchId);
2505
+ }
2310
2506
  }
2311
2507
  // Check if more messages arrived while we were busy
2312
2508
  // Small delay to let any in-flight messages settle
@@ -2803,7 +2999,7 @@ export class FeishuBot {
2803
2999
  const previous = this.deliveryTargetsByRun.get(key);
2804
3000
  if (previous?.timer)
2805
3001
  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:[^:]+:/, '')}` });
3002
+ 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
3003
  this.deliveryTargetsByRun.set(key, {
2808
3004
  triggerId: target.triggerId,
2809
3005
  messageId: target.messageId,
@@ -3162,7 +3358,7 @@ export class FeishuBot {
3162
3358
  this.store.saveErrorNotice(record);
3163
3359
  if (verdict.state === "terminal" && verdict.terminalKey && verdict.text) {
3164
3360
  // 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);
3361
+ 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
3362
  // The send-time recheck may have parked or suppressed the observation.
3167
3363
  const refreshed = this.store.getErrorNotice(this.config.name, chatId, record.noticeId);
3168
3364
  if (refreshed?.verdict && ["pending", "suppressed", "stopped"].includes(refreshed.verdict.state))
@@ -3212,6 +3408,14 @@ export class FeishuBot {
3212
3408
  if (this.store.getDeliveryByKey(this.config.name, chatId, source)?.status !== "delivered")
3213
3409
  throw new Error("Go executor outbox delivery pending/unconfirmed");
3214
3410
  }
3411
+ insertionNoticeCurrent(chatId, notice) {
3412
+ if (!notice)
3413
+ return true;
3414
+ const current = this.store.getInsertion(this.config.name, chatId, notice.rowId);
3415
+ return current?.insertionId === notice.insertionId && current.state === 'unknown' && current.stopEpoch === notice.stopEpoch &&
3416
+ this.stopGeneration(chatId) === notice.stopEpoch && current.sessionId === notice.sessionId &&
3417
+ Number.isFinite(current.unknownSince) && Date.now() - current.unknownSince >= 45_000;
3418
+ }
3215
3419
  async dispatchPendingDeliveries(chatId, replyToMessageId) {
3216
3420
  this.installGoReceiptCapture();
3217
3421
  for (const id of this.localDeliveryRetryCustody.get(chatId) || []) {
@@ -3230,6 +3434,20 @@ export class FeishuBot {
3230
3434
  if (pending.length === 0)
3231
3435
  break;
3232
3436
  for (const item of pending) {
3437
+ const notice = JSON.parse(item.deliveryMetaJson || '{}').insertionNotice;
3438
+ if (notice) {
3439
+ const info = await this.openclawClient.getSessionInfo(this.getSessionKey(chatId), 1500).catch(() => null);
3440
+ if (!this.insertionNoticeCurrent(chatId, notice) || info?.session?.archived === true ||
3441
+ info?.session?.sessionId && info.session.sessionId !== notice.sessionId) {
3442
+ this.store.markDeliveryFailed(item.id);
3443
+ continue;
3444
+ }
3445
+ if (!info?.session?.sessionId) {
3446
+ this.scheduleDeliveryRetry(chatId, 1);
3447
+ retryScheduled = true;
3448
+ continue;
3449
+ }
3450
+ }
3233
3451
  const ownerId = JSON.parse(item.deliveryMetaJson || "{}").provenance?.goJobId;
3234
3452
  const deliveryJob = ownerId ? this.store.getGoJobById(ownerId) : this.store.getActiveGoJob(this.config.name, chatId);
3235
3453
  const receipts = [
@@ -3238,6 +3456,8 @@ export class FeishuBot {
3238
3456
  ];
3239
3457
  await this.sendOrdered(chatId, () => this.goDeliveryReceipts.run({ receipts, goId: deliveryJob?.id, outboxId: item.id, chatId, stage: "body", ordinals: new Map(), effectGuard: () => {
3240
3458
  const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
3459
+ if (!this.insertionNoticeCurrent(chatId, meta.insertionNotice))
3460
+ throw Object.assign(new Error('Insertion notice no longer current'), { deliveryStopped: true });
3241
3461
  if ((p?.final || item.sourceType === 'run_error') &&
3242
3462
  (typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
3243
3463
  p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
@@ -3380,6 +3600,12 @@ export class FeishuBot {
3380
3600
  }
3381
3601
  }
3382
3602
  this.store.markDeliveryDelivered(item.id);
3603
+ if (item.sourceType === 'run_error' && provenance?.terminalEvidence?.state === 'terminal' && provenance.runId && !provenance.runId.startsWith('announce:')) {
3604
+ this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3605
+ if (provenance.sessionKey)
3606
+ this.store.finishRunObservation(provenance.sessionKey, provenance.runId);
3607
+ void this.reconcileReactions(chatId).catch(() => { });
3608
+ }
3383
3609
  if (provenance?.final && provenance.runId) {
3384
3610
  this.store.settleRunReactionIntents(this.config.name, chatId, provenance.runId);
3385
3611
  void this.reconcileReactions(chatId).catch(() => { });
@@ -4887,7 +5113,10 @@ export class FeishuBot {
4887
5113
  this.store.stopInsertions(this.config.name, chatId);
4888
5114
  this.store.cancelReactionIntents(this.config.name, chatId, this.stopGeneration(chatId));
4889
5115
  void this.openclawClient.cancelSteerSession?.(sessionKey, this.stopGeneration(chatId)).catch(() => { });
4890
- // Fire reset (no response expected)
5116
+ this.forceExpireQueueOwner(chatId, 'explicit session reset');
5117
+ this.store.clearAllPendingTriggers(this.config.name, chatId);
5118
+ // Fire reset (no response expected). Client fences old exact observations
5119
+ // and exposes its reset barrier to new post-cutoff human inputs.
4891
5120
  this.openclawClient.resetSession(sessionKey).catch(() => { });
4892
5121
  // Wait a moment for it to take effect
4893
5122
  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.