openclaw-lark-multi-agent 1.8.13 → 1.8.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/feishu-bot.d.ts +31 -9
- package/dist/feishu-bot.js +525 -327
- package/dist/live-status.d.ts +85 -15
- package/dist/live-status.js +395 -89
- package/dist/message-store.d.ts +20 -0
- package/dist/message-store.js +88 -10
- package/dist/openclaw-client.d.ts +39 -11
- package/dist/openclaw-client.js +520 -407
- package/dist/queued-presentation.d.ts +52 -0
- package/dist/queued-presentation.js +471 -0
- package/dist/session-status.d.ts +1 -0
- package/dist/session-status.js +4 -0
- package/docs/releases/1.8.15.md +28 -0
- package/package.json +4 -3
package/dist/openclaw-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { safeErrorSummary, rawErrorExcerpt } from "./provider-errors.js";
|
|
3
3
|
import { inspectError, genericFailure, announceCandidate } from "./terminal-errors.js";
|
|
4
|
-
import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
4
|
+
import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS, IDLE_GUARD_MS } from "./session-status.js";
|
|
5
5
|
import { createHash, randomUUID } from "crypto";
|
|
6
6
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
7
7
|
import { basename, extname, join, resolve, sep } from "path";
|
|
@@ -25,6 +25,10 @@ export class SupersededObservation extends Error {
|
|
|
25
25
|
this.name = "SupersededObservation";
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/** Local observer only: the exact background result remains owned by durable recovery. */
|
|
29
|
+
export class CollectorStalledError extends Error {
|
|
30
|
+
constructor() { super('本地状态观察未及时返回,已转为后台结果监听;没有停止任务或自动重发输入。'); this.name = 'CollectorStalledError'; }
|
|
31
|
+
}
|
|
28
32
|
export class AdmissionUnconfirmed extends Error {
|
|
29
33
|
submissionId;
|
|
30
34
|
constructor(submissionId, cause) {
|
|
@@ -53,6 +57,12 @@ const CONTEXT_SYNC_DIR = join(getDataDir(), "context-sync");
|
|
|
53
57
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
54
58
|
const MAX_FEISHU_DOC_ATTACHMENT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_FEISHU_DOC_ATTACHMENT_BYTES || 1024 * 1024);
|
|
55
59
|
const MAX_INLINE_CONTEXT_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_MESSAGES || 20);
|
|
60
|
+
// Byte budgets apply to lossy progress projections, NEVER authoritative terminal
|
|
61
|
+
// text. Terminal records stay complete until exact readers + ACK reservations
|
|
62
|
+
// release, then successful result/durable observation custody can retire them.
|
|
63
|
+
const EVENT_PROGRESS_BUCKET_BYTES = 256 * 1024;
|
|
64
|
+
const EVENT_PROGRESS_TOTAL_BYTES = 4 * 1024 * 1024;
|
|
65
|
+
const EVENT_PROGRESS_FIELD_CHARS = 2048;
|
|
56
66
|
const MAX_INLINE_CONTEXT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_BYTES || 128 * 1024);
|
|
57
67
|
/**
|
|
58
68
|
* OpenClaw Gateway WebSocket client.
|
|
@@ -68,9 +78,14 @@ export class OpenClawClient {
|
|
|
68
78
|
config;
|
|
69
79
|
ws = null;
|
|
70
80
|
pending = new Map();
|
|
81
|
+
admissionResponseHooks = new Map();
|
|
71
82
|
connected = false;
|
|
72
83
|
connectPromise = null;
|
|
73
84
|
agentEvents = new Map();
|
|
85
|
+
eventProgressSizes = new WeakMap();
|
|
86
|
+
runEventReaders = new Map();
|
|
87
|
+
runEventReservations = new Map();
|
|
88
|
+
completedRunEventCustody = new Set();
|
|
74
89
|
reconnectDelay = 1000;
|
|
75
90
|
maxReconnectDelay = 30000;
|
|
76
91
|
shouldReconnect = true;
|
|
@@ -83,10 +98,12 @@ export class OpenClawClient {
|
|
|
83
98
|
* `stream=item` mirrors when a runtime emits both representations. */
|
|
84
99
|
recentToolEventKeys = new Map();
|
|
85
100
|
privateInputToolCalls = new Set();
|
|
101
|
+
publicToolCalls = new Set();
|
|
86
102
|
sessionSettlementCallbacks = new Map();
|
|
87
103
|
sessionMessageCallbacks = new Map();
|
|
88
104
|
progressCallbacks = new Map();
|
|
89
105
|
progressGenerations = new Map();
|
|
106
|
+
progressRuns = new Map();
|
|
90
107
|
/** Live collectReply fences per canonical session key. The most recent
|
|
91
108
|
* collector for a session owns the shared event bucket; a new batch (or a
|
|
92
109
|
* queue-custody revocation) fences stale ones synchronously so they cannot
|
|
@@ -225,23 +242,166 @@ export class OpenClawClient {
|
|
|
225
242
|
this.recoveryChecking = false;
|
|
226
243
|
}
|
|
227
244
|
}
|
|
245
|
+
runEventKey(sessionKey, runId) {
|
|
246
|
+
return JSON.stringify([sessionKey === undefined ? null : this.shortKey(sessionKey), runId]);
|
|
247
|
+
}
|
|
248
|
+
retainRunEventCustody(kind, sessionKey, runId) {
|
|
249
|
+
const key = this.runEventKey(sessionKey, runId);
|
|
250
|
+
const refs = kind === 'reader' ? this.runEventReaders : this.runEventReservations;
|
|
251
|
+
refs.set(key, (refs.get(key) || 0) + 1);
|
|
252
|
+
let released = false;
|
|
253
|
+
return () => {
|
|
254
|
+
if (released)
|
|
255
|
+
return;
|
|
256
|
+
released = true;
|
|
257
|
+
const count = (refs.get(key) || 1) - 1;
|
|
258
|
+
if (count)
|
|
259
|
+
refs.set(key, count);
|
|
260
|
+
else
|
|
261
|
+
refs.delete(key);
|
|
262
|
+
for (const completed of this.completedRunEventCustody) {
|
|
263
|
+
if (JSON.parse(completed)[1] === runId)
|
|
264
|
+
this.retireCompletedRunEvents(completed);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
retireCompletedRunEvents(key) {
|
|
269
|
+
const [session, runId] = JSON.parse(key);
|
|
270
|
+
// A same-run second reader or receiver ACK reservation can still need the
|
|
271
|
+
// exact terminal. Never reclaim on merely seeing another session's frame.
|
|
272
|
+
if (this.runEventReaders.has(key) || this.runEventReaders.has(this.runEventKey(undefined, runId)) || this.runEventReservations.has(key))
|
|
273
|
+
return;
|
|
274
|
+
for (const [bucketKey, bucket] of this.agentEvents) {
|
|
275
|
+
const retained = bucket.filter(ev => !(ev.runId === runId && this.shortKey(ev.sessionKey || bucketKey) === session));
|
|
276
|
+
if (retained.length !== bucket.length)
|
|
277
|
+
this.agentEvents.set(bucketKey, retained);
|
|
278
|
+
}
|
|
279
|
+
this.completedRunEventCustody.delete(key);
|
|
280
|
+
}
|
|
281
|
+
isTerminalBufferedEvent(event) {
|
|
282
|
+
return event.stream === 'chatFinal' || event.stream === 'chatError'
|
|
283
|
+
|| event.stream === 'lifecycle' && event.data?.phase === 'error';
|
|
284
|
+
}
|
|
285
|
+
/** Buffers are observation indexes, not raw tool-output/transcript caches.
|
|
286
|
+
* Presentation/evidence callbacks still see the already privacy-filtered wire
|
|
287
|
+
* payload. Keep only the fields collectReply actually consumes here. */
|
|
288
|
+
projectBufferedEvent(event) {
|
|
289
|
+
const data = event.data || {}, clip = (value) => typeof value === 'string' ? value.slice(0, EVENT_PROGRESS_FIELD_CHARS) : value;
|
|
290
|
+
const out = { runId: event.runId, sessionKey: event.sessionKey, stream: event.stream, seq: event.seq, data: {} };
|
|
291
|
+
if (event.stream === 'chatFinal') {
|
|
292
|
+
out.data = { text: data.gatewayYielded === true ? '' : typeof data.text === 'string' ? data.text : '', gatewayYielded: data.gatewayYielded === true };
|
|
293
|
+
return out; // Entire authority, not a preview; no duplicated message body.
|
|
294
|
+
}
|
|
295
|
+
if (event.stream === 'chatError') {
|
|
296
|
+
out.data = { error: data.error };
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
const keep = event.stream === 'lifecycle'
|
|
300
|
+
? ['phase', 'livenessState', 'status', 'stopReason', 'replayInvalid', 'aborted', ...(data.phase === 'error' ? ['error'] : [])]
|
|
301
|
+
: event.stream === 'tool' || event.stream === 'item'
|
|
302
|
+
? ['kind', 'phase', 'name', 'toolName', 'toolCallId', 'itemId', 'id', 'isError']
|
|
303
|
+
: ['text', 'deltaText', 'delta', 'replace'];
|
|
304
|
+
for (const key of keep)
|
|
305
|
+
if (data[key] !== undefined)
|
|
306
|
+
out.data[key] = key === 'error' ? data[key] : clip(data[key]);
|
|
307
|
+
if (['assistant', 'chatDelta', 'transcriptAssistant'].includes(event.stream)) {
|
|
308
|
+
const full = typeof data.text === 'string' ? data.text : '';
|
|
309
|
+
const chunk = full || data.deltaText || data.delta;
|
|
310
|
+
if (typeof chunk === 'string' && chunk.length > EVENT_PROGRESS_FIELD_CHARS) {
|
|
311
|
+
// Activity identity must notice changes beyond a retained preview, and
|
|
312
|
+
// dedup cumulative snapshots/mirrors, without retaining the raw draft.
|
|
313
|
+
if (chunk.trim())
|
|
314
|
+
out.data.activityFingerprint = createHash('sha256').update(chunk).digest('hex');
|
|
315
|
+
else
|
|
316
|
+
out.data.text = out.data.deltaText = out.data.delta = '';
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (event.stream === 'tool' || event.stream === 'item') {
|
|
320
|
+
if (typeof data.meta === 'string')
|
|
321
|
+
out.data.meta = clip(data.meta);
|
|
322
|
+
// The ONLY tool-result semantic consumed by collectReply is a top-level
|
|
323
|
+
// message terminate hint. Preserve that boolean, not an echoed body.
|
|
324
|
+
if (String(data.name || data.toolName) === 'message') {
|
|
325
|
+
let value = data.output ?? data.result ?? data.error;
|
|
326
|
+
if (typeof value === 'string') {
|
|
327
|
+
try {
|
|
328
|
+
value = JSON.parse(value);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
value = /^terminate\s*=\s*true$/i.test(value.trim()) ? { terminate: true } : undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (value && typeof value === 'object' && value.terminate === true)
|
|
335
|
+
out.data.output = { terminate: true };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
progressEventBytes(event) {
|
|
341
|
+
if (this.isTerminalBufferedEvent(event))
|
|
342
|
+
return 0;
|
|
343
|
+
let bytes = this.eventProgressSizes.get(event);
|
|
344
|
+
if (bytes === undefined) {
|
|
345
|
+
bytes = Buffer.byteLength(JSON.stringify(event), 'utf8') + 2;
|
|
346
|
+
this.eventProgressSizes.set(event, bytes);
|
|
347
|
+
}
|
|
348
|
+
return bytes;
|
|
349
|
+
}
|
|
350
|
+
enforceEventProgressBudget() {
|
|
351
|
+
let total = 0;
|
|
352
|
+
for (const [key, bucket] of this.agentEvents) {
|
|
353
|
+
let bytes = bucket.reduce((n, event) => n + this.progressEventBytes(event), 0);
|
|
354
|
+
let excessCount = Math.max(0, bucket.length - 2048);
|
|
355
|
+
for (let i = 0; i < bucket.length && (bytes > EVENT_PROGRESS_BUCKET_BYTES || excessCount > 0);) {
|
|
356
|
+
const event = bucket[i];
|
|
357
|
+
if (this.isTerminalBufferedEvent(event)) {
|
|
358
|
+
i++;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
bytes -= this.progressEventBytes(event);
|
|
362
|
+
bucket.splice(i, 1);
|
|
363
|
+
excessCount--;
|
|
364
|
+
}
|
|
365
|
+
total += bytes;
|
|
366
|
+
}
|
|
367
|
+
if (total > EVENT_PROGRESS_TOTAL_BYTES)
|
|
368
|
+
for (const [key, bucket] of this.agentEvents) {
|
|
369
|
+
for (let i = 0; i < bucket.length && total > EVENT_PROGRESS_TOTAL_BYTES;) {
|
|
370
|
+
if (this.isTerminalBufferedEvent(bucket[i])) {
|
|
371
|
+
i++;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
total -= this.progressEventBytes(bucket[i]);
|
|
375
|
+
bucket.splice(i, 1);
|
|
376
|
+
}
|
|
377
|
+
if (total <= EVENT_PROGRESS_TOTAL_BYTES)
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
// Empty/nonterminal-only idle buckets may go; unknown terminal custody is
|
|
381
|
+
// explicitly exempt, not silently discarded to satisfy a count ceiling.
|
|
382
|
+
if (this.agentEvents.size > 512)
|
|
383
|
+
for (const [key, bucket] of this.agentEvents) {
|
|
384
|
+
if (this.agentEvents.size <= 512)
|
|
385
|
+
break;
|
|
386
|
+
if (!this.sessionCollectorFences.has(this.canonicalSessionKey(key)) && !bucket.some(ev => this.isTerminalBufferedEvent(ev)))
|
|
387
|
+
this.agentEvents.delete(key);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
228
390
|
bufferEvent(key, event) {
|
|
229
391
|
let bucket = this.agentEvents.get(key);
|
|
230
392
|
if (!bucket) {
|
|
231
393
|
bucket = [];
|
|
232
394
|
this.agentEvents.set(key, bucket);
|
|
233
395
|
}
|
|
234
|
-
bucket.push(event);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
}
|
|
396
|
+
bucket.push(this.projectBufferedEvent(event));
|
|
397
|
+
}
|
|
398
|
+
dispatchBufferedEvents() {
|
|
399
|
+
// Deliver the just-received projection to every current exact reader BEFORE
|
|
400
|
+
// applying lossy progress pressure, even if this is the oldest session in
|
|
401
|
+
// the map. Progress pressure must never manufacture effective silence.
|
|
402
|
+
for (const wake of this.eventCollectors)
|
|
403
|
+
wake();
|
|
404
|
+
this.enforceEventProgressBudget();
|
|
245
405
|
}
|
|
246
406
|
constructor(config) {
|
|
247
407
|
this.config = config;
|
|
@@ -339,14 +499,18 @@ export class OpenClawClient {
|
|
|
339
499
|
if (p) {
|
|
340
500
|
this.pending.delete(frame.id);
|
|
341
501
|
clearTimeout(p.timer);
|
|
342
|
-
if (frame.ok)
|
|
502
|
+
if (frame.ok) {
|
|
503
|
+
p.onSuccess?.(frame.payload);
|
|
343
504
|
p.resolve(frame.payload);
|
|
505
|
+
}
|
|
344
506
|
else
|
|
345
507
|
p.reject(new Error(`RPC error: ${JSON.stringify(frame.error)}`));
|
|
346
508
|
}
|
|
347
509
|
}
|
|
348
|
-
if (frame.event === 'agent' && ['tool', 'item'].includes(frame.payload?.stream))
|
|
510
|
+
if (frame.event === 'agent' && ['tool', 'item', 'command_output'].includes(frame.payload?.stream))
|
|
349
511
|
this.redactPrivateInputTool(frame.payload);
|
|
512
|
+
if (frame.event === 'session.message')
|
|
513
|
+
this.redactPrivateTranscriptTools(frame.payload);
|
|
350
514
|
// Public transcript projections can render an INPUT as role=assistant
|
|
351
515
|
// (forwarded sessions_send / internal receipts). Reject its structured
|
|
352
516
|
// identity before any collector, proactive callback or progress buffer.
|
|
@@ -415,13 +579,9 @@ export class OpenClawClient {
|
|
|
415
579
|
}
|
|
416
580
|
}
|
|
417
581
|
if (frame.type === "event" && ["agent", "chat"].includes(frame.event)) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
// exact public agent.wait, never an unbounded transcript cache.
|
|
422
|
-
for (const [key, bucket] of this.agentEvents)
|
|
423
|
-
if (bucket.length > 2048)
|
|
424
|
-
bucket.splice(0, bucket.length - 2048);
|
|
582
|
+
// No unconditional count splice: a foreign progress burst cannot
|
|
583
|
+
// evict a reserved/unknown full terminal.
|
|
584
|
+
this.dispatchBufferedEvents();
|
|
425
585
|
}
|
|
426
586
|
if (frame.type === "event" && ["agent", "chat", "session.message"].includes(frame.event) &&
|
|
427
587
|
!(frame.event === 'session.message' && this.isInputRecord(frame.payload))) {
|
|
@@ -458,7 +618,7 @@ export class OpenClawClient {
|
|
|
458
618
|
// now actually consumed it (session.message is emitted as the message
|
|
459
619
|
// is committed to the transcript). Render it on the live-status card
|
|
460
620
|
// in correct time order and flip the reaction to Get.
|
|
461
|
-
this.handleSteerConsumption(rawKey, visibleUserText);
|
|
621
|
+
this.handleSteerConsumption(rawKey, visibleUserText, frame.payload.runId);
|
|
462
622
|
}
|
|
463
623
|
const inputRecord = this.isInputRecord(frame.payload);
|
|
464
624
|
const visibleAssistantText = inputRecord ? "" : this.extractVisibleAssistantText(msg);
|
|
@@ -474,8 +634,7 @@ export class OpenClawClient {
|
|
|
474
634
|
}
|
|
475
635
|
if (!inputRecord)
|
|
476
636
|
this.handleProactiveSessionMessage(rawKey, msg, frame.payload.runId, frame.payload.sessionId);
|
|
477
|
-
|
|
478
|
-
wake();
|
|
637
|
+
this.dispatchBufferedEvents();
|
|
479
638
|
// Tool calls in assistant messages — skip, using agent item events instead
|
|
480
639
|
// (session.message toolCall events are batched, not real-time)
|
|
481
640
|
}
|
|
@@ -488,8 +647,8 @@ export class OpenClawClient {
|
|
|
488
647
|
}
|
|
489
648
|
if (frame.event === "agent" && frame.payload?.stream === "lifecycle" && frame.payload?.data?.phase === "end") {
|
|
490
649
|
const rawKey = frame.payload.sessionKey || "";
|
|
491
|
-
this.flushVerboseAssistantState(rawKey);
|
|
492
|
-
this.clearVerboseAssistantState(rawKey);
|
|
650
|
+
this.flushVerboseAssistantState(rawKey, frame.payload.runId);
|
|
651
|
+
this.clearVerboseAssistantState(rawKey, frame.payload.runId);
|
|
493
652
|
}
|
|
494
653
|
// Agent item / command output events — real-time tool tracking for verbose mode.
|
|
495
654
|
if (frame.event === "agent" && frame.payload?.stream === "item") {
|
|
@@ -500,20 +659,20 @@ export class OpenClawClient {
|
|
|
500
659
|
if (data.itemId && data.name)
|
|
501
660
|
this.lastToolNamesByItemId.set(String(data.itemId), String(data.name));
|
|
502
661
|
if (data.kind === "tool" && data.phase === "start") {
|
|
503
|
-
this.flushVerboseAssistantState(rawKey);
|
|
504
|
-
this.clearVerboseAssistantState(rawKey);
|
|
662
|
+
this.flushVerboseAssistantState(rawKey, frame.payload.runId);
|
|
663
|
+
this.clearVerboseAssistantState(rawKey, frame.payload.runId);
|
|
505
664
|
}
|
|
506
665
|
if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
|
|
507
666
|
const phase = data.phase;
|
|
508
667
|
const eventId = String(data.toolCallId || data.itemId || data.id || data.name);
|
|
509
|
-
if (this.claimToolEvent(rawKey, eventId, phase)) {
|
|
668
|
+
if (this.claimToolEvent(rawKey, eventId, phase, Date.now(), frame.payload.runId)) {
|
|
510
669
|
const meta = data.meta || data.input || data.args || "";
|
|
511
670
|
const output = data.output || data.result || data.error || "";
|
|
512
671
|
const inputText = this.formatToolValue(meta, String(data.name), "start");
|
|
513
672
|
const outputText = this.formatToolValue(output, String(data.name), phase);
|
|
514
673
|
if (toolCb)
|
|
515
674
|
toolCb(`${data.name} ${phase}`.trim(), inputText, outputText);
|
|
516
|
-
const progressCb = this.
|
|
675
|
+
const progressCb = this.progressForRun(rawKey, frame.payload.runId);
|
|
517
676
|
if (progressCb) {
|
|
518
677
|
const detail = phase === "start" ? inputText : (outputText || inputText);
|
|
519
678
|
void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
|
|
@@ -535,10 +694,10 @@ export class OpenClawClient {
|
|
|
535
694
|
: undefined;
|
|
536
695
|
const name = String(data.name || data.toolName || "tool");
|
|
537
696
|
const eventId = String(data.toolCallId || data.itemId || data.id || name);
|
|
538
|
-
if (phase && this.claimToolEvent(rawKey, eventId, phase)) {
|
|
697
|
+
if (phase && this.claimToolEvent(rawKey, eventId, phase, Date.now(), frame.payload.runId)) {
|
|
539
698
|
if (phase === "start") {
|
|
540
|
-
this.flushVerboseAssistantState(rawKey);
|
|
541
|
-
this.clearVerboseAssistantState(rawKey);
|
|
699
|
+
this.flushVerboseAssistantState(rawKey, frame.payload.runId);
|
|
700
|
+
this.clearVerboseAssistantState(rawKey, frame.payload.runId);
|
|
542
701
|
}
|
|
543
702
|
const input = data.input || data.args || data.meta || "";
|
|
544
703
|
const output = data.output || data.result || data.error || "";
|
|
@@ -547,7 +706,7 @@ export class OpenClawClient {
|
|
|
547
706
|
const toolCb = this.toolEventCallbacks.get(rawKey) || this.toolEventCallbacks.get(shortKey);
|
|
548
707
|
if (toolCb)
|
|
549
708
|
toolCb(`${name} ${phase}`, inputText, outputText);
|
|
550
|
-
const progressCb = this.
|
|
709
|
+
const progressCb = this.progressForRun(rawKey, frame.payload.runId);
|
|
551
710
|
if (progressCb) {
|
|
552
711
|
const detail = phase === "start" ? inputText : (outputText || inputText);
|
|
553
712
|
void progressCb({ kind: "tool", phase, name, text: `${name} ${phase}${detail ? `: ${detail}` : ""}` });
|
|
@@ -659,8 +818,27 @@ export class OpenClawClient {
|
|
|
659
818
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
660
819
|
return this.verboseTranscriptSessions.has(rawKey) || this.verboseTranscriptSessions.has(shortKey);
|
|
661
820
|
}
|
|
662
|
-
|
|
663
|
-
|
|
821
|
+
progressKey(sessionKey, runId) {
|
|
822
|
+
return JSON.stringify([this.canonicalSessionKey(sessionKey), runId || ""]);
|
|
823
|
+
}
|
|
824
|
+
progressForRun(sessionKey, runId) {
|
|
825
|
+
const key = this.canonicalSessionKey(this.shortKey(sessionKey));
|
|
826
|
+
// Installed public agent events already map runtime continuations to the
|
|
827
|
+
// admitted client runId. Session proximity/lifecycle starts are not lineage.
|
|
828
|
+
if (!runId || this.progressRuns.get(key) !== runId)
|
|
829
|
+
return undefined;
|
|
830
|
+
return this.progressCallbacks.get(key) || this.progressCallbacks.get(this.shortKey(key));
|
|
831
|
+
}
|
|
832
|
+
clearVerboseAssistantState(sessionKey, runId) {
|
|
833
|
+
const canonical = this.canonicalSessionKey(sessionKey);
|
|
834
|
+
const keys = runId !== undefined ? [this.progressKey(sessionKey, runId)]
|
|
835
|
+
: [...this.verboseAssistantLastTouched.keys()].filter(key => { try {
|
|
836
|
+
return JSON.parse(key)[0] === canonical;
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return false;
|
|
840
|
+
} });
|
|
841
|
+
for (const key of keys) {
|
|
664
842
|
const timer = this.verboseAssistantTimers.get(key);
|
|
665
843
|
if (timer)
|
|
666
844
|
clearTimeout(timer);
|
|
@@ -670,49 +848,145 @@ export class OpenClawClient {
|
|
|
670
848
|
this.verboseAssistantLastTouched.delete(key);
|
|
671
849
|
}
|
|
672
850
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
851
|
+
canonicalToolName(name) {
|
|
852
|
+
return String(name || "").split(/[.:]/).pop() || "";
|
|
853
|
+
}
|
|
854
|
+
/** Inspect supported public wrappers recursively, including JSON arguments.
|
|
855
|
+
* A wrapper containing any private coordination call has private results too;
|
|
856
|
+
* do not try to salvage arbitrary echoed result/error strings. */
|
|
857
|
+
isPrivateCoordination(name, value, depth = 0) {
|
|
858
|
+
const tool = this.canonicalToolName(name);
|
|
859
|
+
if (['sessions_spawn', 'sessions_send', 'sessions_yield'].includes(tool))
|
|
860
|
+
return true;
|
|
861
|
+
if (depth > 16)
|
|
862
|
+
return ['tool_call', 'parallel'].includes(tool);
|
|
863
|
+
let args = value;
|
|
684
864
|
if (typeof args === 'string') {
|
|
685
865
|
try {
|
|
686
866
|
args = JSON.parse(args);
|
|
687
867
|
}
|
|
688
868
|
catch {
|
|
689
|
-
|
|
869
|
+
return false;
|
|
690
870
|
}
|
|
691
871
|
}
|
|
692
|
-
|
|
693
|
-
|
|
872
|
+
if (!args || typeof args !== 'object')
|
|
873
|
+
return false;
|
|
874
|
+
if (tool === 'tool_call')
|
|
875
|
+
return this.isPrivateCoordination(args.id || args.name, args.args ?? args.parameters, depth + 1);
|
|
876
|
+
if (tool === 'parallel' && Array.isArray(args.tool_uses))
|
|
877
|
+
return args.tool_uses.some((call) => this.isPrivateCoordination(call?.recipient_name || call?.name || call?.id, call?.parameters ?? call?.args, depth + 1));
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
knownPublicWrapper(name, value, depth = 0) {
|
|
881
|
+
if (depth > 16)
|
|
882
|
+
return false;
|
|
883
|
+
const tool = this.canonicalToolName(name);
|
|
884
|
+
if (['sessions_spawn', 'sessions_send', 'sessions_yield'].includes(tool))
|
|
885
|
+
return false;
|
|
886
|
+
if (!['tool_call', 'parallel'].includes(tool))
|
|
887
|
+
return Boolean(tool);
|
|
888
|
+
let args = value;
|
|
889
|
+
if (typeof args === 'string') {
|
|
890
|
+
try {
|
|
891
|
+
args = JSON.parse(args);
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (!args || typeof args !== 'object')
|
|
898
|
+
return false;
|
|
899
|
+
if (tool === 'tool_call')
|
|
900
|
+
return this.knownPublicWrapper(args.id || args.name, args.args ?? args.parameters, depth + 1);
|
|
901
|
+
return Array.isArray(args.tool_uses) && args.tool_uses.every((call) => this.knownPublicWrapper(call?.recipient_name || call?.name || call?.id, call?.parameters ?? call?.args, depth + 1));
|
|
902
|
+
}
|
|
903
|
+
/** Redact BEFORE event buffers, logging, observers, cards and verbose sinks.
|
|
904
|
+
* Call custody is session+run+id scoped, so item/command/result mirrors stay
|
|
905
|
+
* private even after the original wrapper arguments have disappeared. */
|
|
906
|
+
redactPrivateInputTool(payload) {
|
|
907
|
+
const data = payload?.data;
|
|
908
|
+
if (!data || payload.stream === 'item' && data.kind !== 'tool')
|
|
909
|
+
return false;
|
|
910
|
+
const eventId = String(data.toolCallId || data.itemId || data.id || '').replace(/^(?:tool|command):/, '');
|
|
911
|
+
const key = this.progressKey(payload.sessionKey || '', payload.runId) + ':' + eventId;
|
|
912
|
+
const name = data.name || data.toolName;
|
|
913
|
+
const wrapper = ['tool_call', 'parallel'].includes(this.canonicalToolName(name));
|
|
914
|
+
const privateBody = [data.input, data.args, data.meta, data.output, data.result, data.error].some(value => this.isPrivateCoordination(name, value));
|
|
915
|
+
if (data.phase === 'start' && eventId && !privateBody && this.knownPublicWrapper(name, data.input ?? data.args ?? data.meta)) {
|
|
916
|
+
this.publicToolCalls.add(key);
|
|
917
|
+
if (this.publicToolCalls.size > 2048)
|
|
918
|
+
this.publicToolCalls.delete(this.publicToolCalls.values().next().value);
|
|
919
|
+
}
|
|
920
|
+
// An orphan wrapper result can echo hidden arguments. Only a correlated
|
|
921
|
+
// known-public call may expose its arbitrary scalar output.
|
|
922
|
+
const ambiguousResult = (wrapper || ['', 'tool', 'command_output'].includes(this.canonicalToolName(name))) && data.phase !== 'start' && !this.publicToolCalls.has(key);
|
|
694
923
|
if (privateBody && eventId) {
|
|
695
924
|
this.privateInputToolCalls.add(key);
|
|
696
925
|
if (this.privateInputToolCalls.size > 2048)
|
|
697
926
|
this.privateInputToolCalls.delete(this.privateInputToolCalls.values().next().value);
|
|
698
927
|
}
|
|
699
|
-
if (!privateBody && !this.privateInputToolCalls.has(key))
|
|
700
|
-
return;
|
|
928
|
+
if (!privateBody && !ambiguousResult && (!eventId || !this.privateInputToolCalls.has(key)))
|
|
929
|
+
return false;
|
|
701
930
|
payload.data = Object.fromEntries(['kind', 'phase', 'name', 'toolName', 'toolCallId', 'itemId', 'id', 'isError'].filter(k => data[k] !== undefined).map(k => [k, data[k]]));
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
redactPrivateTranscriptTools(payload) {
|
|
934
|
+
const msg = payload?.message || payload;
|
|
935
|
+
if (!msg || typeof msg !== 'object')
|
|
936
|
+
return;
|
|
937
|
+
const redact = (part, phase) => {
|
|
938
|
+
const synthetic = { sessionKey: payload?.sessionKey, runId: payload?.runId, stream: 'tool', data: {
|
|
939
|
+
...part, name: part?.name || part?.toolName, toolCallId: part?.toolCallId || part?.tool_use_id || part?.id,
|
|
940
|
+
phase, input: part?.arguments ?? part?.input ?? part?.args, result: part?.content ?? part?.output ?? part?.result,
|
|
941
|
+
} };
|
|
942
|
+
return this.redactPrivateInputTool(synthetic);
|
|
943
|
+
};
|
|
944
|
+
if (['tool', 'toolResult'].includes(msg.role) && redact(msg, 'result')) {
|
|
945
|
+
msg.content = [];
|
|
946
|
+
delete msg.output;
|
|
947
|
+
delete msg.result;
|
|
948
|
+
delete msg.error;
|
|
949
|
+
delete msg.arguments;
|
|
950
|
+
delete msg.input;
|
|
951
|
+
delete msg.args;
|
|
952
|
+
}
|
|
953
|
+
if (Array.isArray(msg.content))
|
|
954
|
+
msg.content = msg.content.map((part) => {
|
|
955
|
+
const type = String(part?.type || '').toLowerCase();
|
|
956
|
+
const phase = ['toolcall', 'tool_call', 'tooluse', 'tool_use'].includes(type) ? 'start'
|
|
957
|
+
: ['toolresult', 'tool_result'].includes(type) ? 'result' : undefined;
|
|
958
|
+
if (!phase || !redact(part, phase))
|
|
959
|
+
return part;
|
|
960
|
+
return Object.fromEntries(['type', 'id', 'name', 'toolName', 'toolCallId', 'tool_use_id', 'isError'].filter(k => part[k] !== undefined).map(k => [k, part[k]]));
|
|
961
|
+
});
|
|
702
962
|
}
|
|
703
963
|
/** Render protocol-4 structured tool args/results without `[object Object]`.
|
|
704
964
|
* Prefer useful, non-secret fields and keep the fallback bounded. */
|
|
705
965
|
formatToolValue(value, toolName, phase) {
|
|
706
|
-
|
|
707
|
-
// arguments/results (including JSON strings) into process cards or verbose
|
|
708
|
-
// tool replies. This is redaction only, never evidence of a handoff.
|
|
709
|
-
if (toolName === "sessions_yield" || toolName === "sessions_send")
|
|
966
|
+
if (this.isPrivateCoordination(toolName, value))
|
|
710
967
|
return "";
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
968
|
+
toolName = this.canonicalToolName(toolName);
|
|
969
|
+
if (phase === 'start' && ['tool_call', 'parallel'].includes(toolName)) {
|
|
970
|
+
let args = value;
|
|
971
|
+
if (typeof args === 'string') {
|
|
972
|
+
try {
|
|
973
|
+
args = JSON.parse(args);
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
return "";
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (toolName === 'tool_call' && args?.id) {
|
|
980
|
+
const name = this.canonicalToolName(args.id), detail = this.formatToolValue(args.args ?? args.parameters, name, phase);
|
|
981
|
+
return `${name}${detail ? ': ' + detail : ''}`;
|
|
982
|
+
}
|
|
983
|
+
if (toolName === 'parallel' && Array.isArray(args?.tool_uses))
|
|
984
|
+
return args.tool_uses.slice(0, 4).map((call) => {
|
|
985
|
+
const name = this.canonicalToolName(call?.recipient_name || call?.name || call?.id), detail = this.formatToolValue(call?.parameters ?? call?.args, name, phase);
|
|
986
|
+
return `${name}${detail ? ': ' + detail : ''}`;
|
|
987
|
+
}).join('; ');
|
|
715
988
|
return "";
|
|
989
|
+
}
|
|
716
990
|
if (value === null || value === undefined || value === "")
|
|
717
991
|
return "";
|
|
718
992
|
if (typeof value === "string")
|
|
@@ -779,10 +1053,10 @@ export class OpenClawClient {
|
|
|
779
1053
|
}
|
|
780
1054
|
return parts.join(", ");
|
|
781
1055
|
}
|
|
782
|
-
claimToolEvent(sessionKey, eventId, phase, now = Date.now()) {
|
|
1056
|
+
claimToolEvent(sessionKey, eventId, phase, now = Date.now(), runId) {
|
|
783
1057
|
const normalizedPhase = phase === "error" ? "end" : phase;
|
|
784
1058
|
const normalizedId = eventId.replace(/^(?:tool|command):/, "");
|
|
785
|
-
const key = `${this.
|
|
1059
|
+
const key = `${this.progressKey(sessionKey, runId)}:${normalizedId}:${normalizedPhase}`;
|
|
786
1060
|
const seenAt = this.recentToolEventKeys.get(key);
|
|
787
1061
|
if (seenAt !== undefined && now - seenAt < 60_000)
|
|
788
1062
|
return false;
|
|
@@ -817,15 +1091,15 @@ export class OpenClawClient {
|
|
|
817
1091
|
this.recentToolEventKeys.delete(key);
|
|
818
1092
|
}
|
|
819
1093
|
}
|
|
820
|
-
flushVerboseAssistantState(rawKey) {
|
|
1094
|
+
flushVerboseAssistantState(rawKey, runId) {
|
|
821
1095
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
822
1096
|
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
823
|
-
const progressCb = this.
|
|
1097
|
+
const progressCb = this.progressForRun(rawKey, runId);
|
|
824
1098
|
if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
|
|
825
1099
|
return false;
|
|
826
1100
|
if (!cb && !progressCb)
|
|
827
1101
|
return false;
|
|
828
|
-
const key = rawKey;
|
|
1102
|
+
const key = this.progressKey(rawKey, runId);
|
|
829
1103
|
const latest = this.verboseAssistantLatest.get(key)?.trim() || "";
|
|
830
1104
|
if (!latest)
|
|
831
1105
|
return false;
|
|
@@ -840,7 +1114,7 @@ export class OpenClawClient {
|
|
|
840
1114
|
return false;
|
|
841
1115
|
this.verboseAssistantSent.set(key, latest);
|
|
842
1116
|
if (this.isVerboseTranscriptEnabled(rawKey) && cb)
|
|
843
|
-
cb(toSend, { sourceType: "verbose_transcript" });
|
|
1117
|
+
cb(toSend, { sourceType: "verbose_transcript", ...(runId ? { runId, sessionKey: this.canonicalSessionKey(rawKey) } : {}) });
|
|
844
1118
|
if (progressCb)
|
|
845
1119
|
void progressCb({ kind: "assistant_note", text: toSend });
|
|
846
1120
|
return true;
|
|
@@ -853,7 +1127,7 @@ export class OpenClawClient {
|
|
|
853
1127
|
return;
|
|
854
1128
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
855
1129
|
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
856
|
-
const progressCb = this.
|
|
1130
|
+
const progressCb = this.progressForRun(rawKey, payload.runId);
|
|
857
1131
|
if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
|
|
858
1132
|
return;
|
|
859
1133
|
if (!cb && !progressCb)
|
|
@@ -861,7 +1135,7 @@ export class OpenClawClient {
|
|
|
861
1135
|
const data = payload.data || {};
|
|
862
1136
|
const fullText = typeof data.text === "string" ? data.text : typeof data.deltaText === "string" && data.replace ? data.deltaText : "";
|
|
863
1137
|
const deltaText = !fullText && typeof data.delta === "string" ? data.delta : !fullText && typeof data.deltaText === "string" ? data.deltaText : "";
|
|
864
|
-
const key = rawKey;
|
|
1138
|
+
const key = this.progressKey(rawKey, payload.runId);
|
|
865
1139
|
const previous = this.verboseAssistantLatest.get(key) || "";
|
|
866
1140
|
const text = (fullText || (previous + deltaText)).trim().slice(-1024 * 1024);
|
|
867
1141
|
if (!text)
|
|
@@ -871,7 +1145,7 @@ export class OpenClawClient {
|
|
|
871
1145
|
this.verboseAssistantLastTouched.set(key, now);
|
|
872
1146
|
this.pruneVerboseCaches(now);
|
|
873
1147
|
if (text.length >= 3000) {
|
|
874
|
-
this.flushVerboseAssistantState(rawKey);
|
|
1148
|
+
this.flushVerboseAssistantState(rawKey, payload.runId);
|
|
875
1149
|
}
|
|
876
1150
|
}
|
|
877
1151
|
handleProactiveSessionMessage(rawKey, msg, runId, sessionId) {
|
|
@@ -948,17 +1222,16 @@ export class OpenClawClient {
|
|
|
948
1222
|
this.pending.delete(id);
|
|
949
1223
|
reject(new Error(`RPC timeout: ${method}`));
|
|
950
1224
|
}, timeoutMs);
|
|
951
|
-
this.pending.set(id, { resolve, reject, timer
|
|
1225
|
+
this.pending.set(id, { resolve, reject, timer,
|
|
1226
|
+
onSuccess: method === 'chat.send' ? this.admissionResponseHooks.get(params?.idempotencyKey) : undefined });
|
|
952
1227
|
this.ws.send(JSON.stringify({ type: "req", id, method, params }));
|
|
953
1228
|
});
|
|
954
1229
|
}
|
|
955
|
-
/**
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
* triggers read-only reconciliation, never a task-lifetime cutoff or draft final.
|
|
961
|
-
* Explicit terminal/error/disconnect paths keep their existing behavior.
|
|
1230
|
+
/** Exact admitted-root observation with non-destructive event cursors.
|
|
1231
|
+
* Gateway maps runtime continuations to its public client runId; an unrelated
|
|
1232
|
+
* session lifecycle start is not lineage. Separate requester/task continuation
|
|
1233
|
+
* lineage remains owned by handoffLinkedContinuation + exact root recovery.
|
|
1234
|
+
* Effective silence prompts read-only reconciliation, never a lifetime cutoff.
|
|
962
1235
|
*/
|
|
963
1236
|
collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
|
|
964
1237
|
let cleanupObservation = () => { };
|
|
@@ -968,16 +1241,7 @@ export class OpenClawClient {
|
|
|
968
1241
|
// this keeps unit tests that drive collectReply without a live socket from
|
|
969
1242
|
// tripping the disconnect path.
|
|
970
1243
|
const startedConnected = this.connected;
|
|
971
|
-
let text = "";
|
|
972
|
-
let chatDeltaText = "";
|
|
973
1244
|
let chatFinalText = "";
|
|
974
|
-
let chatFinalSeen = false;
|
|
975
|
-
let transcriptAssistantText = "";
|
|
976
|
-
let draftOverflow = false;
|
|
977
|
-
const boundedDraft = (value) => { if (value.length > 1024 * 1024) {
|
|
978
|
-
draftOverflow = true;
|
|
979
|
-
return value.slice(-1024 * 1024);
|
|
980
|
-
} return value; };
|
|
981
1245
|
let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
|
|
982
1246
|
let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
|
|
983
1247
|
let chatFinalTimer = null;
|
|
@@ -995,19 +1259,9 @@ export class OpenClawClient {
|
|
|
995
1259
|
let lastActivityAt = 0;
|
|
996
1260
|
const collectStartedAt = Date.now();
|
|
997
1261
|
let lifecycleStartedLogged = false;
|
|
998
|
-
const expectedUserText = options?.expectedUserText?.trim() || "";
|
|
999
|
-
let anchorSeen = !expectedUserText;
|
|
1000
1262
|
const activeRunIds = new Set([runId]);
|
|
1001
|
-
|
|
1002
|
-
const
|
|
1003
|
-
const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
|
|
1004
|
-
const isExpectedUserText = (value) => {
|
|
1005
|
-
const actual = normalizeText(value);
|
|
1006
|
-
const expected = normalizeText(expectedUserText);
|
|
1007
|
-
if (!actual || !expected)
|
|
1008
|
-
return false;
|
|
1009
|
-
return actual === expected || actual.includes(expected) || expected.includes(actual);
|
|
1010
|
-
};
|
|
1263
|
+
const seenEvents = new WeakSet();
|
|
1264
|
+
const releaseEventReader = this.retainRunEventCustody('reader', targetSessionKey, runId);
|
|
1011
1265
|
let idleTimer;
|
|
1012
1266
|
let observationClosed = false;
|
|
1013
1267
|
// Close synchronously: a deferred yield notice must not overtake a final
|
|
@@ -1020,11 +1274,15 @@ export class OpenClawClient {
|
|
|
1020
1274
|
let decidingTimeout = false;
|
|
1021
1275
|
let waitNoticeSent = false;
|
|
1022
1276
|
let foregroundPaused = false;
|
|
1023
|
-
let foregroundYielded = false;
|
|
1024
1277
|
let turnYielded = false;
|
|
1025
|
-
const handleTimeoutResult = async (error) => {
|
|
1278
|
+
const handleTimeoutResult = async (error, stillCurrent = () => true) => {
|
|
1279
|
+
collectEvents();
|
|
1026
1280
|
if (observationClosed)
|
|
1027
1281
|
return;
|
|
1282
|
+
if (!stillCurrent()) {
|
|
1283
|
+
decidingTimeout = false;
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1028
1286
|
if (chatFinalText.trim()) {
|
|
1029
1287
|
finish(chatFinalText);
|
|
1030
1288
|
return;
|
|
@@ -1043,7 +1301,7 @@ export class OpenClawClient {
|
|
|
1043
1301
|
}
|
|
1044
1302
|
}
|
|
1045
1303
|
decidingTimeout = false;
|
|
1046
|
-
if (!observationClosed)
|
|
1304
|
+
if (!observationClosed && stillCurrent())
|
|
1047
1305
|
resetIdleTimer(60_000, false);
|
|
1048
1306
|
return;
|
|
1049
1307
|
}
|
|
@@ -1054,10 +1312,17 @@ export class OpenClawClient {
|
|
|
1054
1312
|
return;
|
|
1055
1313
|
checkingState = true;
|
|
1056
1314
|
lastStateCheckAt = Date.now();
|
|
1315
|
+
const decisionGeneration = idleGeneration;
|
|
1057
1316
|
try {
|
|
1058
1317
|
const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
|
|
1318
|
+
// A query can return a terminal projection sampled before a newer
|
|
1319
|
+
// same-root continuation made real progress. Drain the received wire
|
|
1320
|
+
// first and fence that stale cut; a fresh exact query may still fail.
|
|
1321
|
+
collectEvents();
|
|
1322
|
+
if (!idleStillCurrent() || idleGeneration !== decisionGeneration)
|
|
1323
|
+
return;
|
|
1059
1324
|
if (observationClosed || snapshot?.runId !== runId || snapshot?.pendingError === true
|
|
1060
|
-
|| snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
|
|
1325
|
+
|| snapshot?.yielded === true || snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
|
|
1061
1326
|
return;
|
|
1062
1327
|
// A bare wait timeout is not an execution timeout. Require a recorded
|
|
1063
1328
|
// terminal time; never infer failure from stopReason or replay flags.
|
|
@@ -1069,26 +1334,17 @@ export class OpenClawClient {
|
|
|
1069
1334
|
const terminal = snapshot.terminalReply;
|
|
1070
1335
|
// B6 (2026-10-16 audit): disposition "empty" is an authoritative
|
|
1071
1336
|
// no-visible-text terminal — never recycle an interim draft as the
|
|
1072
|
-
// final answer.
|
|
1337
|
+
// final answer. Unspecified/malformed metadata retains observation.
|
|
1073
1338
|
const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
|
|
1074
1339
|
? terminal.text
|
|
1075
1340
|
: terminal?.disposition === "silent" || terminal?.disposition === "empty"
|
|
1076
1341
|
? "NO_REPLY"
|
|
1077
1342
|
: "";
|
|
1078
|
-
if (!visible
|
|
1343
|
+
if (!visible.trim())
|
|
1079
1344
|
return;
|
|
1080
|
-
resolve(visible
|
|
1345
|
+
resolve(visible);
|
|
1081
1346
|
}
|
|
1082
1347
|
else if (snapshot.status === "error" || snapshot.status === "timeout") {
|
|
1083
|
-
if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
|
|
1084
|
-
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
1085
|
-
collectEvents();
|
|
1086
|
-
if (!observationClosed && status.running) {
|
|
1087
|
-
if (idleStillCurrent())
|
|
1088
|
-
await handleTimeoutResult(new SessionWaitPaused(status, true));
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
1348
|
if (observationClosed)
|
|
1093
1349
|
return;
|
|
1094
1350
|
reject(new Error(`Agent error: ${typeof snapshot.error === "string" && snapshot.error.trim()
|
|
@@ -1103,6 +1359,9 @@ export class OpenClawClient {
|
|
|
1103
1359
|
// One clock for foreground silence and subsequent low-frequency observation.
|
|
1104
1360
|
// A generation fences status RPCs that complete after newer real activity.
|
|
1105
1361
|
let idleGeneration = 0;
|
|
1362
|
+
let idleGuardTimer;
|
|
1363
|
+
const clearIdleGuard = () => { if (idleGuardTimer)
|
|
1364
|
+
clearTimeout(idleGuardTimer); idleGuardTimer = undefined; };
|
|
1106
1365
|
const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
|
|
1107
1366
|
if (activity) {
|
|
1108
1367
|
if (!foregroundPaused)
|
|
@@ -1113,33 +1372,54 @@ export class OpenClawClient {
|
|
|
1113
1372
|
const idleStillCurrent = () => !observationClosed && generation === idleGeneration;
|
|
1114
1373
|
if (idleTimer)
|
|
1115
1374
|
clearTimeout(idleTimer);
|
|
1375
|
+
clearIdleGuard();
|
|
1116
1376
|
if (observationClosed)
|
|
1117
1377
|
return;
|
|
1118
|
-
idleTimer = setTimeout(
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1378
|
+
idleTimer = setTimeout(() => {
|
|
1379
|
+
const ownGuard = setTimeout(() => {
|
|
1380
|
+
if (!idleStillCurrent())
|
|
1381
|
+
return;
|
|
1382
|
+
// Remove success suppression synchronously BEFORE closing this exact
|
|
1383
|
+
// observer. Late final/error stays on its durable original route.
|
|
1384
|
+
this.releasePausedWait(runId);
|
|
1385
|
+
cleanupObservation();
|
|
1386
|
+
reject(new CollectorStalledError());
|
|
1387
|
+
}, IDLE_GUARD_MS);
|
|
1388
|
+
idleGuardTimer = ownGuard;
|
|
1389
|
+
ownGuard.unref?.();
|
|
1390
|
+
void (async () => {
|
|
1391
|
+
// Drain already-received frames before judging silence: the 50ms
|
|
1392
|
+
// collection poll may otherwise run just after this deadline.
|
|
1393
|
+
collectEvents();
|
|
1394
|
+
if (!idleStillCurrent())
|
|
1395
|
+
return;
|
|
1396
|
+
if (!quietNotified) {
|
|
1397
|
+
quietNotified = true;
|
|
1398
|
+
console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
|
|
1399
|
+
}
|
|
1400
|
+
await checkRunState(idleStillCurrent);
|
|
1401
|
+
if (!idleStillCurrent())
|
|
1402
|
+
return;
|
|
1403
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
1404
|
+
collectEvents();
|
|
1405
|
+
if (!idleStillCurrent())
|
|
1406
|
+
return;
|
|
1407
|
+
// With a foreground callback, silence freezes presentation even when
|
|
1408
|
+
// status is unknown/idle; never invent running. Without one, retain
|
|
1409
|
+
// the published running-only typed outcome and background checks.
|
|
1410
|
+
if (status.running || options?.onWaitPaused) {
|
|
1411
|
+
await handleTimeoutResult(new SessionWaitPaused(status), idleStillCurrent);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
resetIdleTimer(60_000, false);
|
|
1415
|
+
})().catch(error => { if (idleStillCurrent())
|
|
1416
|
+
reject(error instanceof Error ? error : new Error(String(error))); })
|
|
1417
|
+
.finally(() => {
|
|
1418
|
+
// An obsolete callback must never clear a newer callback's guard.
|
|
1419
|
+
clearTimeout(ownGuard);
|
|
1420
|
+
if (idleGuardTimer === ownGuard)
|
|
1421
|
+
idleGuardTimer = undefined;
|
|
1422
|
+
});
|
|
1143
1423
|
}, delayMs);
|
|
1144
1424
|
};
|
|
1145
1425
|
// Activity filtering is deliberately separate from reply assembly/matching:
|
|
@@ -1176,7 +1456,9 @@ export class OpenClawClient {
|
|
|
1176
1456
|
}
|
|
1177
1457
|
if (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") {
|
|
1178
1458
|
const fullText = typeof data.text === "string" ? data.text : "";
|
|
1179
|
-
const
|
|
1459
|
+
const rawChunk = fullText || data.deltaText || data.delta;
|
|
1460
|
+
const chunk = typeof data.activityFingerprint === 'string'
|
|
1461
|
+
? `[activity-sha256:${data.activityFingerprint}]` : rawChunk;
|
|
1180
1462
|
const replace = Boolean(fullText || data.replace);
|
|
1181
1463
|
if (typeof chunk !== "string" || !chunk.trim())
|
|
1182
1464
|
return false;
|
|
@@ -1234,26 +1516,8 @@ export class OpenClawClient {
|
|
|
1234
1516
|
lastActivityAt = Date.now();
|
|
1235
1517
|
}
|
|
1236
1518
|
};
|
|
1237
|
-
const buildFailureText = (ev) => {
|
|
1238
|
-
const state = ev.data?.livenessState || ev.data?.status || "unknown";
|
|
1239
|
-
const reason = ev.data?.stopReason || "";
|
|
1240
|
-
const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
|
|
1241
|
-
const lines = [`⚠️ Agent 未正常完成`, `状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}`];
|
|
1242
|
-
if (lastActivitySummary) {
|
|
1243
|
-
lines.push(`最后活动: ${lastActivitySummary}`);
|
|
1244
|
-
lines.push(`最后活动时间: ${new Date(lastActivityAt).toLocaleString()}`);
|
|
1245
|
-
}
|
|
1246
|
-
lines.push("请重试,或用 /reset 重置会话");
|
|
1247
|
-
return lines.join("\n");
|
|
1248
|
-
};
|
|
1249
1519
|
resetIdleTimer();
|
|
1250
1520
|
const finish = (finalText) => {
|
|
1251
|
-
if (draftOverflow && finalText !== 'NO_REPLY' && finalText !== chatFinalText) {
|
|
1252
|
-
// Never publish a truncated accumulator. Keep exact result custody
|
|
1253
|
-
// until authoritative root terminalReply or full root chat final.
|
|
1254
|
-
void checkRunState(() => true, true);
|
|
1255
|
-
return;
|
|
1256
|
-
}
|
|
1257
1521
|
clearTimeout(idleTimer);
|
|
1258
1522
|
clearInterval(poller);
|
|
1259
1523
|
if (chatFinalTimer)
|
|
@@ -1294,111 +1558,24 @@ export class OpenClawClient {
|
|
|
1294
1558
|
continue;
|
|
1295
1559
|
let i = 0;
|
|
1296
1560
|
while (i < bucket.length) {
|
|
1297
|
-
const ev = bucket[i];
|
|
1561
|
+
const ev = bucket[i++];
|
|
1562
|
+
if (seenEvents.has(ev))
|
|
1563
|
+
continue;
|
|
1298
1564
|
const evRunId = typeof ev.runId === "string" ? ev.runId : "";
|
|
1299
|
-
const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey
|
|
1565
|
+
const eventSessionMatches = Boolean(sessionKey && this.shortKey(ev.sessionKey || bucketKey) === this.shortKey(sessionKey));
|
|
1300
1566
|
const matchesRun = evRunId ? activeRunIds.has(evRunId) : false;
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
if (evRunId && foreignUserRuns.has(evRunId)) {
|
|
1306
|
-
i++;
|
|
1567
|
+
// Non-destructive per-observer cursor: ACK and a replacement final
|
|
1568
|
+
// may be delivered synchronously before the awaiting sender resumes.
|
|
1569
|
+
// Leave every foreign/unidentified frame available to its own owner.
|
|
1570
|
+
if (!matchesRun || sessionKey && !eventSessionMatches)
|
|
1307
1571
|
continue;
|
|
1308
|
-
|
|
1572
|
+
seenEvents.add(ev);
|
|
1309
1573
|
if (!sessionKey && ev.sessionKey) {
|
|
1310
1574
|
shortSessionKey = String(ev.sessionKey).replace(/^agent:[^:]+:/, "");
|
|
1311
|
-
sessionKey =
|
|
1312
|
-
}
|
|
1313
|
-
if (eventSessionMatches && ev.stream === "sessionUser") {
|
|
1314
|
-
if (anchorSeen && evRunId && !matchesRun) {
|
|
1315
|
-
foreignUserRuns.add(evRunId);
|
|
1316
|
-
i++;
|
|
1317
|
-
continue;
|
|
1318
|
-
}
|
|
1319
|
-
bucket.splice(i, 1);
|
|
1320
|
-
if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
|
|
1321
|
-
anchorSeen = true;
|
|
1322
|
-
for (const pendingEv of preAnchorEvents) {
|
|
1323
|
-
const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
|
|
1324
|
-
if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
|
|
1325
|
-
activeRunIds.add(pendingRunId);
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
const replay = preAnchorEvents.filter((pendingEv) => {
|
|
1329
|
-
const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
|
|
1330
|
-
return !pendingRunId || activeRunIds.has(pendingRunId);
|
|
1331
|
-
});
|
|
1332
|
-
preAnchorEvents = [];
|
|
1333
|
-
if (replay.length)
|
|
1334
|
-
bucket.splice(i, 0, ...replay);
|
|
1335
|
-
}
|
|
1336
|
-
else if (!anchorSeen) {
|
|
1337
|
-
// A different user/runtime continuation belongs to stale queued work;
|
|
1338
|
-
// anything before it should not be attributed to the next real user turn.
|
|
1339
|
-
preAnchorEvents = [];
|
|
1340
|
-
}
|
|
1341
|
-
continue;
|
|
1342
|
-
}
|
|
1343
|
-
// After handoff, an unowned new run (including requester-settle)
|
|
1344
|
-
// retains its existing proactive delivery route. A session/lifecycle
|
|
1345
|
-
// start alone must not turn it into the old run's final. Discussion's
|
|
1346
|
-
// transcript mute marks scheduler-owned collection; retain its existing
|
|
1347
|
-
// anchored collector path rather than changing discussion routing.
|
|
1348
|
-
if (eventSessionMatches && foregroundYielded && evRunId && !matchesRun
|
|
1349
|
-
&& !this.mutedProactiveSessions.has(sessionKey) && !this.mutedProactiveSessions.has(shortSessionKey)) {
|
|
1350
|
-
bucket.splice(i, 1);
|
|
1351
|
-
continue;
|
|
1352
|
-
}
|
|
1353
|
-
if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
|
|
1354
|
-
bucket.splice(i, 1);
|
|
1355
|
-
preAnchorEvents.push(ev);
|
|
1356
|
-
if (preAnchorEvents.length > 2048)
|
|
1357
|
-
preAnchorEvents.splice(0, preAnchorEvents.length - 2048);
|
|
1358
|
-
if (preAnchorEvents.length > 200)
|
|
1359
|
-
preAnchorEvents.shift();
|
|
1360
|
-
continue;
|
|
1361
|
-
}
|
|
1362
|
-
let matchesSession = false;
|
|
1363
|
-
if (!options?.exactRunIdentity && eventSessionMatches && anchorSeen) {
|
|
1364
|
-
if (!expectedUserText) {
|
|
1365
|
-
// Backward-compatible mode for tests/internal callers that do not
|
|
1366
|
-
// provide an anchor: allow session-key matching as before.
|
|
1367
|
-
matchesSession = true;
|
|
1368
|
-
}
|
|
1369
|
-
else if (!evRunId) {
|
|
1370
|
-
matchesSession = true;
|
|
1371
|
-
}
|
|
1372
|
-
else if (activeRunIds.has(evRunId)) {
|
|
1373
|
-
matchesSession = true;
|
|
1374
|
-
}
|
|
1375
|
-
else if (ev.stream === "lifecycle" && ev.data?.phase === "start") {
|
|
1376
|
-
activeRunIds.add(evRunId);
|
|
1377
|
-
matchesSession = true;
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
if (!(matchesRun || matchesSession)) {
|
|
1381
|
-
// When collectReply is anchored to a specific user message, discard stale
|
|
1382
|
-
// session events that arrive before that anchor (or from unrelated runIds)
|
|
1383
|
-
// so replayInvalid/aborted events from older runtime continuations cannot
|
|
1384
|
-
// be misattributed to the current user turn.
|
|
1385
|
-
if (eventSessionMatches && expectedUserText) {
|
|
1386
|
-
bucket.splice(i, 1);
|
|
1387
|
-
}
|
|
1388
|
-
else {
|
|
1389
|
-
i++;
|
|
1390
|
-
}
|
|
1391
|
-
continue;
|
|
1575
|
+
sessionKey = this.canonicalSessionKey(ev.sessionKey);
|
|
1392
1576
|
}
|
|
1393
|
-
|
|
1394
|
-
// Continuation progress is valid, but only the admitted root's
|
|
1395
|
-
// committed terminalReply can authorize another run's final text.
|
|
1396
|
-
if (evRunId && evRunId !== runId && (ev.stream === 'chatFinal' || ev.stream === 'chatError' || ev.stream === 'lifecycle' && ['end', 'error'].includes(ev.data?.phase))) {
|
|
1397
|
-
if (ev.stream === 'chatFinal' && ev.data?.gatewayYielded === true)
|
|
1398
|
-
continue;
|
|
1399
|
-
void checkRunState(() => true, true);
|
|
1577
|
+
if (ev.stream === 'sessionUser')
|
|
1400
1578
|
continue;
|
|
1401
|
-
}
|
|
1402
1579
|
if (hasEffectiveActivity(ev))
|
|
1403
1580
|
resetIdleTimer();
|
|
1404
1581
|
rememberActivity(ev);
|
|
@@ -1462,48 +1639,18 @@ export class OpenClawClient {
|
|
|
1462
1639
|
lifecycleStartedLogged = true;
|
|
1463
1640
|
console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
|
|
1464
1641
|
}
|
|
1465
|
-
if ((!evRunId || evRunId === runId) && (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") && (ev.data?.deltaText || ev.data?.delta)) {
|
|
1466
|
-
const chunk = ev.data.deltaText || ev.data.delta;
|
|
1467
|
-
if (ev.stream === "assistant") {
|
|
1468
|
-
if (ev.data?.replace) {
|
|
1469
|
-
text = boundedDraft(chunk);
|
|
1470
|
-
}
|
|
1471
|
-
else {
|
|
1472
|
-
text = boundedDraft(text + chunk);
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
else if (ev.stream === "chatDelta") {
|
|
1476
|
-
if (ev.data?.replace) {
|
|
1477
|
-
chatDeltaText = boundedDraft(chunk);
|
|
1478
|
-
}
|
|
1479
|
-
else {
|
|
1480
|
-
chatDeltaText = boundedDraft(chatDeltaText + chunk);
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
else {
|
|
1484
|
-
if (ev.data?.replace) {
|
|
1485
|
-
transcriptAssistantText = boundedDraft(chunk);
|
|
1486
|
-
}
|
|
1487
|
-
else {
|
|
1488
|
-
transcriptAssistantText = boundedDraft(transcriptAssistantText + chunk);
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
1642
|
if (ev.stream === "chatFinal") {
|
|
1493
1643
|
// Only the exact admitted root's public chat envelope can yield
|
|
1494
1644
|
// scheduling custody. Session fallback/foreign/tool flags cannot.
|
|
1495
1645
|
if (ev.data?.gatewayYielded === true && (evRunId !== runId || !eventSessionMatches))
|
|
1496
1646
|
continue;
|
|
1497
|
-
chatFinalSeen = true;
|
|
1498
1647
|
if (ev.data?.gatewayYielded === true) {
|
|
1499
1648
|
// A yielded turn is not a terminal answer, even if its buffered
|
|
1500
1649
|
// message contains a preface. Keep the final key unclaimed;
|
|
1501
1650
|
// opt-in durable handoff can retire only foreground observation.
|
|
1502
1651
|
if (chatFinalText.trim())
|
|
1503
1652
|
continue; // an actual final wins
|
|
1504
|
-
foregroundYielded = true;
|
|
1505
1653
|
turnYielded = true;
|
|
1506
|
-
text = chatDeltaText = transcriptAssistantText = "";
|
|
1507
1654
|
if (chatFinalTimer) {
|
|
1508
1655
|
clearTimeout(chatFinalTimer);
|
|
1509
1656
|
chatFinalTimer = null;
|
|
@@ -1539,96 +1686,27 @@ export class OpenClawClient {
|
|
|
1539
1686
|
turnYielded = false;
|
|
1540
1687
|
else if (turnYielded)
|
|
1541
1688
|
continue;
|
|
1542
|
-
//
|
|
1689
|
+
// A nonempty exact root chat terminal is authoritative. Empty
|
|
1690
|
+
// envelopes/timers never promote streaming/transcript drafts.
|
|
1543
1691
|
if (!chatFinalTimer) {
|
|
1544
1692
|
chatFinalTimer = setTimeout(() => {
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
if (latestFinalText) {
|
|
1551
|
-
finish(latestFinalText);
|
|
1552
|
-
}
|
|
1553
|
-
else if (options?.emptyFinalAsNoReply) {
|
|
1554
|
-
finish("NO_REPLY");
|
|
1555
|
-
}
|
|
1556
|
-
else {
|
|
1557
|
-
console.warn(`[OpenClaw] collectReply: empty chatFinal fallback ignored; waiting for real text or idle timeout`);
|
|
1558
|
-
chatFinalTimer = null;
|
|
1559
|
-
}
|
|
1693
|
+
chatFinalTimer = null;
|
|
1694
|
+
if (chatFinalText.trim())
|
|
1695
|
+
finish(chatFinalText);
|
|
1696
|
+
else
|
|
1697
|
+
void checkRunState(() => true, true);
|
|
1560
1698
|
}, 5000);
|
|
1561
1699
|
}
|
|
1562
1700
|
}
|
|
1563
1701
|
if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
if (!chatFinalText.trim() && (turnYielded || (ev.data?.livenessState === "paused" && ev.data?.stopReason === "end_turn")))
|
|
1568
|
-
continue;
|
|
1569
|
-
// Prefer final chat message over accumulated deltas: some providers may
|
|
1570
|
-
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
1571
|
-
const finalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
1572
|
-
const finishFromLifecycle = () => {
|
|
1573
|
-
const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
1574
|
-
if (!chatFinalText && latestFinalText.trim() === "N") {
|
|
1575
|
-
// Some providers stream the first character of NO_REPLY ("N") but
|
|
1576
|
-
// never deliver a final chat message in time. Never surface a lone
|
|
1577
|
-
// "N" to the user; treat it as a suppressed reply.
|
|
1578
|
-
finish("NO_REPLY");
|
|
1579
|
-
return;
|
|
1580
|
-
}
|
|
1581
|
-
if (!latestFinalText && options?.emptyFinalAsNoReply) {
|
|
1582
|
-
finish("NO_REPLY");
|
|
1583
|
-
return;
|
|
1584
|
-
}
|
|
1585
|
-
if (!latestFinalText) {
|
|
1586
|
-
const failureText = buildFailureText(ev);
|
|
1587
|
-
const state = ev.data?.livenessState || "";
|
|
1588
|
-
const reason = ev.data?.stopReason || "";
|
|
1589
|
-
// replayInvalid, cancelled/rpc, and abandoned are often
|
|
1590
|
-
// transient runtime states — the real reply may still arrive
|
|
1591
|
-
// shortly after via session.message or a subsequent run.
|
|
1592
|
-
// Defer the failure instead of finishing immediately.
|
|
1593
|
-
const isTransient = ev.data?.replayInvalid
|
|
1594
|
-
|| state === "cancelled"
|
|
1595
|
-
|| state === "abandoned"
|
|
1596
|
-
|| reason === "rpc";
|
|
1597
|
-
if (isTransient) {
|
|
1598
|
-
pendingRuntimeFailureText = failureText;
|
|
1599
|
-
console.warn(`[OpenClaw] transient lifecycle end (${state}, reason=${reason}) for runId=${evRunId || runId}; waiting for real text or idle timeout`);
|
|
1600
|
-
return;
|
|
1601
|
-
}
|
|
1602
|
-
if (state !== "working") {
|
|
1603
|
-
if (isWaitTimeout(failureText)) {
|
|
1604
|
-
if (!decidingTimeout) {
|
|
1605
|
-
decidingTimeout = true;
|
|
1606
|
-
void this.classifyWaitTimeout(failureText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
else
|
|
1610
|
-
finish(failureText);
|
|
1611
|
-
return;
|
|
1612
|
-
}
|
|
1613
|
-
console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
|
|
1614
|
-
return;
|
|
1615
|
-
}
|
|
1616
|
-
finish(latestFinalText);
|
|
1617
|
-
};
|
|
1618
|
-
// If lifecycle end beats chat final, a short delta like "N" can be a truncated
|
|
1619
|
-
// final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
|
|
1620
|
-
if ((!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
|
|
1621
|
-
|| (options?.emptyFinalAsNoReply && !chatFinalSeen && !finalText)) {
|
|
1622
|
-
// A lifecycle-only empty discussion end also needs this small
|
|
1623
|
-
// window for a confirming yielded chat final, not instant NO_REPLY.
|
|
1624
|
-
if (lifecycleEndTimer)
|
|
1625
|
-
clearTimeout(lifecycleEndTimer);
|
|
1626
|
-
lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
|
|
1627
|
-
}
|
|
1628
|
-
else {
|
|
1629
|
-
finishFromLifecycle();
|
|
1702
|
+
if (chatFinalText.trim()) {
|
|
1703
|
+
finish(chatFinalText);
|
|
1704
|
+
return;
|
|
1630
1705
|
}
|
|
1631
|
-
|
|
1706
|
+
// Lifecycle can precede chat final and can describe a yielded or
|
|
1707
|
+
// abandoned turn. It is only a read-only reconciliation hint.
|
|
1708
|
+
void checkRunState(() => true, true);
|
|
1709
|
+
continue;
|
|
1632
1710
|
}
|
|
1633
1711
|
if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
|
|
1634
1712
|
const errText = String(ev.data?.error || "unknown");
|
|
@@ -1652,7 +1730,8 @@ export class OpenClawClient {
|
|
|
1652
1730
|
// (a real chatFinal following a timeout error was swallowed).
|
|
1653
1731
|
if (!decidingTimeout) {
|
|
1654
1732
|
decidingTimeout = true;
|
|
1655
|
-
|
|
1733
|
+
const decisionGeneration = idleGeneration;
|
|
1734
|
+
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(error => handleTimeoutResult(error, () => idleGeneration === decisionGeneration));
|
|
1656
1735
|
}
|
|
1657
1736
|
continue;
|
|
1658
1737
|
}
|
|
@@ -1681,8 +1760,10 @@ export class OpenClawClient {
|
|
|
1681
1760
|
if (!genericChatErrorTimer) {
|
|
1682
1761
|
genericChatErrorTimer = setTimeout(() => {
|
|
1683
1762
|
genericChatErrorTimer = null;
|
|
1763
|
+
const decisionGeneration = idleGeneration;
|
|
1684
1764
|
void this.resolveTerminalChatError(evRunId || runId, targetSessionKey || sessionKey).then((detail) => {
|
|
1685
|
-
|
|
1765
|
+
collectEvents();
|
|
1766
|
+
if (observationClosed || idleGeneration !== decisionGeneration)
|
|
1686
1767
|
return;
|
|
1687
1768
|
if (chatFinalText.trim()) {
|
|
1688
1769
|
finish(chatFinalText);
|
|
@@ -1695,7 +1776,7 @@ export class OpenClawClient {
|
|
|
1695
1776
|
if (lifecycleEndTimer)
|
|
1696
1777
|
clearTimeout(lifecycleEndTimer);
|
|
1697
1778
|
reject(new Error(`Agent error: ${detail}`));
|
|
1698
|
-
}, error => { void handleTimeoutResult(error); });
|
|
1779
|
+
}, error => { void handleTimeoutResult(error, () => idleGeneration === decisionGeneration); });
|
|
1699
1780
|
}, 350);
|
|
1700
1781
|
}
|
|
1701
1782
|
continue;
|
|
@@ -1712,7 +1793,8 @@ export class OpenClawClient {
|
|
|
1712
1793
|
// (a real chatFinal following a timeout error was swallowed).
|
|
1713
1794
|
if (!decidingTimeout) {
|
|
1714
1795
|
decidingTimeout = true;
|
|
1715
|
-
|
|
1796
|
+
const decisionGeneration = idleGeneration;
|
|
1797
|
+
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(error => handleTimeoutResult(error, () => idleGeneration === decisionGeneration));
|
|
1716
1798
|
}
|
|
1717
1799
|
continue;
|
|
1718
1800
|
}
|
|
@@ -1726,12 +1808,6 @@ export class OpenClawClient {
|
|
|
1726
1808
|
clearTimeout(genericChatErrorTimer);
|
|
1727
1809
|
if (terminatingReconcileTimer)
|
|
1728
1810
|
clearTimeout(terminatingReconcileTimer);
|
|
1729
|
-
// Abort the (possibly still-running) backgrounded run before we
|
|
1730
|
-
// reject, so an auto-retry probe does not race a zombie run on the
|
|
1731
|
-
// same session. Idempotent/best-effort.
|
|
1732
|
-
this.abortChat(targetSessionKey || sessionKey, evRunId || runId).catch((abortErr) => {
|
|
1733
|
-
console.warn(`[OpenClaw] abort after chat error failed:`, safeErrorSummary(abortErr));
|
|
1734
|
-
});
|
|
1735
1811
|
reject(new Error(`Agent error: ${errText}`));
|
|
1736
1812
|
return;
|
|
1737
1813
|
}
|
|
@@ -1753,15 +1829,17 @@ export class OpenClawClient {
|
|
|
1753
1829
|
this.eventCollectors.add(wakeCollector);
|
|
1754
1830
|
const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
|
|
1755
1831
|
const checkAfterNoActiveRun = async () => {
|
|
1832
|
+
const decisionGeneration = idleGeneration;
|
|
1756
1833
|
await checkRunState();
|
|
1757
|
-
if (observationClosed || !observationKey)
|
|
1834
|
+
if (observationClosed || !observationKey || decisionGeneration !== idleGeneration)
|
|
1758
1835
|
return;
|
|
1759
1836
|
try {
|
|
1760
1837
|
// Used only after the plugin reports no active run. Missing terminal
|
|
1761
1838
|
// history is not failure, but an explicit idle session must not keep
|
|
1762
1839
|
// a phantom queue owner blocking this newly-arrived user message.
|
|
1763
|
-
const
|
|
1764
|
-
|
|
1840
|
+
const dispatch = await this.getSessionDispatchStatus(observationKey);
|
|
1841
|
+
collectEvents();
|
|
1842
|
+
if (!observationClosed && decisionGeneration === idleGeneration && dispatch.idleConfirmed && !dispatch.archived && (!options?.expectedSessionId || dispatch.sessionId === options.expectedSessionId)) {
|
|
1765
1843
|
reject(new InactiveRunObservation());
|
|
1766
1844
|
}
|
|
1767
1845
|
}
|
|
@@ -1771,6 +1849,8 @@ export class OpenClawClient {
|
|
|
1771
1849
|
this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
|
|
1772
1850
|
cleanupObservation = () => {
|
|
1773
1851
|
observationClosed = true;
|
|
1852
|
+
releaseEventReader();
|
|
1853
|
+
clearIdleGuard();
|
|
1774
1854
|
clearTimeout(idleTimer);
|
|
1775
1855
|
this.eventCollectors.delete(wakeCollector);
|
|
1776
1856
|
clearInterval(poller);
|
|
@@ -1835,18 +1915,18 @@ export class OpenClawClient {
|
|
|
1835
1915
|
async resolveTerminalChatError(runId, sessionKey) {
|
|
1836
1916
|
try {
|
|
1837
1917
|
const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
|
|
1838
|
-
if (
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1918
|
+
if (snapshot?.runId !== runId || !Number.isFinite(snapshot.endedAt) || snapshot.endedAt <= 0 || snapshot.yielded || snapshot.pendingError
|
|
1919
|
+
|| ['queue', 'gateway_draining'].includes(snapshot.timeoutPhase) || typeof snapshot.startedAt === 'number' && snapshot.endedAt < snapshot.startedAt
|
|
1920
|
+
|| !['error', 'timeout'].includes(snapshot.status)) {
|
|
1921
|
+
throw new SessionWaitPaused(await this.getSessionRuntimeStatus(sessionKey || ''), true);
|
|
1842
1922
|
}
|
|
1843
1923
|
return this.formatTerminalRunError(snapshot);
|
|
1844
1924
|
}
|
|
1845
1925
|
catch (err) {
|
|
1846
1926
|
if (err instanceof SessionWaitPaused)
|
|
1847
1927
|
throw err;
|
|
1848
|
-
|
|
1849
|
-
|
|
1928
|
+
// Failed read-only reconciliation is not the execution's failure.
|
|
1929
|
+
throw new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), true);
|
|
1850
1930
|
}
|
|
1851
1931
|
}
|
|
1852
1932
|
formatTerminalRunError(snapshot) {
|
|
@@ -1863,7 +1943,7 @@ export class OpenClawClient {
|
|
|
1863
1943
|
: 0;
|
|
1864
1944
|
const elapsed = elapsedSeconds > 0 ? `(运行约 ${this.formatDurationSeconds(elapsedSeconds)})` : "";
|
|
1865
1945
|
const phase = timeoutPhase ? `,阶段:${timeoutPhase}` : "";
|
|
1866
|
-
return `等待 Agent 运行结果超时${elapsed}${phase}
|
|
1946
|
+
return `等待 Agent 运行结果超时${elapsed}${phase};底层运行可能仍在继续${rawError ? `;${rawError}` : ""}`;
|
|
1867
1947
|
}
|
|
1868
1948
|
if (rawError)
|
|
1869
1949
|
return rawError;
|
|
@@ -1887,17 +1967,6 @@ export class OpenClawClient {
|
|
|
1887
1967
|
|| t.includes("too many tokens")
|
|
1888
1968
|
|| t.includes("maximum context");
|
|
1889
1969
|
}
|
|
1890
|
-
pickBestCollectedText(chatFinalText, assistantText, chatDeltaText, transcriptAssistantText) {
|
|
1891
|
-
if (chatFinalText)
|
|
1892
|
-
return chatFinalText;
|
|
1893
|
-
const candidates = [assistantText, chatDeltaText, transcriptAssistantText].filter((value) => value && value.trim());
|
|
1894
|
-
if (candidates.length === 0)
|
|
1895
|
-
return "";
|
|
1896
|
-
// transcriptAssistant is a full transcript mirror. Keep it separate from
|
|
1897
|
-
// streaming deltas to avoid concatenating the same response twice; choose
|
|
1898
|
-
// the richest available non-final text as fallback.
|
|
1899
|
-
return candidates.sort((a, b) => b.length - a.length)[0];
|
|
1900
|
-
}
|
|
1901
1970
|
// --- Session management ---
|
|
1902
1971
|
async createSession(params) {
|
|
1903
1972
|
return this.rpc("sessions.create", { ...params, key: this.canonicalSessionKey(params.key) });
|
|
@@ -2088,7 +2157,7 @@ export class OpenClawClient {
|
|
|
2088
2157
|
* order, showing the ORIGINAL text) and the steer-consumed callback, then
|
|
2089
2158
|
* clear that pending entry. Returns true if it matched a steered message.
|
|
2090
2159
|
*/
|
|
2091
|
-
handleSteerConsumption(sessionKey, text) {
|
|
2160
|
+
handleSteerConsumption(sessionKey, text, runId) {
|
|
2092
2161
|
const key = this.shortKey(sessionKey);
|
|
2093
2162
|
const queue = this.pendingSteerTexts.get(key);
|
|
2094
2163
|
if (!queue || queue.length === 0)
|
|
@@ -2114,7 +2183,7 @@ export class OpenClawClient {
|
|
|
2114
2183
|
const [matched] = queue.splice(matchedIndex, 1);
|
|
2115
2184
|
if (queue.length === 0)
|
|
2116
2185
|
this.pendingSteerTexts.delete(key);
|
|
2117
|
-
const progressCb = this.
|
|
2186
|
+
const progressCb = this.progressForRun(sessionKey, runId);
|
|
2118
2187
|
if (progressCb)
|
|
2119
2188
|
void progressCb({ kind: "steer", text: matched.displayText });
|
|
2120
2189
|
const list = this.steerConsumedCallbacks.get(key);
|
|
@@ -2286,8 +2355,11 @@ export class OpenClawClient {
|
|
|
2286
2355
|
return unknown();
|
|
2287
2356
|
};
|
|
2288
2357
|
try {
|
|
2358
|
+
// The outer race must honor the SAME total budget as the scan itself
|
|
2359
|
+
// (audit P1 2026-10-17: an 8000ms scan raced against a 1500ms timer was
|
|
2360
|
+
// still killed at 1500ms — three real 650ms pages never got to finish).
|
|
2289
2361
|
return await Promise.race([scan(), new Promise((_, reject) => {
|
|
2290
|
-
timer = setTimeout(() => reject(new Error('activity lookup timeout')),
|
|
2362
|
+
timer = setTimeout(() => reject(new Error('activity lookup timeout')), 8000);
|
|
2291
2363
|
})]);
|
|
2292
2364
|
}
|
|
2293
2365
|
catch {
|
|
@@ -2300,7 +2372,8 @@ export class OpenClawClient {
|
|
|
2300
2372
|
}
|
|
2301
2373
|
async classifyWaitTimeout(text, sessionKey) {
|
|
2302
2374
|
const status = await this.getSessionRuntimeStatus(sessionKey).catch(() => normalizeSessionRuntimeStatus(undefined));
|
|
2303
|
-
|
|
2375
|
+
// A wait timeout plus session idle/unknown is still not exact terminal proof.
|
|
2376
|
+
return new SessionWaitPaused(status, true);
|
|
2304
2377
|
}
|
|
2305
2378
|
async getSessionInfo(sessionKey, timeoutMs = 120000) {
|
|
2306
2379
|
return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, timeoutMs);
|
|
@@ -2965,9 +3038,17 @@ export class OpenClawClient {
|
|
|
2965
3038
|
const isCurrent = () => this.observationEpoch.get(fullSessionKey) === generation && (this.stopGenerations.get(fullSessionKey) || 0) === stopGeneration;
|
|
2966
3039
|
const assertCurrent = () => { if (!isCurrent())
|
|
2967
3040
|
throw new SupersededObservation('admission generation revoked'); };
|
|
2968
|
-
this.clearVerboseAssistantState(sk);
|
|
2969
3041
|
const submissionId = randomUUID();
|
|
2970
3042
|
let ownedRunId = "";
|
|
3043
|
+
let eventReservationRunId = '';
|
|
3044
|
+
let releaseEventReservation = () => { };
|
|
3045
|
+
const reserveEvents = (runId) => {
|
|
3046
|
+
if (eventReservationRunId === runId)
|
|
3047
|
+
return;
|
|
3048
|
+
releaseEventReservation();
|
|
3049
|
+
eventReservationRunId = runId;
|
|
3050
|
+
releaseEventReservation = this.retainRunEventCustody('reservation', fullSessionKey, runId);
|
|
3051
|
+
};
|
|
2971
3052
|
let waitPaused = false;
|
|
2972
3053
|
try {
|
|
2973
3054
|
// New user inputs arriving after /stop are admitted only AFTER that
|
|
@@ -2989,10 +3070,23 @@ export class OpenClawClient {
|
|
|
2989
3070
|
assertCurrent();
|
|
2990
3071
|
this.recoveryStore?.saveAdmission(fullSessionKey, idempotencyKey, 'dispatching', { generation, stopGeneration, payloadDigest: createHash('sha256').update(params.message).digest('hex') });
|
|
2991
3072
|
this.recoveryStore?.saveRunObservation(fullSessionKey, submissionId, { admissionOnly: true }, Date.now() + 30_000);
|
|
3073
|
+
// Claim exact output custody synchronously in the WS ACK handler.
|
|
3074
|
+
// ws.Receiver can emit ACK + final before any await continuation runs.
|
|
3075
|
+
// Failed admission never runs this hook and cannot revoke the old owner.
|
|
3076
|
+
this.admissionResponseHooks.set(idempotencyKey, receipt => {
|
|
3077
|
+
if (isCurrent() && typeof receipt?.runId === 'string' && receipt.runId && !this.isExplicitlyStoppedRun(fullSessionKey, receipt.runId)) {
|
|
3078
|
+
ownedRunId = receipt.runId;
|
|
3079
|
+
reserveEvents(ownedRunId);
|
|
3080
|
+
this.ownDeliveryRun(ownedRunId);
|
|
3081
|
+
}
|
|
3082
|
+
});
|
|
2992
3083
|
return this.rpc("chat.send", {
|
|
2993
3084
|
sessionKey: this.canonicalSessionKey(sk),
|
|
2994
3085
|
message: params.message,
|
|
2995
3086
|
attachments: params.attachments,
|
|
3087
|
+
// Ordinary admission is a separate serialized turn, not a request to
|
|
3088
|
+
// inherit stored interrupt/steer policy or its commit-wait finalizer.
|
|
3089
|
+
queueMode: "followup",
|
|
2996
3090
|
deliver: params.deliver ?? false,
|
|
2997
3091
|
idempotencyKey,
|
|
2998
3092
|
});
|
|
@@ -3042,6 +3136,7 @@ export class OpenClawClient {
|
|
|
3042
3136
|
if (previous)
|
|
3043
3137
|
for (const fence of [...previous])
|
|
3044
3138
|
fence();
|
|
3139
|
+
reserveEvents(ownedRunId); // mocked/legacy transports may not use the WS ACK hook
|
|
3045
3140
|
this.ownDeliveryRun(ownedRunId);
|
|
3046
3141
|
console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
|
|
3047
3142
|
await params.onRunIdentified?.(result.runId, { sessionId: result.sessionId });
|
|
@@ -3052,26 +3147,43 @@ export class OpenClawClient {
|
|
|
3052
3147
|
await params.onSubmitted?.(result.runId);
|
|
3053
3148
|
assertCurrent();
|
|
3054
3149
|
if (params.onProgress) {
|
|
3150
|
+
this.progressRuns.set(fullSessionKey, ownedRunId);
|
|
3055
3151
|
this.progressCallbacks.set(sk, params.onProgress);
|
|
3056
3152
|
this.progressCallbacks.set(fullSessionKey, params.onProgress);
|
|
3057
3153
|
this.progressGenerations.set(sk, generation);
|
|
3058
3154
|
this.progressGenerations.set(fullSessionKey, generation);
|
|
3059
3155
|
void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
|
|
3060
3156
|
}
|
|
3061
|
-
const collecting = this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused, onYieldHandoff: params.onYieldHandoff });
|
|
3157
|
+
const collecting = this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, expectedSessionId: result.sessionId, onWaitPaused: params.onWaitPaused, onYieldHandoff: params.onYieldHandoff });
|
|
3158
|
+
// Reader registration is synchronous inside collectReply. Only now may
|
|
3159
|
+
// the receiver's ACK reservation release its same-run terminal custody.
|
|
3160
|
+
releaseEventReservation();
|
|
3062
3161
|
params.onCollectionStarted?.(result.runId);
|
|
3063
3162
|
const reply = await collecting;
|
|
3064
3163
|
if (reply.trim().toUpperCase() === 'NO_REPLY')
|
|
3065
3164
|
this.recoveryStore?.finishRunObservation(fullSessionKey, ownedRunId);
|
|
3066
3165
|
else
|
|
3067
3166
|
this.recoveryStore?.saveRunObservation(fullSessionKey, ownedRunId, { sessionId: result.sessionId, terminalText: reply, terminalObserved: true }, Date.now() + 30_000);
|
|
3167
|
+
// Do not retire on lifecycle/timeout/cleanup alone. Successful full result
|
|
3168
|
+
// custody (and synchronous durable save when bound) precedes retirement.
|
|
3169
|
+
const durable = this.recoveryStore?.getRunObservation(fullSessionKey, ownedRunId);
|
|
3170
|
+
const durableResult = !this.recoveryStore || (reply.trim().toUpperCase() === 'NO_REPLY'
|
|
3171
|
+
? durable?.state === 'settled'
|
|
3172
|
+
: durable?.terminalObserved === true && durable?.terminalText === reply);
|
|
3173
|
+
if (durableResult) {
|
|
3174
|
+
const completedKey = this.runEventKey(fullSessionKey, ownedRunId);
|
|
3175
|
+
this.completedRunEventCustody.add(completedKey);
|
|
3176
|
+
this.retireCompletedRunEvents(completedKey);
|
|
3177
|
+
}
|
|
3068
3178
|
return reply;
|
|
3069
3179
|
}
|
|
3070
3180
|
catch (error) {
|
|
3071
|
-
waitPaused = error instanceof SessionWaitPaused || error instanceof SupersededObservation;
|
|
3181
|
+
waitPaused = error instanceof SessionWaitPaused || error instanceof SupersededObservation || error instanceof CollectorStalledError;
|
|
3072
3182
|
throw error;
|
|
3073
3183
|
}
|
|
3074
3184
|
finally {
|
|
3185
|
+
this.admissionResponseHooks.delete(submissionId);
|
|
3186
|
+
releaseEventReservation();
|
|
3075
3187
|
// OpenClaw can emit the final assistant session.message a moment after
|
|
3076
3188
|
// collectReply returns. Keep a short grace window so normal chat replies
|
|
3077
3189
|
// are not delivered twice via the proactive-message path. Cron/LMA runs
|
|
@@ -3087,6 +3199,7 @@ export class OpenClawClient {
|
|
|
3087
3199
|
if (this.progressGenerations.get(fullSessionKey) === generation && this.progressCallbacks.get(fullSessionKey) === params.onProgress) {
|
|
3088
3200
|
this.progressCallbacks.delete(fullSessionKey);
|
|
3089
3201
|
this.progressGenerations.delete(fullSessionKey);
|
|
3202
|
+
this.progressRuns.delete(fullSessionKey);
|
|
3090
3203
|
}
|
|
3091
3204
|
}
|
|
3092
3205
|
if (waitPaused)
|