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.
- 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 +401 -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 +9 -3
- 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,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
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
}
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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);
|
|
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
|
|
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 };
|
|
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
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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.
|
|
1709
|
-
*
|
|
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
|
|
2167
|
-
const
|
|
2168
|
-
if (!
|
|
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
|
-
|
|
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
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
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
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
const sendChat = () =>
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|