openclaw-lark-multi-agent 1.8.4 → 1.8.6

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,12 +889,22 @@ 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;
794
900
  let lifecycleEndTimer = null;
795
901
  let replayInvalidTimer = null;
796
902
  let genericChatErrorTimer = null;
903
+ // A channel message-tool send marked terminate=true is the turn's intended
904
+ // user-visible output (2026-10-16 incident: it bypassed the bridge and the
905
+ // collector waited forever). Capture its text and settle on a short grace.
906
+ let terminatingSendText = "";
907
+ let terminatingSendTimer = null;
797
908
  let pendingRuntimeFailureText = "";
798
909
  let lastActivitySummary = "";
799
910
  let lastActivityAt = 0;
@@ -803,6 +914,7 @@ export class OpenClawClient {
803
914
  let anchorSeen = !expectedUserText;
804
915
  const activeRunIds = new Set([runId]);
805
916
  let preAnchorEvents = [];
917
+ const foreignUserRuns = new Set();
806
918
  const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
807
919
  const isExpectedUserText = (value) => {
808
920
  const actual = normalizeText(value);
@@ -832,14 +944,14 @@ export class OpenClawClient {
832
944
  finish(chatFinalText);
833
945
  return;
834
946
  }
835
- if (error instanceof SessionWaitPaused && options?.onWaitPaused) {
947
+ if (error instanceof SessionWaitPaused) {
836
948
  // Foreground notice, background observation. Keep the original queue
837
949
  // owner/progress path so later input still steers into the same run.
838
950
  foregroundPaused = true;
839
951
  if (!waitNoticeSent) {
840
952
  waitNoticeSent = true;
841
953
  try {
842
- await options.onWaitPaused(error);
954
+ await options?.onWaitPaused?.(error);
843
955
  }
844
956
  catch {
845
957
  console.warn("[OpenClaw] wait notice delivery pending; observation retained");
@@ -852,8 +964,8 @@ export class OpenClawClient {
852
964
  }
853
965
  reject(error);
854
966
  };
855
- const checkRunState = async (idleStillCurrent = () => true) => {
856
- if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
967
+ const checkRunState = async (idleStillCurrent = () => true, force = false) => {
968
+ if (observationClosed || checkingState || !force && Date.now() - lastStateCheckAt < 60_000)
857
969
  return;
858
970
  checkingState = true;
859
971
  lastStateCheckAt = Date.now();
@@ -877,7 +989,9 @@ export class OpenClawClient {
877
989
  ? terminal.text
878
990
  : terminal?.disposition === "silent" || terminal?.disposition === "empty"
879
991
  ? "NO_REPLY"
880
- : this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
992
+ : "";
993
+ if (!visible && terminal?.disposition !== "visible")
994
+ return;
881
995
  resolve(visible || "NO_REPLY");
882
996
  }
883
997
  else if (snapshot.status === "error" || snapshot.status === "timeout") {
@@ -954,6 +1068,8 @@ export class OpenClawClient {
954
1068
  if (activityKeys.has(key))
955
1069
  return false;
956
1070
  activityKeys.add(key);
1071
+ if (activityKeys.size > 4096)
1072
+ activityKeys.delete(activityKeys.values().next().value);
957
1073
  return true;
958
1074
  };
959
1075
  const hasEffectiveActivity = (ev) => {
@@ -986,7 +1102,7 @@ export class OpenClawClient {
986
1102
  return false;
987
1103
  const previous = activityText.get(source) || "";
988
1104
  const next = replace ? chunk : previous + chunk;
989
- activityText.set(source, next);
1105
+ activityText.set(source, next.slice(-256 * 1024));
990
1106
  if (next === previous || (replace && previous.includes(next)))
991
1107
  return false;
992
1108
  // Replayed replacement snapshots and assistant/chat/transcript mirrors
@@ -1047,6 +1163,12 @@ export class OpenClawClient {
1047
1163
  };
1048
1164
  resetIdleTimer();
1049
1165
  const finish = (finalText) => {
1166
+ if (draftOverflow && finalText !== 'NO_REPLY' && finalText !== chatFinalText) {
1167
+ // Never publish a truncated accumulator. Keep exact result custody
1168
+ // until authoritative root terminalReply or full root chat final.
1169
+ void checkRunState(() => true, true);
1170
+ return;
1171
+ }
1050
1172
  clearTimeout(idleTimer);
1051
1173
  clearInterval(poller);
1052
1174
  if (chatFinalTimer)
@@ -1057,6 +1179,8 @@ export class OpenClawClient {
1057
1179
  clearTimeout(replayInvalidTimer);
1058
1180
  if (genericChatErrorTimer)
1059
1181
  clearTimeout(genericChatErrorTimer);
1182
+ if (terminatingSendTimer)
1183
+ clearTimeout(terminatingSendTimer);
1060
1184
  resolve(finalText);
1061
1185
  };
1062
1186
  const collectEvents = () => {
@@ -1066,36 +1190,18 @@ export class OpenClawClient {
1066
1190
  // never deliver a reply on this connection, so stop waiting instead of
1067
1191
  // hanging until the idle timeout. Failing fast lets the bridge
1068
1192
  // 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
- }
1193
+ // A disconnect is an observation gap, not an execution failure. The
1194
+ // connection resubscribes and the paced exact-run query reconciles it.
1089
1195
  // /stop force-abort: finish immediately instead of waiting for the
1090
1196
  // cancelled lifecycle to be treated as a transient state (idle timeout).
1091
1197
  if (sessionKey && (this.forceAbortedSessions.has(sessionKey) || this.forceAbortedSessions.has(shortSessionKey))) {
1092
1198
  this.forceAbortedSessions.delete(sessionKey);
1093
1199
  this.forceAbortedSessions.delete(shortSessionKey);
1094
- finish(text || chatDeltaText || chatFinalText || transcriptAssistantText || "NO_REPLY");
1200
+ finish("NO_REPLY");
1095
1201
  return;
1096
1202
  }
1097
1203
  const bucketsToScan = sessionKey
1098
- ? Array.from(new Set([sessionKey, shortSessionKey].filter(Boolean)))
1204
+ ? 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
1205
  : Array.from(this.agentEvents.keys());
1100
1206
  for (const bucketKey of bucketsToScan) {
1101
1207
  const bucket = this.agentEvents.get(bucketKey);
@@ -1105,17 +1211,26 @@ export class OpenClawClient {
1105
1211
  while (i < bucket.length) {
1106
1212
  const ev = bucket[i];
1107
1213
  const evRunId = typeof ev.runId === "string" ? ev.runId : "";
1108
- const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey || ev.sessionKey === shortSessionKey));
1214
+ const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey || ev.sessionKey === shortSessionKey || activeRunIds.has(evRunId) && this.shortKey(ev.sessionKey || '') === shortSessionKey));
1109
1215
  const matchesRun = evRunId ? activeRunIds.has(evRunId) : false;
1110
1216
  if (options?.exactRunIdentity && !matchesRun) {
1111
1217
  i++;
1112
1218
  continue;
1113
1219
  }
1220
+ if (evRunId && foreignUserRuns.has(evRunId)) {
1221
+ i++;
1222
+ continue;
1223
+ }
1114
1224
  if (!sessionKey && ev.sessionKey) {
1115
1225
  shortSessionKey = String(ev.sessionKey).replace(/^agent:[^:]+:/, "");
1116
1226
  sessionKey = `agent:main:${shortSessionKey}`;
1117
1227
  }
1118
1228
  if (eventSessionMatches && ev.stream === "sessionUser") {
1229
+ if (anchorSeen && evRunId && !matchesRun) {
1230
+ foreignUserRuns.add(evRunId);
1231
+ i++;
1232
+ continue;
1233
+ }
1119
1234
  bucket.splice(i, 1);
1120
1235
  if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
1121
1236
  anchorSeen = true;
@@ -1153,6 +1268,8 @@ export class OpenClawClient {
1153
1268
  if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
1154
1269
  bucket.splice(i, 1);
1155
1270
  preAnchorEvents.push(ev);
1271
+ if (preAnchorEvents.length > 2048)
1272
+ preAnchorEvents.splice(0, preAnchorEvents.length - 2048);
1156
1273
  if (preAnchorEvents.length > 200)
1157
1274
  preAnchorEvents.shift();
1158
1275
  continue;
@@ -1189,9 +1306,54 @@ export class OpenClawClient {
1189
1306
  continue;
1190
1307
  }
1191
1308
  bucket.splice(i, 1);
1309
+ // Continuation progress is valid, but only the admitted root's
1310
+ // committed terminalReply can authorize another run's final text.
1311
+ if (evRunId && evRunId !== runId && (ev.stream === 'chatFinal' || ev.stream === 'chatError' || ev.stream === 'lifecycle' && ['end', 'error'].includes(ev.data?.phase))) {
1312
+ if (ev.stream === 'chatFinal' && ev.data?.gatewayYielded === true)
1313
+ continue;
1314
+ void checkRunState(() => true, true);
1315
+ continue;
1316
+ }
1192
1317
  if (hasEffectiveActivity(ev))
1193
1318
  resetIdleTimer();
1194
1319
  rememberActivity(ev);
1320
+ // Terminating channel-send detection: a message-tool result carrying
1321
+ // terminate=true ends the turn with that send as its user-visible
1322
+ // output. The bridge is the delivery channel for LMA sessions, so
1323
+ // waiting for a further final would hang forever (2026-10-16 frozen
1324
+ // card incident). Capture the sent text at tool start; settle on a
1325
+ // short grace unless a real final arrives first.
1326
+ {
1327
+ const tdata = ev.data || {};
1328
+ if (ev.stream === "tool" || (ev.stream === "item" && tdata.kind === "tool")) {
1329
+ const toolName = String(tdata.name || tdata.toolName || "");
1330
+ const tphase = tdata.phase === "result" ? (tdata.isError || tdata.error ? "error" : "end") : tdata.phase;
1331
+ if (toolName === "message" && tphase === "start") {
1332
+ const raw = tdata.input ?? tdata.args ?? tdata.meta ?? null;
1333
+ if (raw !== null) {
1334
+ try {
1335
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
1336
+ if (parsed && typeof parsed.message === "string" && parsed.message.trim())
1337
+ terminatingSendText = parsed.message;
1338
+ }
1339
+ catch { /* non-JSON tool input */ }
1340
+ }
1341
+ }
1342
+ else if (toolName === "message" && (tphase === "end" || tphase === "error")) {
1343
+ const out = String(JSON.stringify(tdata.output ?? tdata.result ?? tdata.error ?? ""));
1344
+ if (/"?terminate"?\s*[:=]\s*true/i.test(out) && !terminatingSendTimer) {
1345
+ terminatingSendTimer = setTimeout(() => {
1346
+ terminatingSendTimer = null;
1347
+ if (observationClosed)
1348
+ return;
1349
+ const best = (chatFinalText.trim() || text.trim() || terminatingSendText || "").trim();
1350
+ finish(best || "NO_REPLY");
1351
+ }, 10_000);
1352
+ terminatingSendTimer.unref?.();
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1195
1357
  // If more events arrive after a replay-invalid lifecycle end, that lifecycle
1196
1358
  // was not terminal for the user-visible run. Keep waiting for the real final.
1197
1359
  if (replayInvalidTimer) {
@@ -1209,30 +1371,30 @@ export class OpenClawClient {
1209
1371
  lifecycleStartedLogged = true;
1210
1372
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
1211
1373
  }
1212
- if ((ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") && (ev.data?.deltaText || ev.data?.delta)) {
1374
+ if ((!evRunId || evRunId === runId) && (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") && (ev.data?.deltaText || ev.data?.delta)) {
1213
1375
  const chunk = ev.data.deltaText || ev.data.delta;
1214
1376
  if (ev.stream === "assistant") {
1215
1377
  if (ev.data?.replace) {
1216
- text = chunk;
1378
+ text = boundedDraft(chunk);
1217
1379
  }
1218
1380
  else {
1219
- text += chunk;
1381
+ text = boundedDraft(text + chunk);
1220
1382
  }
1221
1383
  }
1222
1384
  else if (ev.stream === "chatDelta") {
1223
1385
  if (ev.data?.replace) {
1224
- chatDeltaText = chunk;
1386
+ chatDeltaText = boundedDraft(chunk);
1225
1387
  }
1226
1388
  else {
1227
- chatDeltaText += chunk;
1389
+ chatDeltaText = boundedDraft(chatDeltaText + chunk);
1228
1390
  }
1229
1391
  }
1230
1392
  else {
1231
1393
  if (ev.data?.replace) {
1232
- transcriptAssistantText = chunk;
1394
+ transcriptAssistantText = boundedDraft(chunk);
1233
1395
  }
1234
1396
  else {
1235
- transcriptAssistantText += chunk;
1397
+ transcriptAssistantText = boundedDraft(transcriptAssistantText + chunk);
1236
1398
  }
1237
1399
  }
1238
1400
  }
@@ -1258,8 +1420,7 @@ export class OpenClawClient {
1258
1420
  // Finish scanning this batch first so an already-arrived real
1259
1421
  // final/error wins over a stale waiting notice. No status RPC
1260
1422
  // (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")));
1423
+ void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
1263
1424
  continue;
1264
1425
  }
1265
1426
  chatFinalText = ev.data?.text || "";
@@ -1275,10 +1436,6 @@ export class OpenClawClient {
1275
1436
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1276
1437
  const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1277
1438
  // 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
1439
  if (latestFinalText) {
1283
1440
  finish(latestFinalText);
1284
1441
  }
@@ -1468,7 +1625,19 @@ export class OpenClawClient {
1468
1625
  }
1469
1626
  }
1470
1627
  };
1471
- const poller = setInterval(collectEvents, 50);
1628
+ let poller;
1629
+ const poll = () => {
1630
+ collectEvents();
1631
+ if (!observationClosed) {
1632
+ const quiet = Date.now() - (lastActivityAt || collectStartedAt);
1633
+ poller = setTimeout(poll, !startedConnected || quiet < 1000 ? 50 : quiet < 10_000 ? 1000 : 30_000);
1634
+ poller.unref?.();
1635
+ }
1636
+ };
1637
+ poller = setTimeout(poll, 50);
1638
+ const wakeCollector = () => { if (!observationClosed)
1639
+ collectEvents(); };
1640
+ this.eventCollectors.add(wakeCollector);
1472
1641
  const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1473
1642
  const checkAfterNoActiveRun = async () => {
1474
1643
  await checkRunState();
@@ -1487,13 +1656,10 @@ export class OpenClawClient {
1487
1656
  };
1488
1657
  if (observationKey)
1489
1658
  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
1659
  cleanupObservation = () => {
1494
1660
  observationClosed = true;
1495
1661
  clearTimeout(idleTimer);
1496
- clearTimeout(observationHorizon);
1662
+ this.eventCollectors.delete(wakeCollector);
1497
1663
  clearInterval(poller);
1498
1664
  if (chatFinalTimer)
1499
1665
  clearTimeout(chatFinalTimer);
@@ -1503,6 +1669,8 @@ export class OpenClawClient {
1503
1669
  clearTimeout(replayInvalidTimer);
1504
1670
  if (genericChatErrorTimer)
1505
1671
  clearTimeout(genericChatErrorTimer);
1672
+ if (terminatingSendTimer)
1673
+ clearTimeout(terminatingSendTimer);
1506
1674
  if (observationKey && this.runStateChecks.get(observationKey) === checkAfterNoActiveRun)
1507
1675
  this.runStateChecks.delete(observationKey);
1508
1676
  };
@@ -1513,6 +1681,9 @@ export class OpenClawClient {
1513
1681
  const fenceSuperseded = () => {
1514
1682
  if (observationClosed)
1515
1683
  return;
1684
+ // Atomic custody transfer BEFORE closing the old consumer; a final
1685
+ // arriving in this tick is immediately eligible for exact proactive.
1686
+ this.releasePausedWait(runId);
1516
1687
  cleanupObservation();
1517
1688
  reject(new SupersededObservation());
1518
1689
  };
@@ -1655,47 +1826,52 @@ export class OpenClawClient {
1655
1826
  async injectAssistantMessage(params) {
1656
1827
  return this.rpc("chat.inject", { ...params, sessionKey: this.canonicalSessionKey(params.sessionKey) }, 10000);
1657
1828
  }
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);
1829
+ /** Real-time session input uses the coordinated plugin's durable callback
1830
+ * receipt protocol. SDK eligibility/text mirrors never prove acceptance;
1831
+ * missing/legacy plugin observations stay pending without blind mutation. */
1832
+ async exactRunSettled(runId) {
1675
1833
  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 };
1834
+ const r = await this.rpc('agent.wait', { runId, timeoutMs: 1 }, 2500);
1835
+ 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
1836
  }
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" };
1837
+ catch {
1838
+ return false;
1839
+ }
1840
+ }
1841
+ async cancelSteerSession(sessionKey, stopEpoch) {
1842
+ await this.rpc('lma.steer.stop', { sessionKey: this.canonicalSessionKey(sessionKey), stopEpoch }, 2500);
1843
+ }
1844
+ async steer(sessionKey, text, displayText, identity) {
1845
+ if (!identity?.insertionId || !identity.expectedRunId)
1846
+ return { status: 'pending', attempted: false, reason: 'exact target not admitted yet' };
1847
+ let attempted = false;
1848
+ const base = { insertionId: identity.insertionId, expectedRunId: identity.expectedRunId, sessionId: identity.expectedSessionId };
1849
+ try {
1850
+ const capability = await this.rpc('lma.steer.capabilities', {}, 2500);
1851
+ // This is an LMA protocol-version check, NOT a fictitious source-CAS
1852
+ // requirement. Builtin callbacks can be used with atomicRunBound=false.
1853
+ if (capability?.protocol !== 2 || capability?.durableAttempts !== true || capability?.acceptanceCallback !== true)
1854
+ return { ...base, status: 'unavailable', attempted: identity.queryOnly === true, reason: 'Update the coordinated LMA plugin; legacy booleans are not acceptance receipts' };
1855
+ const info = await this.getSessionStatus(sessionKey);
1856
+ const currentId = info?.session?.sessionId;
1857
+ if (info?.session?.archived === true || (identity.expectedSessionId && currentId && currentId !== identity.expectedSessionId))
1858
+ return { ...base, status: 'target_changed', attempted: identity.queryOnly === true, reason: 'session archived or generation changed; no automatic migration' };
1859
+ if (!currentId)
1860
+ return { ...base, status: 'pending', attempted: identity.queryOnly === true, reason: 'session generation observation unavailable' };
1861
+ base.sessionId = currentId;
1862
+ attempted = true;
1863
+ const result = await this.rpc('lma.steer', { sessionKey: this.canonicalSessionKey(sessionKey), text, ...identity,
1864
+ expectedSessionId: identity.expectedSessionId || currentId, stopEpoch: identity.stopEpoch ?? 0 }, 2500);
1865
+ if (result?.insertionId !== identity.insertionId || result?.expectedRunId !== identity.expectedRunId || result?.sessionId !== currentId ||
1866
+ !['pending', 'accepted', 'rejected', 'unknown', 'target_changed', 'target_ended', 'stopped'].includes(result.status))
1867
+ return { ...base, status: 'unknown', attempted: true, reason: 'Invalid or legacy plugin receipt; not replayed' };
1868
+ if (result.status === 'pending' && result.attempted === false && result.reason === 'target_not_ready')
1869
+ await this.runStateChecks.get(this.canonicalSessionKey(sessionKey))?.();
1870
+ return result;
1871
+ }
1872
+ catch (error) {
1873
+ return { ...base, status: attempted ? 'unknown' : 'pending', attempted: attempted || identity.queryOnly === true,
1874
+ reason: attempted ? 'Insertion receipt unknown; query stable identity only' : 'Readiness observation unavailable; retained for paced recovery' };
1699
1875
  }
1700
1876
  }
1701
1877
  /** Callbacks fired when a steered message is confirmed consumed by the model
@@ -1705,8 +1881,8 @@ export class OpenClawClient {
1705
1881
  * Callbacks are one-shot: returning true removes them. */
1706
1882
  steerConsumedCallbacks = new Map();
1707
1883
  /** 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).
1884
+ * is actually consumed by the active run. This legacy text observer is
1885
+ * presentation-only; it cannot authorize a receipt reaction.
1710
1886
  * Return true from the callback to consume/remove it (one-shot). */
1711
1887
  onSteerConsumed(sessionKey, matchText, cb) {
1712
1888
  const key = this.shortKey(sessionKey);
@@ -1848,6 +2024,7 @@ export class OpenClawClient {
1848
2024
  * stale poller cannot steal the replacement batch's frames (2026-10-16). */
1849
2025
  revokeSessionCollectors(sessionKey, reason) {
1850
2026
  const key = this.canonicalSessionKey(sessionKey);
2027
+ this.observationEpoch.set(key, (this.observationEpoch.get(key) || 0) + 1);
1851
2028
  const fences = this.sessionCollectorFences.get(key);
1852
2029
  if (!fences || fences.size === 0)
1853
2030
  return 0;
@@ -2070,6 +2247,9 @@ export class OpenClawClient {
2070
2247
  // chat.abort supports { sessionKey } with no runId to abort ALL active runs
2071
2248
  // for that session. Used by /stop to force-clear a stuck run.
2072
2249
  if (!runId) {
2250
+ this.stopGenerations.set(key, (this.stopGenerations.get(key) || 0) + 1);
2251
+ this.revokeSessionCollectors(key, 'explicit stop');
2252
+ this.recoveryStore?.stopSessionObservations(key);
2073
2253
  for (const [identity, at] of this.explicitlyStoppedRuns)
2074
2254
  if (Date.now() - at > 24 * 60 * 60_000)
2075
2255
  this.explicitlyStoppedRuns.delete(identity);
@@ -2094,6 +2274,7 @@ export class OpenClawClient {
2094
2274
  for (const stopped of result.runIds)
2095
2275
  if (typeof stopped === "string" && stopped) {
2096
2276
  this.explicitlyStoppedRuns.set(JSON.stringify([key, stopped]), Date.now());
2277
+ this.recoveryStore?.stopExactRun(key, stopped);
2097
2278
  }
2098
2279
  }
2099
2280
  return result;
@@ -2119,6 +2300,8 @@ export class OpenClawClient {
2119
2300
  // still reach the proactive callback.
2120
2301
  return;
2121
2302
  }
2303
+ if (runId && this.isExplicitlyStoppedRun(sessionKey, runId) && state === 'final')
2304
+ return;
2122
2305
  const stopReason = [payload?.stopReason, payload?.message?.stopReason].find(reason => reason === "error" || reason === "aborted")
2123
2306
  || payload?.stopReason || payload?.message?.stopReason;
2124
2307
  const isError = state === "error" || state === "aborted" || stopReason === "error" || stopReason === "aborted";
@@ -2163,11 +2346,31 @@ export class OpenClawClient {
2163
2346
  return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
2164
2347
  }
2165
2348
  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)
2349
+ const keys = this.sessionKeyVariants(sessionKey);
2350
+ const callback = keys.map(k => this.sessionMessageCallbacks.get(k)).find(Boolean);
2351
+ if (!callback)
2169
2352
  return false;
2170
- cb(text, meta);
2353
+ const identity = meta?.final && meta.runId ? JSON.stringify([this.canonicalSessionKey(sessionKey), meta.runId]) : '';
2354
+ const fingerprint = createHash('sha256').update(text).digest('hex');
2355
+ if (identity && this.forwardedFinals.get(identity) === fingerprint)
2356
+ return true;
2357
+ if (identity) {
2358
+ this.forwardedFinals.set(identity, fingerprint);
2359
+ if (this.forwardedFinals.size > 2048)
2360
+ this.forwardedFinals.delete(this.forwardedFinals.keys().next().value);
2361
+ }
2362
+ if (identity)
2363
+ this.recoveryStore?.saveRunObservation(this.canonicalSessionKey(sessionKey), meta.runId, { sessionId: meta?.sessionId, terminalText: text, terminalObserved: true }, Date.now() + 60_000);
2364
+ try {
2365
+ const result = meta ? callback(text, meta) : callback(text);
2366
+ if (identity)
2367
+ void Promise.resolve(result).catch(() => this.forwardedFinals.delete(identity));
2368
+ }
2369
+ catch (error) {
2370
+ if (identity)
2371
+ this.forwardedFinals.delete(identity);
2372
+ throw error;
2373
+ }
2171
2374
  return true;
2172
2375
  }
2173
2376
  suppressSessionKeys(keys) {
@@ -2421,31 +2624,44 @@ export class OpenClawClient {
2421
2624
  async chatSend(params) {
2422
2625
  const sk = this.shortKey(params.sessionKey);
2423
2626
  const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
2627
+ const generation = (this.observationEpoch.get(fullSessionKey) || 0) + 1;
2628
+ this.observationEpoch.set(fullSessionKey, generation);
2629
+ const stopGeneration = this.stopGenerations.get(fullSessionKey) || 0;
2630
+ const isCurrent = () => this.observationEpoch.get(fullSessionKey) === generation && (this.stopGenerations.get(fullSessionKey) || 0) === stopGeneration;
2631
+ const assertCurrent = () => { if (!isCurrent())
2632
+ throw new SupersededObservation('admission generation revoked'); };
2424
2633
  this.clearVerboseAssistantState(sk);
2634
+ const submissionId = randomUUID();
2425
2635
  let ownedRunId = "";
2426
2636
  let waitPaused = false;
2427
2637
  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, []);
2638
+ // New user inputs arriving after /stop are admitted only AFTER that
2639
+ // authorized session-wide abort returns. They are not part of its cutoff.
2640
+ const stopBarrier = this.pendingExplicitStops.get(fullSessionKey);
2641
+ if (stopBarrier)
2642
+ await stopBarrier;
2643
+ assertCurrent();
2433
2644
  const releaseChatSendSlot = await this.acquireChatSendSlot();
2434
2645
  let result;
2435
2646
  const sendStartedAt = Date.now();
2436
2647
  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
- });
2648
+ assertCurrent();
2649
+ this.recoveryStore?.saveAdmission(fullSessionKey, submissionId, 'reserved', { generation, stopGeneration });
2650
+ await params.onSendAttempt?.(submissionId);
2651
+ assertCurrent();
2652
+ const idempotencyKey = submissionId;
2653
+ const sendChat = () => {
2654
+ assertCurrent();
2655
+ this.recoveryStore?.saveAdmission(fullSessionKey, idempotencyKey, 'dispatching', { generation, stopGeneration, payloadDigest: createHash('sha256').update(params.message).digest('hex') });
2656
+ this.recoveryStore?.saveRunObservation(fullSessionKey, submissionId, { admissionOnly: true }, Date.now() + 30_000);
2657
+ return this.rpc("chat.send", {
2658
+ sessionKey: this.canonicalSessionKey(sk),
2659
+ message: params.message,
2660
+ attachments: params.attachments,
2661
+ deliver: params.deliver ?? false,
2662
+ idempotencyKey,
2663
+ });
2664
+ };
2449
2665
  try {
2450
2666
  result = await sendChat();
2451
2667
  }
@@ -2453,8 +2669,15 @@ export class OpenClawClient {
2453
2669
  // The Gateway auto-archives sessions past the active-session cap; an
2454
2670
  // archived session refuses new work. Restore it once and retry — the
2455
2671
  // user should never have to fix this by hand.
2456
- if (!this.isArchivedSessionError(err))
2457
- throw err;
2672
+ if (!this.isArchivedSessionError(err)) {
2673
+ const rejected = err instanceof Error && /^RPC error:/.test(err.message);
2674
+ this.recoveryStore?.saveAdmission(fullSessionKey, idempotencyKey, rejected ? 'rejected' : 'unknown', { generation, stopGeneration });
2675
+ if (rejected) {
2676
+ this.recoveryStore?.finishRunObservation(fullSessionKey, submissionId, 'rejected');
2677
+ throw err;
2678
+ }
2679
+ throw new AdmissionUnconfirmed(idempotencyKey, err);
2680
+ }
2458
2681
  console.warn(`[OpenClaw] chat.send hit archived session for ${sk}; restoring and retrying once`);
2459
2682
  const restored = await this.unarchiveSession(sk);
2460
2683
  if (!restored)
@@ -2466,18 +2689,49 @@ export class OpenClawClient {
2466
2689
  releaseChatSendSlot();
2467
2690
  }
2468
2691
  ownedRunId = typeof result?.runId === "string" ? result.runId : "";
2692
+ if (!ownedRunId)
2693
+ throw new AdmissionUnconfirmed('unidentified-receipt');
2694
+ this.recoveryStore?.saveAdmission(fullSessionKey, submissionId, ['started', 'ok'].includes(result.status) ? 'accepted' : 'unconfirmed', { generation, stopGeneration, runId: ownedRunId });
2695
+ if (ownedRunId !== submissionId)
2696
+ this.recoveryStore?.finishRunObservation(fullSessionKey, submissionId, 'identified');
2697
+ this.recoveryStore?.saveRunObservation(fullSessionKey, ownedRunId, { sessionId: result.sessionId });
2698
+ if (this.isExplicitlyStoppedRun(fullSessionKey, ownedRunId) || (this.stopGenerations.get(fullSessionKey) || 0) !== stopGeneration) {
2699
+ this.recoveryStore?.finishRunObservation(fullSessionKey, ownedRunId, 'stopped');
2700
+ throw new SupersededObservation('late stopped admission');
2701
+ }
2702
+ if (!isCurrent())
2703
+ throw new SupersededObservation('late admission is not current');
2704
+ // Commit replacement only AFTER confirmed admission. A failed new send
2705
+ // must leave the healthy old collector and its custody intact.
2706
+ const previous = this.sessionCollectorFences.get(fullSessionKey);
2707
+ if (previous)
2708
+ for (const fence of [...previous])
2709
+ fence();
2469
2710
  this.ownDeliveryRun(ownedRunId);
2470
2711
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
2471
- await params.onSubmitted?.(result.runId);
2712
+ await params.onRunIdentified?.(result.runId);
2713
+ assertCurrent();
2714
+ // A reservation/in_flight reply is not confirmed acceptance. Keep UI
2715
+ // Typing while exact collection/read-only receipt reconciliation proceeds.
2716
+ if (result.status === 'started' || result.status === 'ok' || Number.isSafeInteger(result.messageSeq) && result.messageSeq >= 0)
2717
+ await params.onSubmitted?.(result.runId);
2718
+ assertCurrent();
2472
2719
  if (params.onProgress) {
2473
2720
  this.progressCallbacks.set(sk, params.onProgress);
2474
2721
  this.progressCallbacks.set(fullSessionKey, params.onProgress);
2722
+ this.progressGenerations.set(sk, generation);
2723
+ this.progressGenerations.set(fullSessionKey, generation);
2475
2724
  void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
2476
2725
  }
2477
- return await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
2726
+ const reply = await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
2727
+ if (reply.trim().toUpperCase() === 'NO_REPLY')
2728
+ this.recoveryStore?.finishRunObservation(fullSessionKey, ownedRunId);
2729
+ else
2730
+ this.recoveryStore?.saveRunObservation(fullSessionKey, ownedRunId, { sessionId: result.sessionId, terminalText: reply, terminalObserved: true }, Date.now() + 30_000);
2731
+ return reply;
2478
2732
  }
2479
2733
  catch (error) {
2480
- waitPaused = error instanceof SessionWaitPaused;
2734
+ waitPaused = error instanceof SessionWaitPaused || error instanceof SupersededObservation;
2481
2735
  throw error;
2482
2736
  }
2483
2737
  finally {
@@ -2489,10 +2743,14 @@ export class OpenClawClient {
2489
2743
  // progress callback (the fence settles the old promise after the new
2490
2744
  // batch may already have registered its own callback).
2491
2745
  if (params.onProgress) {
2492
- if (this.progressCallbacks.get(sk) === params.onProgress)
2746
+ if (this.progressGenerations.get(sk) === generation && this.progressCallbacks.get(sk) === params.onProgress) {
2493
2747
  this.progressCallbacks.delete(sk);
2494
- if (this.progressCallbacks.get(fullSessionKey) === params.onProgress)
2748
+ this.progressGenerations.delete(sk);
2749
+ }
2750
+ if (this.progressGenerations.get(fullSessionKey) === generation && this.progressCallbacks.get(fullSessionKey) === params.onProgress) {
2495
2751
  this.progressCallbacks.delete(fullSessionKey);
2752
+ this.progressGenerations.delete(fullSessionKey);
2753
+ }
2496
2754
  }
2497
2755
  if (waitPaused)
2498
2756
  this.releasePausedWait(ownedRunId);
@@ -2548,6 +2806,7 @@ export class OpenClawClient {
2548
2806
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
2549
2807
  onSendAttempt: params.onSendAttempt,
2550
2808
  onSubmitted: params.onSubmitted,
2809
+ onRunIdentified: params.onRunIdentified,
2551
2810
  onProgress: params.onProgress,
2552
2811
  onWaitPaused: params.onWaitPaused,
2553
2812
  });
@@ -2585,6 +2844,7 @@ export class OpenClawClient {
2585
2844
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
2586
2845
  onSendAttempt: params.onSendAttempt,
2587
2846
  onSubmitted: params.onSubmitted,
2847
+ onRunIdentified: params.onRunIdentified,
2588
2848
  onProgress: params.onProgress,
2589
2849
  onWaitPaused: params.onWaitPaused,
2590
2850
  });
@@ -2683,6 +2943,11 @@ export class OpenClawClient {
2683
2943
  }
2684
2944
  async disconnect() {
2685
2945
  this.shouldReconnect = false;
2946
+ for (const key of [...this.sessionCollectorFences.keys()])
2947
+ this.revokeSessionCollectors(key, 'bridge disconnected; durable exact observation retained');
2948
+ if (this.recoveryTimer)
2949
+ clearTimeout(this.recoveryTimer);
2950
+ this.recoveryTimer = undefined;
2686
2951
  for (const timer of this.suppressedSessionTimers.values())
2687
2952
  clearTimeout(timer);
2688
2953
  this.suppressedSessionTimers.clear();
@@ -2715,8 +2980,10 @@ export class OpenClawClient {
2715
2980
  * Subscribe to message events for a session.
2716
2981
  * When the agent proactively produces a message, the callback fires.
2717
2982
  */
2718
- async subscribeSession(sessionKey, onMessage) {
2983
+ async subscribeSession(sessionKey, onMessage, onSettled) {
2719
2984
  // Register under both the short key and the full key with agent:main: prefix
2985
+ if (onSettled)
2986
+ this.sessionSettlementCallbacks.set(this.canonicalSessionKey(sessionKey), onSettled);
2720
2987
  this.sessionMessageCallbacks.set(sessionKey, onMessage);
2721
2988
  this.sessionMessageCallbacks.set(`agent:main:${sessionKey}`, onMessage);
2722
2989
  this.subscribedKeys.add(sessionKey);
@@ -2739,6 +3006,7 @@ export class OpenClawClient {
2739
3006
  this.clearVerboseAssistantState(sessionKey);
2740
3007
  }
2741
3008
  async unsubscribeSession(sessionKey) {
3009
+ this.sessionSettlementCallbacks.delete(this.canonicalSessionKey(sessionKey));
2742
3010
  this.sessionMessageCallbacks.delete(sessionKey);
2743
3011
  this.sessionMessageCallbacks.delete(`agent:main:${sessionKey}`);
2744
3012
  this.toolEventCallbacks.delete(sessionKey);