openclaw-lark-multi-agent 1.8.4 → 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.
- package/README.md +23 -17
- package/README.zh-CN.md +3 -1
- package/dist/cli.js +19 -20
- package/dist/feishu-bot.d.ts +10 -0
- package/dist/feishu-bot.js +611 -363
- package/dist/message-store.d.ts +63 -1
- package/dist/message-store.js +336 -8
- package/dist/openclaw-client.d.ts +50 -24
- package/dist/openclaw-client.js +355 -133
- package/dist/session-status.d.ts +2 -0
- package/dist/session-status.js +1 -0
- package/docs/releases/1.8.5.md +65 -0
- package/package.json +10 -4
- package/plugins/lma-core/README.md +83 -0
- package/plugins/lma-core/dist/compaction.js +82 -0
- package/plugins/lma-core/dist/go-policy.js +25 -0
- package/plugins/lma-core/dist/go-registry.js +37 -0
- package/plugins/lma-core/dist/go.js +42 -0
- package/plugins/lma-core/dist/index.js +29 -0
- package/plugins/lma-core/dist/steer.js +212 -0
- package/plugins/lma-core/dist/tooltrim.js +152 -0
- package/plugins/lma-core/openclaw.plugin.json +45 -0
- package/plugins/lma-core/package.json +15 -0
- package/plugins/lma-steer/README.md +7 -0
- package/scripts/audit-delivery-context.mjs +49 -0
package/dist/openclaw-client.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
1070
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
|
|
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
|
|
1677
|
-
|
|
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
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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.
|
|
1709
|
-
*
|
|
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
|
|
2167
|
-
const
|
|
2168
|
-
if (!
|
|
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
|
-
|
|
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
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
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
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
const sendChat = () =>
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|