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.
- package/dist/feishu-bot.d.ts +20 -5
- package/dist/feishu-bot.js +336 -68
- package/dist/message-store.d.ts +8 -1
- package/dist/message-store.js +30 -5
- package/dist/openclaw-client.d.ts +37 -9
- package/dist/openclaw-client.js +73 -6
- package/docs/releases/1.8.16.md +26 -0
- package/package.json +3 -2
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -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
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
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
|
-
//
|
|
1315
|
-
//
|
|
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
|
-
|
|
1390
|
-
const idle = compatible && !status.running && !
|
|
1391
|
-
|
|
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 &&
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1403
|
-
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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:
|
|
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 ? {
|
|
1425
|
-
|
|
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.
|
|
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
|
|
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
|
|
1554
|
+
if (!unchanged() || !status || status.archived || status.sessionId !== saved.sessionId)
|
|
1448
1555
|
return false;
|
|
1449
|
-
if (
|
|
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
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
//
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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,
|
|
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
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
1900
|
-
*
|
|
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}]
|
|
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
|
-
|
|
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);
|
package/dist/message-store.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/message-store.js
CHANGED
|
@@ -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')
|
|
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
|
-
/**
|
|
296
|
-
*
|
|
297
|
-
|
|
298
|
-
|
|
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. */
|
package/dist/openclaw-client.js
CHANGED
|
@@ -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
|
-
/**
|
|
2263
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|