openclaw-lark-multi-agent 1.8.3 → 1.8.5

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.
@@ -25,6 +25,14 @@ export class SupersededObservation extends Error {
25
25
  this.name = "SupersededObservation";
26
26
  }
27
27
  }
28
+ export class AdmissionUnconfirmed extends Error {
29
+ submissionId;
30
+ constructor(submissionId, cause) {
31
+ super('消息提交结果尚未确认;本地保留原提交身份,不会自动重复发送。', { cause });
32
+ this.submissionId = submissionId;
33
+ this.name = 'AdmissionUnconfirmed';
34
+ }
35
+ }
28
36
  export const GATEWAY_PROTOCOL_MIN = 4;
29
37
  export const GATEWAY_PROTOCOL_MAX = 4;
30
38
  const REQUIRED_GATEWAY_METHODS = [
@@ -74,8 +82,10 @@ export class OpenClawClient {
74
82
  /** Deduplicates 2026.8 canonical `agent.stream=tool` events against legacy
75
83
  * `stream=item` mirrors when a runtime emits both representations. */
76
84
  recentToolEventKeys = new Map();
85
+ sessionSettlementCallbacks = new Map();
77
86
  sessionMessageCallbacks = new Map();
78
87
  progressCallbacks = new Map();
88
+ progressGenerations = new Map();
79
89
  /** Live collectReply fences per canonical session key. The most recent
80
90
  * collector for a session owns the shared event bucket; a new batch (or a
81
91
  * queue-custody revocation) fences stale ones synchronously so they cannot
@@ -113,6 +123,8 @@ export class OpenClawClient {
113
123
  isExplicitlyStoppedRun(sessionKey, runId) {
114
124
  if (!runId)
115
125
  return false;
126
+ if (this.recoveryStore?.isStoppedObservation(this.canonicalSessionKey(sessionKey), runId))
127
+ return true;
116
128
  const key = JSON.stringify([this.canonicalSessionKey(sessionKey), runId]);
117
129
  const at = this.explicitlyStoppedRuns.get(key);
118
130
  if (at === undefined)
@@ -134,6 +146,89 @@ export class OpenClawClient {
134
146
  maintenanceConcurrency = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAINTENANCE_CONCURRENCY || 1);
135
147
  activeMaintenanceRpcs = 0;
136
148
  maintenanceWaiters = [];
149
+ observationEpoch = new Map();
150
+ stopGenerations = new Map();
151
+ recoveryStore;
152
+ recoveryTimer;
153
+ recoveryChecking = false;
154
+ eventCollectors = new Set();
155
+ forwardedFinals = new Map();
156
+ /** One paced recovery scheduler for all persisted exact runs. No business
157
+ * replay, no per-run infinite timers, and each query has its own deadline. */
158
+ bindRecoveryStore(store) { this.recoveryStore = store; this.scheduleRecovery(); }
159
+ scheduleRecovery() {
160
+ if (this.recoveryTimer || !this.shouldReconnect || !this.recoveryStore)
161
+ return;
162
+ this.recoveryTimer = setTimeout(() => { this.recoveryTimer = undefined; void this.recoverExactRuns().finally(() => this.scheduleRecovery()); }, 30_000);
163
+ this.recoveryTimer.unref?.();
164
+ }
165
+ async recoverExactRuns() {
166
+ if (this.recoveryChecking || !this.recoveryStore)
167
+ return;
168
+ this.recoveryChecking = true;
169
+ try {
170
+ for (const item of this.recoveryStore.listRunObservations(undefined, Date.now(), 20)) {
171
+ // Advance every selected row, including temporarily unsubscribed/owned
172
+ // ones, so a due page cannot starve recoverable runs behind it.
173
+ this.recoveryStore.saveRunObservation(item.sessionKey, item.runId, item, Date.now() + 60_000);
174
+ if (!this.sessionMessageCallbacks.has(item.sessionKey) && !this.sessionMessageCallbacks.has(this.shortKey(item.sessionKey)))
175
+ continue;
176
+ if (this.ownedDeliveryRuns.has(item.runId))
177
+ continue;
178
+ try {
179
+ const snapshot = item.terminalObserved ? { runId: item.runId, status: 'ok', endedAt: Date.now(), terminalReply: { disposition: 'visible', text: item.terminalText } } : await this.rpc('agent.wait', { runId: item.runId, timeoutMs: 1 }, 2500);
180
+ if (this.recoveryStore.runObservationState(item.sessionKey, item.runId) !== 'observing' || this.isExplicitlyStoppedRun(item.sessionKey, item.runId) || this.ownedDeliveryRuns.has(item.runId))
181
+ continue;
182
+ if (snapshot?.runId !== item.runId || !Number.isFinite(snapshot.endedAt) || snapshot.endedAt <= 0 || snapshot.yielded || snapshot.pendingError || ['queue', 'gateway_draining'].includes(snapshot.timeoutPhase) || typeof snapshot.startedAt === 'number' && snapshot.endedAt < snapshot.startedAt)
183
+ continue;
184
+ const terminal = snapshot.terminalReply;
185
+ const callback = this.sessionMessageCallbacks.get(item.sessionKey) || this.sessionMessageCallbacks.get(this.shortKey(item.sessionKey));
186
+ if (['error', 'timeout'].includes(snapshot.status)) {
187
+ // endedAt + exact run is terminal evidence; bare query timeouts never get here.
188
+ await callback?.(String(snapshot.error || snapshot.status), { sourceType: 'run_error', sessionKey: item.sessionKey, runId: item.runId,
189
+ error: { sessionKey: item.sessionKey, runId: item.runId, source: 'chat', state: 'error', detail: String(snapshot.error || snapshot.status) } });
190
+ this.recoveryStore.finishRunObservation(item.sessionKey, item.runId);
191
+ continue;
192
+ }
193
+ if (snapshot.status !== 'ok' || !['visible', 'silent', 'empty'].includes(terminal?.disposition))
194
+ continue;
195
+ if (terminal.disposition === 'visible' && typeof terminal.text === 'string' && terminal.text.trim()) {
196
+ const callback = this.sessionMessageCallbacks.get(item.sessionKey) || this.sessionMessageCallbacks.get(this.shortKey(item.sessionKey));
197
+ await callback?.(terminal.text, { sessionKey: item.sessionKey, runId: item.runId, sessionId: item.sessionId, final: true });
198
+ }
199
+ if (terminal.disposition !== 'visible') {
200
+ this.recoveryStore.settleExactRunReactions(item.sessionKey, item.runId);
201
+ this.recoveryStore.finishRunObservation(item.sessionKey, item.runId);
202
+ await this.sessionSettlementCallbacks.get(this.canonicalSessionKey(item.sessionKey))?.({ sessionKey: item.sessionKey, runId: item.runId, sessionId: item.sessionId, final: true });
203
+ }
204
+ // Visible custody ends only when the receiver's durable outbox has
205
+ // claimed it. A callback resolving alone is not a delivery receipt.
206
+ }
207
+ catch { /* Query failed, not the task. Leave exact custody in SQLite. */ }
208
+ }
209
+ }
210
+ finally {
211
+ this.recoveryChecking = false;
212
+ }
213
+ }
214
+ bufferEvent(key, event) {
215
+ let bucket = this.agentEvents.get(key);
216
+ if (!bucket) {
217
+ bucket = [];
218
+ this.agentEvents.set(key, bucket);
219
+ }
220
+ bucket.push(event);
221
+ if (bucket.length > 2048)
222
+ bucket.splice(0, bucket.length - 2048);
223
+ if (this.agentEvents.size > 512) {
224
+ for (const candidate of this.agentEvents.keys()) {
225
+ if (candidate !== key && !this.sessionCollectorFences.has(this.canonicalSessionKey(candidate))) {
226
+ this.agentEvents.delete(candidate);
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ }
137
232
  constructor(config) {
138
233
  this.config = config;
139
234
  }
@@ -250,7 +345,7 @@ export class OpenClawClient {
250
345
  // v4: chat delta events carry deltaText (incremental text chunk)
251
346
  const deltaText = frame.payload?.deltaText;
252
347
  if (deltaText) {
253
- this.agentEvents.get(sk).push({
348
+ this.bufferEvent(sk, {
254
349
  ...frame.payload,
255
350
  stream: "chatDelta",
256
351
  data: {
@@ -271,7 +366,7 @@ export class OpenClawClient {
271
366
  }
272
367
  }
273
368
  // Store as a special chatFinal event (not assistant delta to avoid double-counting)
274
- this.agentEvents.get(sk).push({
369
+ this.bufferEvent(sk, {
275
370
  ...frame.payload,
276
371
  stream: "chatFinal",
277
372
  // Only the Gateway chat-final envelope confirms a handoff.
@@ -284,7 +379,7 @@ export class OpenClawClient {
284
379
  // must NOT be silently dropped — otherwise mid-run delta text gets
285
380
  // surfaced as if it were a normal reply and the run neither errors
286
381
  // nor retries. Record it so the collect loop can surface/handle it.
287
- this.agentEvents.get(sk).push({
382
+ this.bufferEvent(sk, {
288
383
  ...frame.payload,
289
384
  stream: "chatError",
290
385
  data: { error: frame.payload?.errorMessage || frame.payload?.error || "chat error" },
@@ -292,9 +387,18 @@ export class OpenClawClient {
292
387
  }
293
388
  }
294
389
  else {
295
- this.agentEvents.get(sk).push(frame.payload);
390
+ this.bufferEvent(sk, frame.payload);
296
391
  }
297
392
  }
393
+ if (frame.type === "event" && ["agent", "chat"].includes(frame.event)) {
394
+ for (const wake of this.eventCollectors)
395
+ wake();
396
+ // Bound disconnected/unobserved event buffers. Terminal recovery uses
397
+ // exact public agent.wait, never an unbounded transcript cache.
398
+ for (const [key, bucket] of this.agentEvents)
399
+ if (bucket.length > 2048)
400
+ bucket.splice(0, bucket.length - 2048);
401
+ }
298
402
  if (frame.type === "event" && ["agent", "chat", "session.message"].includes(frame.event)) {
299
403
  for (const observer of this.goEvidenceObservers) {
300
404
  try {
@@ -319,7 +423,7 @@ export class OpenClawClient {
319
423
  if (visibleUserText) {
320
424
  if (!this.agentEvents.has(rawKey))
321
425
  this.agentEvents.set(rawKey, []);
322
- this.agentEvents.get(rawKey).push({
426
+ this.bufferEvent(rawKey, {
323
427
  runId: frame.payload.runId,
324
428
  sessionKey: rawKey,
325
429
  stream: "sessionUser",
@@ -335,7 +439,7 @@ export class OpenClawClient {
335
439
  if (visibleAssistantText) {
336
440
  if (!this.agentEvents.has(rawKey))
337
441
  this.agentEvents.set(rawKey, []);
338
- this.agentEvents.get(rawKey).push({
442
+ this.bufferEvent(rawKey, {
339
443
  runId: frame.payload.runId,
340
444
  sessionKey: rawKey,
341
445
  stream: "transcriptAssistant",
@@ -343,6 +447,8 @@ export class OpenClawClient {
343
447
  });
344
448
  }
345
449
  this.handleProactiveSessionMessage(rawKey, msg, frame.payload.runId, frame.payload.sessionId);
450
+ for (const wake of this.eventCollectors)
451
+ wake();
346
452
  // Tool calls in assistant messages — skip, using agent item events instead
347
453
  // (session.message toolCall events are batched, not real-time)
348
454
  }
@@ -675,7 +781,7 @@ export class OpenClawClient {
675
781
  const deltaText = !fullText && typeof data.delta === "string" ? data.delta : !fullText && typeof data.deltaText === "string" ? data.deltaText : "";
676
782
  const key = rawKey;
677
783
  const previous = this.verboseAssistantLatest.get(key) || "";
678
- const text = (fullText || (previous + deltaText)).trim();
784
+ const text = (fullText || (previous + deltaText)).trim().slice(-1024 * 1024);
679
785
  if (!text)
680
786
  return;
681
787
  const now = Date.now();
@@ -720,15 +826,10 @@ export class OpenClawClient {
720
826
  console.log(`[OpenClaw] Dropping proactive transcript msg for ${shortKey}; waiting for final delivery path`);
721
827
  return false;
722
828
  }
723
- const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
724
- if (cb) {
725
- if (runId)
726
- cb(proactiveText, { runId, sessionId, sessionKey: this.canonicalSessionKey(rawKey), final: msg?.stopReason === "stop",
727
- ...(!msg?.stopReason && genericFailure(proactiveText) ? { genericErrorCandidate: true } : {}) });
728
- else
729
- cb(proactiveText);
730
- }
731
- return Boolean(cb);
829
+ if (runId && this.isExplicitlyStoppedRun(rawKey, runId))
830
+ return false;
831
+ return this.emitProactiveForSession(rawKey, proactiveText, runId ? { runId, sessionId, sessionKey: this.canonicalSessionKey(rawKey), final: msg?.stopReason === 'stop',
832
+ ...(!msg?.stopReason && genericFailure(proactiveText) ? { genericErrorCandidate: true } : {}) } : undefined);
732
833
  }
733
834
  scheduleReconnect() {
734
835
  const delay = this.reconnectDelay;
@@ -788,6 +889,11 @@ export class OpenClawClient {
788
889
  let chatFinalText = "";
789
890
  let chatFinalSeen = false;
790
891
  let transcriptAssistantText = "";
892
+ let draftOverflow = false;
893
+ const boundedDraft = (value) => { if (value.length > 1024 * 1024) {
894
+ draftOverflow = true;
895
+ return value.slice(-1024 * 1024);
896
+ } return value; };
791
897
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
792
898
  let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
793
899
  let chatFinalTimer = null;
@@ -803,6 +909,7 @@ export class OpenClawClient {
803
909
  let anchorSeen = !expectedUserText;
804
910
  const activeRunIds = new Set([runId]);
805
911
  let preAnchorEvents = [];
912
+ const foreignUserRuns = new Set();
806
913
  const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
807
914
  const isExpectedUserText = (value) => {
808
915
  const actual = normalizeText(value);
@@ -832,14 +939,14 @@ export class OpenClawClient {
832
939
  finish(chatFinalText);
833
940
  return;
834
941
  }
835
- if (error instanceof SessionWaitPaused && options?.onWaitPaused) {
942
+ if (error instanceof SessionWaitPaused) {
836
943
  // Foreground notice, background observation. Keep the original queue
837
944
  // owner/progress path so later input still steers into the same run.
838
945
  foregroundPaused = true;
839
946
  if (!waitNoticeSent) {
840
947
  waitNoticeSent = true;
841
948
  try {
842
- await options.onWaitPaused(error);
949
+ await options?.onWaitPaused?.(error);
843
950
  }
844
951
  catch {
845
952
  console.warn("[OpenClaw] wait notice delivery pending; observation retained");
@@ -852,8 +959,8 @@ export class OpenClawClient {
852
959
  }
853
960
  reject(error);
854
961
  };
855
- const checkRunState = async (idleStillCurrent = () => true) => {
856
- if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
962
+ const checkRunState = async (idleStillCurrent = () => true, force = false) => {
963
+ if (observationClosed || checkingState || !force && Date.now() - lastStateCheckAt < 60_000)
857
964
  return;
858
965
  checkingState = true;
859
966
  lastStateCheckAt = Date.now();
@@ -877,7 +984,9 @@ export class OpenClawClient {
877
984
  ? terminal.text
878
985
  : terminal?.disposition === "silent" || terminal?.disposition === "empty"
879
986
  ? "NO_REPLY"
880
- : this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
987
+ : "";
988
+ if (!visible && terminal?.disposition !== "visible")
989
+ return;
881
990
  resolve(visible || "NO_REPLY");
882
991
  }
883
992
  else if (snapshot.status === "error" || snapshot.status === "timeout") {
@@ -954,6 +1063,8 @@ export class OpenClawClient {
954
1063
  if (activityKeys.has(key))
955
1064
  return false;
956
1065
  activityKeys.add(key);
1066
+ if (activityKeys.size > 4096)
1067
+ activityKeys.delete(activityKeys.values().next().value);
957
1068
  return true;
958
1069
  };
959
1070
  const hasEffectiveActivity = (ev) => {
@@ -986,7 +1097,7 @@ export class OpenClawClient {
986
1097
  return false;
987
1098
  const previous = activityText.get(source) || "";
988
1099
  const next = replace ? chunk : previous + chunk;
989
- activityText.set(source, next);
1100
+ activityText.set(source, next.slice(-256 * 1024));
990
1101
  if (next === previous || (replace && previous.includes(next)))
991
1102
  return false;
992
1103
  // Replayed replacement snapshots and assistant/chat/transcript mirrors
@@ -1047,6 +1158,12 @@ export class OpenClawClient {
1047
1158
  };
1048
1159
  resetIdleTimer();
1049
1160
  const finish = (finalText) => {
1161
+ if (draftOverflow && finalText !== 'NO_REPLY' && finalText !== chatFinalText) {
1162
+ // Never publish a truncated accumulator. Keep exact result custody
1163
+ // until authoritative root terminalReply or full root chat final.
1164
+ void checkRunState(() => true, true);
1165
+ return;
1166
+ }
1050
1167
  clearTimeout(idleTimer);
1051
1168
  clearInterval(poller);
1052
1169
  if (chatFinalTimer)
@@ -1066,36 +1183,18 @@ export class OpenClawClient {
1066
1183
  // never deliver a reply on this connection, so stop waiting instead of
1067
1184
  // hanging until the idle timeout. Failing fast lets the bridge
1068
1185
  // release its busy lock so new messages are not stuck "waiting".
1069
- if (startedConnected && !this.connected) {
1070
- clearInterval(poller);
1071
- if (chatFinalTimer)
1072
- clearTimeout(chatFinalTimer);
1073
- if (lifecycleEndTimer)
1074
- clearTimeout(lifecycleEndTimer);
1075
- if (replayInvalidTimer)
1076
- clearTimeout(replayInvalidTimer);
1077
- if (genericChatErrorTimer)
1078
- clearTimeout(genericChatErrorTimer);
1079
- if (idleTimer)
1080
- clearTimeout(idleTimer);
1081
- const salvaged = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1082
- if (salvaged) {
1083
- resolve(salvaged);
1084
- return;
1085
- }
1086
- reject(new Error("Gateway disconnected during run"));
1087
- return;
1088
- }
1186
+ // A disconnect is an observation gap, not an execution failure. The
1187
+ // connection resubscribes and the paced exact-run query reconciles it.
1089
1188
  // /stop force-abort: finish immediately instead of waiting for the
1090
1189
  // cancelled lifecycle to be treated as a transient state (idle timeout).
1091
1190
  if (sessionKey && (this.forceAbortedSessions.has(sessionKey) || this.forceAbortedSessions.has(shortSessionKey))) {
1092
1191
  this.forceAbortedSessions.delete(sessionKey);
1093
1192
  this.forceAbortedSessions.delete(shortSessionKey);
1094
- finish(text || chatDeltaText || chatFinalText || transcriptAssistantText || "NO_REPLY");
1193
+ finish("NO_REPLY");
1095
1194
  return;
1096
1195
  }
1097
1196
  const bucketsToScan = sessionKey
1098
- ? Array.from(new Set([sessionKey, shortSessionKey].filter(Boolean)))
1197
+ ? Array.from(new Set([sessionKey, shortSessionKey, ...[...this.agentEvents.keys()].filter(k => this.shortKey(k) === shortSessionKey && this.agentEvents.get(k)?.some(ev => activeRunIds.has(ev.runId)))].filter(Boolean)))
1099
1198
  : Array.from(this.agentEvents.keys());
1100
1199
  for (const bucketKey of bucketsToScan) {
1101
1200
  const bucket = this.agentEvents.get(bucketKey);
@@ -1105,17 +1204,26 @@ export class OpenClawClient {
1105
1204
  while (i < bucket.length) {
1106
1205
  const ev = bucket[i];
1107
1206
  const evRunId = typeof ev.runId === "string" ? ev.runId : "";
1108
- const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey || ev.sessionKey === shortSessionKey));
1207
+ const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey || ev.sessionKey === shortSessionKey || activeRunIds.has(evRunId) && this.shortKey(ev.sessionKey || '') === shortSessionKey));
1109
1208
  const matchesRun = evRunId ? activeRunIds.has(evRunId) : false;
1110
1209
  if (options?.exactRunIdentity && !matchesRun) {
1111
1210
  i++;
1112
1211
  continue;
1113
1212
  }
1213
+ if (evRunId && foreignUserRuns.has(evRunId)) {
1214
+ i++;
1215
+ continue;
1216
+ }
1114
1217
  if (!sessionKey && ev.sessionKey) {
1115
1218
  shortSessionKey = String(ev.sessionKey).replace(/^agent:[^:]+:/, "");
1116
1219
  sessionKey = `agent:main:${shortSessionKey}`;
1117
1220
  }
1118
1221
  if (eventSessionMatches && ev.stream === "sessionUser") {
1222
+ if (anchorSeen && evRunId && !matchesRun) {
1223
+ foreignUserRuns.add(evRunId);
1224
+ i++;
1225
+ continue;
1226
+ }
1119
1227
  bucket.splice(i, 1);
1120
1228
  if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
1121
1229
  anchorSeen = true;
@@ -1153,6 +1261,8 @@ export class OpenClawClient {
1153
1261
  if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
1154
1262
  bucket.splice(i, 1);
1155
1263
  preAnchorEvents.push(ev);
1264
+ if (preAnchorEvents.length > 2048)
1265
+ preAnchorEvents.splice(0, preAnchorEvents.length - 2048);
1156
1266
  if (preAnchorEvents.length > 200)
1157
1267
  preAnchorEvents.shift();
1158
1268
  continue;
@@ -1189,6 +1299,14 @@ export class OpenClawClient {
1189
1299
  continue;
1190
1300
  }
1191
1301
  bucket.splice(i, 1);
1302
+ // Continuation progress is valid, but only the admitted root's
1303
+ // committed terminalReply can authorize another run's final text.
1304
+ if (evRunId && evRunId !== runId && (ev.stream === 'chatFinal' || ev.stream === 'chatError' || ev.stream === 'lifecycle' && ['end', 'error'].includes(ev.data?.phase))) {
1305
+ if (ev.stream === 'chatFinal' && ev.data?.gatewayYielded === true)
1306
+ continue;
1307
+ void checkRunState(() => true, true);
1308
+ continue;
1309
+ }
1192
1310
  if (hasEffectiveActivity(ev))
1193
1311
  resetIdleTimer();
1194
1312
  rememberActivity(ev);
@@ -1209,30 +1327,30 @@ export class OpenClawClient {
1209
1327
  lifecycleStartedLogged = true;
1210
1328
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
1211
1329
  }
1212
- if ((ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") && (ev.data?.deltaText || ev.data?.delta)) {
1330
+ if ((!evRunId || evRunId === runId) && (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") && (ev.data?.deltaText || ev.data?.delta)) {
1213
1331
  const chunk = ev.data.deltaText || ev.data.delta;
1214
1332
  if (ev.stream === "assistant") {
1215
1333
  if (ev.data?.replace) {
1216
- text = chunk;
1334
+ text = boundedDraft(chunk);
1217
1335
  }
1218
1336
  else {
1219
- text += chunk;
1337
+ text = boundedDraft(text + chunk);
1220
1338
  }
1221
1339
  }
1222
1340
  else if (ev.stream === "chatDelta") {
1223
1341
  if (ev.data?.replace) {
1224
- chatDeltaText = chunk;
1342
+ chatDeltaText = boundedDraft(chunk);
1225
1343
  }
1226
1344
  else {
1227
- chatDeltaText += chunk;
1345
+ chatDeltaText = boundedDraft(chatDeltaText + chunk);
1228
1346
  }
1229
1347
  }
1230
1348
  else {
1231
1349
  if (ev.data?.replace) {
1232
- transcriptAssistantText = chunk;
1350
+ transcriptAssistantText = boundedDraft(chunk);
1233
1351
  }
1234
1352
  else {
1235
- transcriptAssistantText += chunk;
1353
+ transcriptAssistantText = boundedDraft(transcriptAssistantText + chunk);
1236
1354
  }
1237
1355
  }
1238
1356
  }
@@ -1258,8 +1376,7 @@ export class OpenClawClient {
1258
1376
  // Finish scanning this batch first so an already-arrived real
1259
1377
  // final/error wins over a stale waiting notice. No status RPC
1260
1378
  // (or ten-minute silence) is needed to confirm this wire fact.
1261
- if (options?.onWaitPaused)
1262
- void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
1379
+ void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
1263
1380
  continue;
1264
1381
  }
1265
1382
  chatFinalText = ev.data?.text || "";
@@ -1275,10 +1392,6 @@ export class OpenClawClient {
1275
1392
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1276
1393
  const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1277
1394
  // Never abort a yielded original run, nor an empty final.
1278
- if (!foregroundYielded && latestFinalText)
1279
- this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
1280
- console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, safeErrorSummary(err));
1281
- });
1282
1395
  if (latestFinalText) {
1283
1396
  finish(latestFinalText);
1284
1397
  }
@@ -1468,7 +1581,19 @@ export class OpenClawClient {
1468
1581
  }
1469
1582
  }
1470
1583
  };
1471
- const poller = setInterval(collectEvents, 50);
1584
+ let poller;
1585
+ const poll = () => {
1586
+ collectEvents();
1587
+ if (!observationClosed) {
1588
+ const quiet = Date.now() - (lastActivityAt || collectStartedAt);
1589
+ poller = setTimeout(poll, !startedConnected || quiet < 1000 ? 50 : quiet < 10_000 ? 1000 : 30_000);
1590
+ poller.unref?.();
1591
+ }
1592
+ };
1593
+ poller = setTimeout(poll, 50);
1594
+ const wakeCollector = () => { if (!observationClosed)
1595
+ collectEvents(); };
1596
+ this.eventCollectors.add(wakeCollector);
1472
1597
  const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1473
1598
  const checkAfterNoActiveRun = async () => {
1474
1599
  await checkRunState();
@@ -1487,13 +1612,10 @@ export class OpenClawClient {
1487
1612
  };
1488
1613
  if (observationKey)
1489
1614
  this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
1490
- // Bound local resources without cancelling an execution we cannot prove stuck.
1491
- const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
1492
- observationHorizon.unref?.();
1493
1615
  cleanupObservation = () => {
1494
1616
  observationClosed = true;
1495
1617
  clearTimeout(idleTimer);
1496
- clearTimeout(observationHorizon);
1618
+ this.eventCollectors.delete(wakeCollector);
1497
1619
  clearInterval(poller);
1498
1620
  if (chatFinalTimer)
1499
1621
  clearTimeout(chatFinalTimer);
@@ -1513,6 +1635,9 @@ export class OpenClawClient {
1513
1635
  const fenceSuperseded = () => {
1514
1636
  if (observationClosed)
1515
1637
  return;
1638
+ // Atomic custody transfer BEFORE closing the old consumer; a final
1639
+ // arriving in this tick is immediately eligible for exact proactive.
1640
+ this.releasePausedWait(runId);
1516
1641
  cleanupObservation();
1517
1642
  reject(new SupersededObservation());
1518
1643
  };
@@ -1655,47 +1780,52 @@ export class OpenClawClient {
1655
1780
  async injectAssistantMessage(params) {
1656
1781
  return this.rpc("chat.inject", { ...params, sessionKey: this.canonicalSessionKey(params.sessionKey) }, 10000);
1657
1782
  }
1658
- /**
1659
- * Submit a mid-run message through the lma-steer plugin.
1660
- *
1661
- * OpenClaw's public `chat.send { queueMode: "steer" }` returns `started` even
1662
- * when it falls through to a later ordinary run; it therefore cannot tell the
1663
- * bridge whether THIS message entered the active run. The plugin resolves the
1664
- * active embedded run and reports `steered` only when it actually queued there.
1665
- * A matching session.message remains the final consumption confirmation.
1666
- *
1667
- * If the plugin is missing, stale, or rejects the injection, return unavailable
1668
- * and let LMA's durable trigger run normally after the current turn. Never use
1669
- * the ambiguous public admission as a realtime-insertion success signal.
1670
- */
1671
- async steer(sessionKey, text, displayText) {
1672
- const key = this.canonicalSessionKey(sessionKey);
1673
- // Consumption can arrive before the plugin RPC response at a tool boundary.
1674
- const cancelPending = this.registerPendingSteer(key, text, displayText);
1783
+ /** Real-time session input uses the coordinated plugin's durable callback
1784
+ * receipt protocol. SDK eligibility/text mirrors never prove acceptance;
1785
+ * missing/legacy plugin observations stay pending without blind mutation. */
1786
+ async exactRunSettled(runId) {
1675
1787
  try {
1676
- const res = await this.rpc("lma.steer", { sessionKey: key, text }, 10000);
1677
- if (res?.status !== "steered") {
1678
- // A locally busy collector can outlive the actual Gateway turn. Check
1679
- // its exact terminal record now, rather than parking input for 30 min.
1680
- if (res?.status === "no_active_run")
1681
- await this.runStateChecks.get(key)?.();
1682
- if (res?.status && res.status !== "no_active_run") {
1683
- console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
1684
- }
1685
- cancelPending();
1686
- // Pass the gateway's authoritative "no active run" verdict through: the
1687
- // caller uses it to detect a zombie queue owner (2026-10-15 incident).
1688
- if (res?.status === "no_active_run")
1689
- return { status: "no_active_run" };
1690
- return { status: "unavailable" };
1691
- }
1692
- return { status: "steered", cancelPending };
1788
+ const r = await this.rpc('agent.wait', { runId, timeoutMs: 1 }, 2500);
1789
+ return r?.runId === runId && !r.pendingError && !r.yielded && ['ok', 'error', 'timeout'].includes(r.status) && Number.isFinite(r.endedAt) && r.endedAt > 0 && (r.status !== 'ok' || ['visible', 'silent', 'empty'].includes(r.terminalReply?.disposition));
1693
1790
  }
1694
- catch (err) {
1695
- cancelPending();
1696
- const message = err?.message || String(err);
1697
- console.warn(`[OpenClaw] lma.steer unavailable for ${key.slice(-8)}: ${message}`);
1698
- return { status: "unavailable" };
1791
+ catch {
1792
+ return false;
1793
+ }
1794
+ }
1795
+ async cancelSteerSession(sessionKey, stopEpoch) {
1796
+ await this.rpc('lma.steer.stop', { sessionKey: this.canonicalSessionKey(sessionKey), stopEpoch }, 2500);
1797
+ }
1798
+ async steer(sessionKey, text, displayText, identity) {
1799
+ if (!identity?.insertionId || !identity.expectedRunId)
1800
+ return { status: 'pending', attempted: false, reason: 'exact target not admitted yet' };
1801
+ let attempted = false;
1802
+ const base = { insertionId: identity.insertionId, expectedRunId: identity.expectedRunId, sessionId: identity.expectedSessionId };
1803
+ try {
1804
+ const capability = await this.rpc('lma.steer.capabilities', {}, 2500);
1805
+ // This is an LMA protocol-version check, NOT a fictitious source-CAS
1806
+ // requirement. Builtin callbacks can be used with atomicRunBound=false.
1807
+ if (capability?.protocol !== 2 || capability?.durableAttempts !== true || capability?.acceptanceCallback !== true)
1808
+ return { ...base, status: 'unavailable', attempted: identity.queryOnly === true, reason: 'Update the coordinated LMA plugin; legacy booleans are not acceptance receipts' };
1809
+ const info = await this.getSessionStatus(sessionKey);
1810
+ const currentId = info?.session?.sessionId;
1811
+ if (info?.session?.archived === true || (identity.expectedSessionId && currentId && currentId !== identity.expectedSessionId))
1812
+ return { ...base, status: 'target_changed', attempted: identity.queryOnly === true, reason: 'session archived or generation changed; no automatic migration' };
1813
+ if (!currentId)
1814
+ return { ...base, status: 'pending', attempted: identity.queryOnly === true, reason: 'session generation observation unavailable' };
1815
+ base.sessionId = currentId;
1816
+ attempted = true;
1817
+ const result = await this.rpc('lma.steer', { sessionKey: this.canonicalSessionKey(sessionKey), text, ...identity,
1818
+ expectedSessionId: identity.expectedSessionId || currentId, stopEpoch: identity.stopEpoch ?? 0 }, 2500);
1819
+ if (result?.insertionId !== identity.insertionId || result?.expectedRunId !== identity.expectedRunId || result?.sessionId !== currentId ||
1820
+ !['pending', 'accepted', 'rejected', 'unknown', 'target_changed', 'target_ended', 'stopped'].includes(result.status))
1821
+ return { ...base, status: 'unknown', attempted: true, reason: 'Invalid or legacy plugin receipt; not replayed' };
1822
+ if (result.status === 'pending' && result.attempted === false && result.reason === 'target_not_ready')
1823
+ await this.runStateChecks.get(this.canonicalSessionKey(sessionKey))?.();
1824
+ return result;
1825
+ }
1826
+ catch (error) {
1827
+ return { ...base, status: attempted ? 'unknown' : 'pending', attempted: attempted || identity.queryOnly === true,
1828
+ reason: attempted ? 'Insertion receipt unknown; query stable identity only' : 'Readiness observation unavailable; retained for paced recovery' };
1699
1829
  }
1700
1830
  }
1701
1831
  /** Callbacks fired when a steered message is confirmed consumed by the model
@@ -1705,8 +1835,8 @@ export class OpenClawClient {
1705
1835
  * Callbacks are one-shot: returning true removes them. */
1706
1836
  steerConsumedCallbacks = new Map();
1707
1837
  /** Register a callback (per bare session key) invoked when a steered message
1708
- * is actually consumed by the active run. The bridge uses this to flip the
1709
- * Feishu reaction from "Typing" (awaiting insertion) to "Get" (inserted).
1838
+ * is actually consumed by the active run. This legacy text observer is
1839
+ * presentation-only; it cannot authorize a receipt reaction.
1710
1840
  * Return true from the callback to consume/remove it (one-shot). */
1711
1841
  onSteerConsumed(sessionKey, matchText, cb) {
1712
1842
  const key = this.shortKey(sessionKey);
@@ -1848,6 +1978,7 @@ export class OpenClawClient {
1848
1978
  * stale poller cannot steal the replacement batch's frames (2026-10-16). */
1849
1979
  revokeSessionCollectors(sessionKey, reason) {
1850
1980
  const key = this.canonicalSessionKey(sessionKey);
1981
+ this.observationEpoch.set(key, (this.observationEpoch.get(key) || 0) + 1);
1851
1982
  const fences = this.sessionCollectorFences.get(key);
1852
1983
  if (!fences || fences.size === 0)
1853
1984
  return 0;
@@ -2070,6 +2201,9 @@ export class OpenClawClient {
2070
2201
  // chat.abort supports { sessionKey } with no runId to abort ALL active runs
2071
2202
  // for that session. Used by /stop to force-clear a stuck run.
2072
2203
  if (!runId) {
2204
+ this.stopGenerations.set(key, (this.stopGenerations.get(key) || 0) + 1);
2205
+ this.revokeSessionCollectors(key, 'explicit stop');
2206
+ this.recoveryStore?.stopSessionObservations(key);
2073
2207
  for (const [identity, at] of this.explicitlyStoppedRuns)
2074
2208
  if (Date.now() - at > 24 * 60 * 60_000)
2075
2209
  this.explicitlyStoppedRuns.delete(identity);
@@ -2094,6 +2228,7 @@ export class OpenClawClient {
2094
2228
  for (const stopped of result.runIds)
2095
2229
  if (typeof stopped === "string" && stopped) {
2096
2230
  this.explicitlyStoppedRuns.set(JSON.stringify([key, stopped]), Date.now());
2231
+ this.recoveryStore?.stopExactRun(key, stopped);
2097
2232
  }
2098
2233
  }
2099
2234
  return result;
@@ -2119,6 +2254,8 @@ export class OpenClawClient {
2119
2254
  // still reach the proactive callback.
2120
2255
  return;
2121
2256
  }
2257
+ if (runId && this.isExplicitlyStoppedRun(sessionKey, runId) && state === 'final')
2258
+ return;
2122
2259
  const stopReason = [payload?.stopReason, payload?.message?.stopReason].find(reason => reason === "error" || reason === "aborted")
2123
2260
  || payload?.stopReason || payload?.message?.stopReason;
2124
2261
  const isError = state === "error" || state === "aborted" || stopReason === "error" || stopReason === "aborted";
@@ -2163,11 +2300,31 @@ export class OpenClawClient {
2163
2300
  return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
2164
2301
  }
2165
2302
  emitProactiveForSession(sessionKey, text, meta) {
2166
- const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
2167
- const cb = this.sessionMessageCallbacks.get(fullKey) || this.sessionMessageCallbacks.get(shortKey);
2168
- if (!cb)
2303
+ const keys = this.sessionKeyVariants(sessionKey);
2304
+ const callback = keys.map(k => this.sessionMessageCallbacks.get(k)).find(Boolean);
2305
+ if (!callback)
2169
2306
  return false;
2170
- cb(text, meta);
2307
+ const identity = meta?.final && meta.runId ? JSON.stringify([this.canonicalSessionKey(sessionKey), meta.runId]) : '';
2308
+ const fingerprint = createHash('sha256').update(text).digest('hex');
2309
+ if (identity && this.forwardedFinals.get(identity) === fingerprint)
2310
+ return true;
2311
+ if (identity) {
2312
+ this.forwardedFinals.set(identity, fingerprint);
2313
+ if (this.forwardedFinals.size > 2048)
2314
+ this.forwardedFinals.delete(this.forwardedFinals.keys().next().value);
2315
+ }
2316
+ if (identity)
2317
+ this.recoveryStore?.saveRunObservation(this.canonicalSessionKey(sessionKey), meta.runId, { sessionId: meta?.sessionId, terminalText: text, terminalObserved: true }, Date.now() + 60_000);
2318
+ try {
2319
+ const result = meta ? callback(text, meta) : callback(text);
2320
+ if (identity)
2321
+ void Promise.resolve(result).catch(() => this.forwardedFinals.delete(identity));
2322
+ }
2323
+ catch (error) {
2324
+ if (identity)
2325
+ this.forwardedFinals.delete(identity);
2326
+ throw error;
2327
+ }
2171
2328
  return true;
2172
2329
  }
2173
2330
  suppressSessionKeys(keys) {
@@ -2421,31 +2578,44 @@ export class OpenClawClient {
2421
2578
  async chatSend(params) {
2422
2579
  const sk = this.shortKey(params.sessionKey);
2423
2580
  const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
2581
+ const generation = (this.observationEpoch.get(fullSessionKey) || 0) + 1;
2582
+ this.observationEpoch.set(fullSessionKey, generation);
2583
+ const stopGeneration = this.stopGenerations.get(fullSessionKey) || 0;
2584
+ const isCurrent = () => this.observationEpoch.get(fullSessionKey) === generation && (this.stopGenerations.get(fullSessionKey) || 0) === stopGeneration;
2585
+ const assertCurrent = () => { if (!isCurrent())
2586
+ throw new SupersededObservation('admission generation revoked'); };
2424
2587
  this.clearVerboseAssistantState(sk);
2588
+ const submissionId = randomUUID();
2425
2589
  let ownedRunId = "";
2426
2590
  let waitPaused = false;
2427
2591
  try {
2428
- // Drop stale buffered events for this session before starting a new run.
2429
- // This prevents an old final text (e.g. previous "ok") from being consumed by
2430
- // the next message while still allowing sessionKey matching for internal runIds.
2431
- this.agentEvents.set(fullSessionKey, []);
2432
- this.agentEvents.set(sk, []);
2592
+ // New user inputs arriving after /stop are admitted only AFTER that
2593
+ // authorized session-wide abort returns. They are not part of its cutoff.
2594
+ const stopBarrier = this.pendingExplicitStops.get(fullSessionKey);
2595
+ if (stopBarrier)
2596
+ await stopBarrier;
2597
+ assertCurrent();
2433
2598
  const releaseChatSendSlot = await this.acquireChatSendSlot();
2434
2599
  let result;
2435
2600
  const sendStartedAt = Date.now();
2436
2601
  try {
2437
- // A stale collector from a previous batch must not steal this batch's
2438
- // frames from the shared session bucket (2026-10-16 regression). Fence
2439
- // any still-live local observation BEFORE issuing chat.send.
2440
- this.revokeSessionCollectors(sk, "new chat.send supersedes stale observation");
2441
- await params.onSendAttempt?.();
2442
- const sendChat = () => this.rpc("chat.send", {
2443
- sessionKey: this.canonicalSessionKey(sk),
2444
- message: params.message,
2445
- attachments: params.attachments,
2446
- deliver: params.deliver ?? false,
2447
- idempotencyKey: randomUUID(),
2448
- });
2602
+ assertCurrent();
2603
+ this.recoveryStore?.saveAdmission(fullSessionKey, submissionId, 'reserved', { generation, stopGeneration });
2604
+ await params.onSendAttempt?.(submissionId);
2605
+ assertCurrent();
2606
+ const idempotencyKey = submissionId;
2607
+ const sendChat = () => {
2608
+ assertCurrent();
2609
+ this.recoveryStore?.saveAdmission(fullSessionKey, idempotencyKey, 'dispatching', { generation, stopGeneration, payloadDigest: createHash('sha256').update(params.message).digest('hex') });
2610
+ this.recoveryStore?.saveRunObservation(fullSessionKey, submissionId, { admissionOnly: true }, Date.now() + 30_000);
2611
+ return this.rpc("chat.send", {
2612
+ sessionKey: this.canonicalSessionKey(sk),
2613
+ message: params.message,
2614
+ attachments: params.attachments,
2615
+ deliver: params.deliver ?? false,
2616
+ idempotencyKey,
2617
+ });
2618
+ };
2449
2619
  try {
2450
2620
  result = await sendChat();
2451
2621
  }
@@ -2453,8 +2623,15 @@ export class OpenClawClient {
2453
2623
  // The Gateway auto-archives sessions past the active-session cap; an
2454
2624
  // archived session refuses new work. Restore it once and retry — the
2455
2625
  // user should never have to fix this by hand.
2456
- if (!this.isArchivedSessionError(err))
2457
- throw err;
2626
+ if (!this.isArchivedSessionError(err)) {
2627
+ const rejected = err instanceof Error && /^RPC error:/.test(err.message);
2628
+ this.recoveryStore?.saveAdmission(fullSessionKey, idempotencyKey, rejected ? 'rejected' : 'unknown', { generation, stopGeneration });
2629
+ if (rejected) {
2630
+ this.recoveryStore?.finishRunObservation(fullSessionKey, submissionId, 'rejected');
2631
+ throw err;
2632
+ }
2633
+ throw new AdmissionUnconfirmed(idempotencyKey, err);
2634
+ }
2458
2635
  console.warn(`[OpenClaw] chat.send hit archived session for ${sk}; restoring and retrying once`);
2459
2636
  const restored = await this.unarchiveSession(sk);
2460
2637
  if (!restored)
@@ -2466,18 +2643,49 @@ export class OpenClawClient {
2466
2643
  releaseChatSendSlot();
2467
2644
  }
2468
2645
  ownedRunId = typeof result?.runId === "string" ? result.runId : "";
2646
+ if (!ownedRunId)
2647
+ throw new AdmissionUnconfirmed('unidentified-receipt');
2648
+ this.recoveryStore?.saveAdmission(fullSessionKey, submissionId, ['started', 'ok'].includes(result.status) ? 'accepted' : 'unconfirmed', { generation, stopGeneration, runId: ownedRunId });
2649
+ if (ownedRunId !== submissionId)
2650
+ this.recoveryStore?.finishRunObservation(fullSessionKey, submissionId, 'identified');
2651
+ this.recoveryStore?.saveRunObservation(fullSessionKey, ownedRunId, { sessionId: result.sessionId });
2652
+ if (this.isExplicitlyStoppedRun(fullSessionKey, ownedRunId) || (this.stopGenerations.get(fullSessionKey) || 0) !== stopGeneration) {
2653
+ this.recoveryStore?.finishRunObservation(fullSessionKey, ownedRunId, 'stopped');
2654
+ throw new SupersededObservation('late stopped admission');
2655
+ }
2656
+ if (!isCurrent())
2657
+ throw new SupersededObservation('late admission is not current');
2658
+ // Commit replacement only AFTER confirmed admission. A failed new send
2659
+ // must leave the healthy old collector and its custody intact.
2660
+ const previous = this.sessionCollectorFences.get(fullSessionKey);
2661
+ if (previous)
2662
+ for (const fence of [...previous])
2663
+ fence();
2469
2664
  this.ownDeliveryRun(ownedRunId);
2470
2665
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
2471
- await params.onSubmitted?.(result.runId);
2666
+ await params.onRunIdentified?.(result.runId);
2667
+ assertCurrent();
2668
+ // A reservation/in_flight reply is not confirmed acceptance. Keep UI
2669
+ // Typing while exact collection/read-only receipt reconciliation proceeds.
2670
+ if (result.status === 'started' || result.status === 'ok' || Number.isSafeInteger(result.messageSeq) && result.messageSeq >= 0)
2671
+ await params.onSubmitted?.(result.runId);
2672
+ assertCurrent();
2472
2673
  if (params.onProgress) {
2473
2674
  this.progressCallbacks.set(sk, params.onProgress);
2474
2675
  this.progressCallbacks.set(fullSessionKey, params.onProgress);
2676
+ this.progressGenerations.set(sk, generation);
2677
+ this.progressGenerations.set(fullSessionKey, generation);
2475
2678
  void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
2476
2679
  }
2477
- return await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
2680
+ const reply = await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
2681
+ if (reply.trim().toUpperCase() === 'NO_REPLY')
2682
+ this.recoveryStore?.finishRunObservation(fullSessionKey, ownedRunId);
2683
+ else
2684
+ this.recoveryStore?.saveRunObservation(fullSessionKey, ownedRunId, { sessionId: result.sessionId, terminalText: reply, terminalObserved: true }, Date.now() + 30_000);
2685
+ return reply;
2478
2686
  }
2479
2687
  catch (error) {
2480
- waitPaused = error instanceof SessionWaitPaused;
2688
+ waitPaused = error instanceof SessionWaitPaused || error instanceof SupersededObservation;
2481
2689
  throw error;
2482
2690
  }
2483
2691
  finally {
@@ -2489,10 +2697,14 @@ export class OpenClawClient {
2489
2697
  // progress callback (the fence settles the old promise after the new
2490
2698
  // batch may already have registered its own callback).
2491
2699
  if (params.onProgress) {
2492
- if (this.progressCallbacks.get(sk) === params.onProgress)
2700
+ if (this.progressGenerations.get(sk) === generation && this.progressCallbacks.get(sk) === params.onProgress) {
2493
2701
  this.progressCallbacks.delete(sk);
2494
- if (this.progressCallbacks.get(fullSessionKey) === params.onProgress)
2702
+ this.progressGenerations.delete(sk);
2703
+ }
2704
+ if (this.progressGenerations.get(fullSessionKey) === generation && this.progressCallbacks.get(fullSessionKey) === params.onProgress) {
2495
2705
  this.progressCallbacks.delete(fullSessionKey);
2706
+ this.progressGenerations.delete(fullSessionKey);
2707
+ }
2496
2708
  }
2497
2709
  if (waitPaused)
2498
2710
  this.releasePausedWait(ownedRunId);
@@ -2548,6 +2760,7 @@ export class OpenClawClient {
2548
2760
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
2549
2761
  onSendAttempt: params.onSendAttempt,
2550
2762
  onSubmitted: params.onSubmitted,
2763
+ onRunIdentified: params.onRunIdentified,
2551
2764
  onProgress: params.onProgress,
2552
2765
  onWaitPaused: params.onWaitPaused,
2553
2766
  });
@@ -2585,6 +2798,7 @@ export class OpenClawClient {
2585
2798
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
2586
2799
  onSendAttempt: params.onSendAttempt,
2587
2800
  onSubmitted: params.onSubmitted,
2801
+ onRunIdentified: params.onRunIdentified,
2588
2802
  onProgress: params.onProgress,
2589
2803
  onWaitPaused: params.onWaitPaused,
2590
2804
  });
@@ -2683,6 +2897,11 @@ export class OpenClawClient {
2683
2897
  }
2684
2898
  async disconnect() {
2685
2899
  this.shouldReconnect = false;
2900
+ for (const key of [...this.sessionCollectorFences.keys()])
2901
+ this.revokeSessionCollectors(key, 'bridge disconnected; durable exact observation retained');
2902
+ if (this.recoveryTimer)
2903
+ clearTimeout(this.recoveryTimer);
2904
+ this.recoveryTimer = undefined;
2686
2905
  for (const timer of this.suppressedSessionTimers.values())
2687
2906
  clearTimeout(timer);
2688
2907
  this.suppressedSessionTimers.clear();
@@ -2715,8 +2934,10 @@ export class OpenClawClient {
2715
2934
  * Subscribe to message events for a session.
2716
2935
  * When the agent proactively produces a message, the callback fires.
2717
2936
  */
2718
- async subscribeSession(sessionKey, onMessage) {
2937
+ async subscribeSession(sessionKey, onMessage, onSettled) {
2719
2938
  // Register under both the short key and the full key with agent:main: prefix
2939
+ if (onSettled)
2940
+ this.sessionSettlementCallbacks.set(this.canonicalSessionKey(sessionKey), onSettled);
2720
2941
  this.sessionMessageCallbacks.set(sessionKey, onMessage);
2721
2942
  this.sessionMessageCallbacks.set(`agent:main:${sessionKey}`, onMessage);
2722
2943
  this.subscribedKeys.add(sessionKey);
@@ -2739,6 +2960,7 @@ export class OpenClawClient {
2739
2960
  this.clearVerboseAssistantState(sessionKey);
2740
2961
  }
2741
2962
  async unsubscribeSession(sessionKey) {
2963
+ this.sessionSettlementCallbacks.delete(this.canonicalSessionKey(sessionKey));
2742
2964
  this.sessionMessageCallbacks.delete(sessionKey);
2743
2965
  this.sessionMessageCallbacks.delete(`agent:main:${sessionKey}`);
2744
2966
  this.toolEventCallbacks.delete(sessionKey);