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.
@@ -12,6 +12,22 @@ const DEFAULT_TICK_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS
12
12
  // freeze the live-status card for the rest of a long run. We skip the failed
13
13
  // tick, keep the ticker alive, and only disable after this many in a row.
14
14
  const MAX_CONSECUTIVE_EDIT_FAILURES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_EDIT_FAILURES || 5);
15
+ /** A transport/observation error is not proof that a platform effect failed. */
16
+ export class LiveStatusEffectUnknownError extends Error {
17
+ cause;
18
+ constructor(cause) {
19
+ super("live status effect outcome unknown");
20
+ this.cause = cause;
21
+ this.name = "LiveStatusEffectUnknownError";
22
+ }
23
+ }
24
+ /** Only a definitive platform rejection is safe for the terminal retry loop. */
25
+ export class LiveStatusEffectRejectedError extends Error {
26
+ }
27
+ /** Proven local no-effect suppression (settings/epoch/lease), not an unknown
28
+ * transport outcome and not permission to retry an attempted SDK operation. */
29
+ export class LiveStatusEffectSuppressedError extends Error {
30
+ }
15
31
  export class LiveStatusController {
16
32
  callbacks;
17
33
  opts;
@@ -23,7 +39,19 @@ export class LiveStatusController {
23
39
  createTimer;
24
40
  tickTimer;
25
41
  createPromise;
42
+ createSuppressed = false;
43
+ waitingCreateAllowed = false;
26
44
  finalized = false;
45
+ terminalOutcome = false;
46
+ disposed = false;
47
+ generation = 0;
48
+ /** Frozen/terminal presentation wins over every older progress snapshot. */
49
+ pinnedView;
50
+ terminalOwner;
51
+ pendingEdit;
52
+ editPump;
53
+ /** Timed-out SDK effects retain receipt ownership, but must not be replayed. */
54
+ unknownEdits = new Set();
27
55
  waitingForResult = false;
28
56
  updatesFrozen = false;
29
57
  disabled = false;
@@ -34,6 +62,7 @@ export class LiveStatusController {
34
62
  * A success resets the counter. */
35
63
  consecutiveEditFailures = 0;
36
64
  state = "running";
65
+ blockedEdits = false;
37
66
  /** Total tool calls (tool_start events) seen during the run, for the summary. */
38
67
  toolCallCount = 0;
39
68
  /** True when finished via noReply(): show a "no content" summary. */
@@ -41,23 +70,116 @@ export class LiveStatusController {
41
70
  constructor(callbacks, opts) {
42
71
  this.callbacks = callbacks;
43
72
  this.opts = opts;
73
+ if (opts.initialPhase)
74
+ this.state = opts.initialPhase;
75
+ const restored = opts.restore;
76
+ if (restored) {
77
+ this.messageId = restored.messageId;
78
+ this.startedAt = restored.startedAt;
79
+ this.blockedEdits = restored.blockEdits;
80
+ if (restored.view) {
81
+ this.state = restored.view.state;
82
+ this.lines = restored.view.lines.map(line => ({ ...line }));
83
+ this.toolCallCount = restored.view.toolCalls;
84
+ this.noReplyResult = restored.view.noReply;
85
+ this.waitingForResult = restored.view.phase === "waiting";
86
+ this.updatesFrozen = this.waitingForResult;
87
+ }
88
+ // Attempted/unknown create survives process loss as a no-create fence.
89
+ if (restored.blockCreate && !this.messageId)
90
+ this.disabled = true;
91
+ }
92
+ }
93
+ /** Receipt is durable locally, NOT Gateway admission. */
94
+ received(detail) {
95
+ if (this.finalized || this.disposed)
96
+ return;
97
+ this.pushLine("lifecycle", detail);
98
+ this.callbacks.desired?.(this.buildView());
99
+ void this.safeEdit(this.buildView());
100
+ }
101
+ /** A proven never-attempted ordinary recovery may reuse its queued card.
102
+ * Merely observing an expected root never calls this method. */
103
+ resumeQueued() {
104
+ if (this.disposed || this.terminalOutcome)
105
+ return;
106
+ this.finalized = false;
107
+ this.updatesFrozen = false;
108
+ this.waitingForResult = false;
109
+ this.waitingCreateAllowed = false;
110
+ this.state = "queued";
111
+ this.generation++;
112
+ this.pinnedView = undefined;
113
+ this.pendingEdit = undefined;
114
+ this.callbacks.desired?.(this.buildView());
115
+ if (this.messageId) {
116
+ this.startTicker();
117
+ void this.safeEdit(this.buildView());
118
+ }
119
+ else
120
+ this.start();
121
+ }
122
+ /** Enqueue/local receipt remains waiting, not actual-owner/model evidence.
123
+ * Keep one latest pinned view while allowing another input to coalesce. */
124
+ async showInputWaiting(detail, title) {
125
+ if (this.disposed || this.terminalOutcome)
126
+ return;
127
+ this.updatesFrozen = true;
128
+ this.waitingForResult = true;
129
+ this.waitingCreateAllowed = !this.messageId && !this.disabled;
130
+ this.pushLine("lifecycle", detail);
131
+ this.stopTimers();
132
+ const view = this.buildView();
133
+ if (title)
134
+ view.title = title;
135
+ // A quick enqueue receipt must not cancel the only queued card before it
136
+ // ever existed. Still exactly one create flight, with the normal unknown
137
+ // fence and effect-time settings/lease checks, never a terminal retry.
138
+ if (!this.messageId && !this.createPromise && !this.disabled) {
139
+ this.pinView(view);
140
+ this.createPromise = this.ensureCreated();
141
+ }
142
+ await this.finishView(view);
143
+ }
144
+ /** Only the exact ACK owner calls this; admission is not model execution. */
145
+ async admitted(successor = false) {
146
+ if (this.finalized || this.disposed || (!successor && this.state !== "queued"))
147
+ return;
148
+ this.waitingForResult = false;
149
+ this.state = "admitted";
150
+ this.pushLine("lifecycle", this.opts.locale === "en" ? "Gateway accepted; waiting for activity" : "Gateway 已接收,等待执行活动");
151
+ this.callbacks.desired?.(this.buildView());
152
+ await this.safeEdit(this.buildView());
44
153
  }
45
154
  get id() { return this.messageId; }
46
155
  start(initialDetail) {
47
- if (this.disabled || this.finalized || this.updatesFrozen || this.createTimer || this.messageId)
156
+ if (this.createSuppressed) {
157
+ this.createPromise = undefined;
158
+ this.createSuppressed = false;
159
+ }
160
+ if (this.disabled || this.finalized || (this.updatesFrozen && !this.waitingCreateAllowed) || this.createTimer || this.createPromise || this.messageId)
48
161
  return;
49
- this.startedAt = Date.now();
162
+ if (!this.opts.restore)
163
+ this.startedAt = Date.now();
50
164
  // Presentation may start before chat.send admission. Only the collector's
51
165
  // effective-activity idle decision (or confirmed yield) freezes this card;
52
166
  // elapsed ticks are presentation, never evidence of task progress.
53
167
  if (initialDetail && initialDetail.trim()) {
54
168
  this.pushLine("lifecycle", initialDetail.trim());
55
169
  }
170
+ this.callbacks.desired?.(this.buildView());
56
171
  this.createTimer = setTimeout(() => {
57
172
  this.createTimer = undefined;
58
173
  this.createPromise = this.ensureCreated();
59
174
  }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
60
175
  }
176
+ setMetadata(model, thinkingLevel) {
177
+ if (this.disposed || this.finalized)
178
+ return;
179
+ this.opts.model = model;
180
+ this.opts.thinkingLevel = thinkingLevel;
181
+ this.callbacks.desired?.(this.buildView());
182
+ }
61
183
  async progress(event) {
62
184
  if (this.disabled || this.finalized)
63
185
  return;
@@ -108,6 +230,8 @@ export class LiveStatusController {
108
230
  return; // lifecycle ticks: ignore (footer timer already advances)
109
231
  }
110
232
  this.waitingForResult = false;
233
+ this.state = "running";
234
+ this.callbacks.desired?.(this.buildView());
111
235
  await this.ensureCreatedNow();
112
236
  await this.safeEdit(this.buildView());
113
237
  }
@@ -120,11 +244,7 @@ export class LiveStatusController {
120
244
  this.waitingForResult = true;
121
245
  this.pushLine("lifecycle", detail);
122
246
  this.stopTimers();
123
- if (this.createPromise)
124
- await this.createPromise.catch(() => { });
125
- this.stopTimers();
126
- if (this.messageId && !this.disabled)
127
- await this.safeEditFinal(this.buildView());
247
+ await this.finishView(this.buildView());
128
248
  }
129
249
  /** Release only local observation of a now-idle session, without claiming
130
250
  * the previous task succeeded or was aborted. Normal progress is unchanged. */
@@ -132,39 +252,62 @@ export class LiveStatusController {
132
252
  if (this.finalized)
133
253
  return;
134
254
  this.pushLine("lifecycle", detail);
255
+ this.waitingForResult = true;
135
256
  this.finalized = true;
136
257
  this.stopTimers();
137
- if (this.createPromise)
138
- await this.createPromise.catch(() => { });
139
- if (!this.messageId || this.disabled)
140
- return;
141
258
  const view = this.buildView();
142
259
  view.title = title || (this.opts.locale === "en" ? `${this.opts.botName} result unconfirmed` : `${this.opts.botName} 结果未确认`);
143
- await this.safeEditFinal(view);
260
+ await this.finishView(view);
144
261
  }
145
262
  async complete() {
263
+ if (this.disposed || this.terminalOutcome)
264
+ return;
265
+ const pending = this.callbacks.unconfirmedInputs?.("done");
266
+ if (pending) {
267
+ await this.showInputWaiting(pending.detail, pending.title);
268
+ return;
269
+ }
270
+ this.terminalOutcome = true;
146
271
  this.state = "done";
147
272
  this.finalized = true;
148
273
  this.stopTimers();
149
- if (this.createPromise)
150
- await this.createPromise.catch(() => { });
151
- if (!this.messageId || this.disabled)
152
- return;
153
- await this.safeEditFinal(this.buildView());
274
+ await this.finishView(this.buildView());
154
275
  }
155
276
  /**
156
277
  * Freeze any successful terminal state (text, attachment-only, or NO_REPLY)
157
278
  * without first emitting the compact done patch. The caller persists this
158
279
  * metadata in the outbox, which owns the final patch/cleanup. If no card was
159
- * created, the caller falls back to normal new-message delivery.
280
+ * created, the caller falls back to normal new-message delivery. Metadata is
281
+ * a target/execution snapshot, never a receipt. B2/outbox callers that may own
282
+ * an unknown or same-card final should supply owner BEFORE attempting it;
283
+ * late IDs/stale effects then delegate reconciliation instead of patching.
160
284
  */
161
- async prepareTerminalDelivery(noReply = false) {
162
- this.state = "done";
163
- this.noReplyResult = noReply;
164
- this.finalized = true;
285
+ async prepareTerminalDelivery(noReply = false, owner) {
286
+ if (this.disposed || (this.terminalOutcome && this.state !== "done"))
287
+ return undefined;
288
+ const pending = this.callbacks.unconfirmedInputs?.("done");
289
+ if (pending) {
290
+ await this.showInputWaiting(pending.detail, pending.title);
291
+ return undefined;
292
+ }
293
+ if (owner) {
294
+ this.terminalOwner = owner;
295
+ this.generation++; // retire any queued controller-owned terminal effect
296
+ this.pendingEdit = undefined;
297
+ }
298
+ if (!this.terminalOutcome) {
299
+ this.terminalOutcome = true;
300
+ this.state = "done";
301
+ this.noReplyResult = noReply;
302
+ this.finalized = true;
303
+ this.pinView(this.buildView()); // retire queued progress before handing off
304
+ }
165
305
  this.stopTimers();
166
306
  if (this.createPromise)
167
307
  await this.createPromise.catch(() => { });
308
+ return this.finalMeta();
309
+ }
310
+ finalMeta() {
168
311
  if (!this.messageId)
169
312
  return undefined;
170
313
  return {
@@ -174,21 +317,25 @@ export class LiveStatusController {
174
317
  model: this.opts.model,
175
318
  thinkingLevel: this.opts.thinkingLevel,
176
319
  locale: this.opts.locale === "en" ? "en" : "zh",
177
- noReply,
320
+ noReply: this.noReplyResult,
178
321
  };
179
322
  }
180
- async prepareFinalDelivery() {
181
- return this.prepareTerminalDelivery(false);
323
+ async prepareFinalDelivery(owner) {
324
+ return this.prepareTerminalDelivery(false, owner);
182
325
  }
183
326
  async fail() {
327
+ if (this.disposed || this.terminalOutcome)
328
+ return;
329
+ const pending = this.callbacks.unconfirmedInputs?.("failed");
330
+ if (pending) {
331
+ await this.showInputWaiting(pending.detail, pending.title);
332
+ return;
333
+ }
334
+ this.terminalOutcome = true;
184
335
  this.state = "failed";
185
336
  this.finalized = true;
186
337
  this.stopTimers();
187
- if (this.createPromise)
188
- await this.createPromise.catch(() => { });
189
- if (!this.messageId || this.disabled)
190
- return;
191
- await this.safeEditFinal(this.buildView());
338
+ await this.finishView(this.buildView());
192
339
  }
193
340
  /**
194
341
  * Finish when the model explicitly produced no reply (NO_REPLY) or an empty
@@ -196,20 +343,60 @@ export class LiveStatusController {
196
343
  * activity window, so the user understands the run finished without output.
197
344
  */
198
345
  async noReply() {
346
+ if (this.disposed || this.terminalOutcome)
347
+ return;
348
+ const pending = this.callbacks.unconfirmedInputs?.("done");
349
+ if (pending) {
350
+ await this.showInputWaiting(pending.detail, pending.title);
351
+ return;
352
+ }
353
+ this.terminalOutcome = true;
199
354
  this.state = "done";
200
355
  this.noReplyResult = true;
201
356
  this.finalized = true;
202
357
  this.stopTimers();
203
- if (this.createPromise)
204
- await this.createPromise.catch(() => { });
205
- if (!this.messageId || this.disabled)
206
- return;
207
- await this.safeEditFinal(this.buildView());
358
+ await this.finishView(this.buildView());
208
359
  }
209
360
  dispose() {
361
+ this.disposed = true;
210
362
  this.finalized = true;
363
+ this.generation++;
364
+ this.pendingEdit = undefined;
211
365
  this.stopTimers();
212
366
  }
367
+ pinView(view) {
368
+ this.generation++;
369
+ this.callbacks.desired?.(view);
370
+ this.pinnedView = view;
371
+ this.pendingEdit = undefined;
372
+ }
373
+ async finishView(view) {
374
+ // Pin BEFORE awaiting creation: a create already in flight may finish late.
375
+ this.pinView(view);
376
+ if (this.createPromise)
377
+ await this.createPromise.catch(() => { });
378
+ if (this.messageId && !this.disposed)
379
+ await this.reconcilePinnedView();
380
+ }
381
+ async reconcilePinnedView(force = false) {
382
+ if (!this.pinnedView || this.disposed)
383
+ return;
384
+ if (!this.terminalOwner) {
385
+ await this.queueEdit(this.pinnedView, true, force);
386
+ return;
387
+ }
388
+ const meta = this.finalMeta();
389
+ if (!meta)
390
+ return;
391
+ try {
392
+ // This is a custody signal, NOT retry authority. The owner must retain
393
+ // attempted/unknown effects and reconcile its own desired final payload.
394
+ await this.terminalOwner.onReconcileNeeded(meta);
395
+ }
396
+ catch (err) {
397
+ this.callbacks.warn?.("live status external reconciliation failed (not replayed)", err);
398
+ }
399
+ }
213
400
  /**
214
401
  * Intermediate assistant text arrives as incremental fragments (each flush
215
402
  * sends only the newly-added slice). Consecutive fragments with no tool event
@@ -270,25 +457,61 @@ export class LiveStatusController {
270
457
  async ensureCreated() {
271
458
  if (this.disabled || this.finalized || this.messageId)
272
459
  return;
273
- const view = this.buildView();
274
- try {
275
- const id = await this.callbacks.create(view);
460
+ const view = this.pinnedView || this.buildView();
461
+ let started = false;
462
+ let settled = false;
463
+ const accept = (id) => {
464
+ if (settled)
465
+ return;
466
+ settled = true;
276
467
  if (!id) {
277
468
  this.disabled = true;
278
469
  return;
279
470
  }
280
471
  this.messageId = id;
281
472
  this.lastSentSignature = this.signature(view);
282
- this.startTicker();
473
+ if (this.pinnedView && !this.disposed) {
474
+ void this.reconcilePinnedView();
475
+ }
476
+ else
477
+ this.startTicker();
478
+ };
479
+ const effect = {
480
+ run: async (send) => {
481
+ // A create waiting behind another chat send is no longer needed once
482
+ // the task finished. Do not create a stale running card at effect time.
483
+ if (this.finalized || (this.updatesFrozen && !this.waitingCreateAllowed) || this.disposed) {
484
+ settled = true;
485
+ return undefined;
486
+ }
487
+ started = true;
488
+ const id = await send();
489
+ accept(id); // do not attach receipt ownership to the deadline promise
490
+ return id;
491
+ },
492
+ };
493
+ try {
494
+ const id = await this.callbacks.create(view, effect);
495
+ if (!settled)
496
+ accept(id); // direct/non-queued callbacks remain supported
283
497
  }
284
498
  catch (err) {
285
- this.disabled = true;
286
- this.callbacks.warn?.("live status create failed", err);
499
+ // No duplicate create, including when the outer deadline expired. The
500
+ // original effect context still adopts a late successful message ID.
501
+ if (err instanceof LiveStatusEffectSuppressedError) {
502
+ this.createSuppressed = true;
503
+ this.createPromise = undefined;
504
+ this.callbacks.warn?.("live status create locally suppressed before transport", err);
505
+ return;
506
+ }
507
+ if (!started)
508
+ this.disabled = true;
509
+ this.callbacks.warn?.("live status create failed (not replayed)", err);
287
510
  }
288
511
  }
289
512
  /** Refresh the elapsed-time footer periodically even with no new activity. */
290
513
  startTicker() {
291
- if (this.tickTimer || this.disabled || this.finalized || this.updatesFrozen)
514
+ if (this.tickTimer || this.disabled || this.finalized || this.updatesFrozen || this.blockedEdits)
292
515
  return;
293
516
  const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
294
517
  this.tickTimer = setInterval(() => {
@@ -298,58 +521,136 @@ export class LiveStatusController {
298
521
  }, tickMs);
299
522
  this.tickTimer.unref?.();
300
523
  }
301
- async safeEdit(view, force = false) {
302
- if (!this.messageId || this.disabled)
303
- return false;
304
- if (!force && (this.finalized || this.updatesFrozen))
524
+ async safeEdit(view) {
525
+ if (!this.messageId || this.disabled || this.finalized || this.updatesFrozen)
526
+ return;
527
+ await this.queueEdit(view, false);
528
+ }
529
+ /** One flight + one latest pending snapshot, regardless of tick/event rate. */
530
+ queueEdit(view, priority, force = false) {
531
+ this.callbacks.desired?.(view);
532
+ if (this.blockedEdits)
533
+ return Promise.resolve();
534
+ if (!this.messageId || this.disposed || (priority && view !== this.pinnedView)
535
+ || (!priority && (this.disabled || this.finalized || this.updatesFrozen)))
536
+ return Promise.resolve();
537
+ if (!priority)
538
+ this.generation++;
539
+ this.pendingEdit = { view, generation: this.generation, priority, force, started: false, settled: false, accepted: false };
540
+ return this.flushEdits();
541
+ }
542
+ canRunPendingEdit() {
543
+ const request = this.pendingEdit;
544
+ if (!request || this.disposed)
305
545
  return false;
306
- const sig = this.signature(view);
307
- if (!force && sig === this.lastSentSignature)
308
- return true;
309
- try {
310
- await this.callbacks.edit(this.messageId, view);
311
- this.lastSentSignature = sig;
312
- this.consecutiveEditFailures = 0; // recovered
313
- return true;
314
- }
315
- catch (err) {
316
- // A single transient Feishu error (e.g. card patch code=2200 "Internal
317
- // Error", rate limit, network blip) must NOT permanently freeze the card.
318
- // Skip this tick and keep the ticker running so the next patch recovers it.
319
- // Only give up after several CONSECUTIVE failures (a persistent problem,
320
- // e.g. the message was deleted or we truly lost access).
321
- this.consecutiveEditFailures++;
322
- const giveUp = this.consecutiveEditFailures >= MAX_CONSECUTIVE_EDIT_FAILURES;
323
- this.callbacks.warn?.(`live status edit failed (${this.consecutiveEditFailures}/${MAX_CONSECUTIVE_EDIT_FAILURES}${giveUp ? ", disabling" : ", will retry next tick"})`, err);
324
- if (giveUp) {
325
- this.disabled = true;
326
- this.stopTimers();
546
+ // An unknown progress is not retried by ticking. A new terminal/frozen
547
+ // view may supersede it once; an unknown terminal is never blind retried.
548
+ return ![...this.unknownEdits].some(old => !request.priority
549
+ || (old.priority && old.generation >= request.generation));
550
+ }
551
+ flushEdits() {
552
+ if (this.editPump)
553
+ return this.editPump;
554
+ // Start on a microtask so editPump is installed before callbacks/receipts
555
+ // can re-enter, including immediately resolved test/SDK implementations.
556
+ const pump = Promise.resolve().then(async () => {
557
+ while (this.canRunPendingEdit()) {
558
+ const request = this.pendingEdit;
559
+ this.pendingEdit = undefined;
560
+ if (!request.force && this.signature(request.view) === this.lastSentSignature)
561
+ continue;
562
+ await this.performEdit(request);
327
563
  }
328
- return false;
329
- }
564
+ });
565
+ const owned = pump.finally(() => {
566
+ if (this.editPump === owned)
567
+ this.editPump = undefined;
568
+ // queueEdit can arrive after the loop's last check, while it still sees
569
+ // this flight. Hand runnable work to a NEW owner and adopt its promise:
570
+ // callers awaiting the old flight must also await the terminal handoff.
571
+ // Do not restart blocked unknown work: that would spin microtasks forever.
572
+ if (this.canRunPendingEdit())
573
+ return this.flushEdits();
574
+ });
575
+ this.editPump = owned;
576
+ return owned;
330
577
  }
331
- /**
332
- * Terminal edits (complete/fail/noReply) are one-shot — there is no next tick to
333
- * recover them — so a transient Feishu error (code=2200 etc.) would leave the
334
- * card stuck on "running". Retry the final patch a few times with small backoff
335
- * so the card reliably reaches its terminal state.
336
- */
337
- async safeEditFinal(view) {
338
- if (!this.messageId || this.disabled)
339
- return;
340
- const attempts = Math.max(1, MAX_CONSECUTIVE_EDIT_FAILURES);
341
- for (let i = 0; i < attempts; i++) {
578
+ async performEdit(request) {
579
+ const current = () => !this.disposed && request.generation === this.generation;
580
+ const accept = () => {
581
+ if (request.settled)
582
+ return;
583
+ request.settled = true;
584
+ request.accepted = true;
585
+ this.unknownEdits.delete(request);
586
+ if (current()) {
587
+ this.lastSentSignature = this.signature(request.view);
588
+ this.consecutiveEditFailures = 0;
589
+ }
590
+ else if (this.pinnedView && !this.disposed) {
591
+ // A deadline frees the ordered chain, not the SDK operation. If old
592
+ // progress applies AFTER done/frozen, repair it on this same card.
593
+ void this.reconcilePinnedView(true);
594
+ }
595
+ void this.flushEdits();
596
+ };
597
+ const effect = {
598
+ run: async (send) => {
599
+ if (!current()) {
600
+ request.settled = true;
601
+ return;
602
+ }
603
+ request.started = true;
604
+ try {
605
+ await send();
606
+ accept();
607
+ }
608
+ catch (err) {
609
+ if (err instanceof LiveStatusEffectRejectedError) {
610
+ request.settled = true;
611
+ request.rejection = err;
612
+ this.unknownEdits.delete(request);
613
+ // A late definitive rejection also releases the unknown gate.
614
+ void this.flushEdits();
615
+ }
616
+ throw err;
617
+ }
618
+ },
619
+ };
620
+ const attempts = request.priority ? Math.max(1, MAX_CONSECUTIVE_EDIT_FAILURES) : 1;
621
+ for (let i = 0; i < attempts && current(); i++) {
342
622
  try {
343
- await this.callbacks.edit(this.messageId, view);
344
- this.lastSentSignature = this.signature(view);
623
+ await this.callbacks.edit(this.messageId, request.view, effect);
624
+ if (!request.settled)
625
+ accept(); // direct/non-queued callback
345
626
  return;
346
627
  }
347
628
  catch (err) {
348
- const last = i === attempts - 1;
349
- this.callbacks.warn?.(`live status final edit failed (${i + 1}/${attempts}${last ? ", giving up" : ", retrying"})`, err);
350
- if (last)
629
+ // The low-level receipt can beat this catch even if the outer race
630
+ // already chose its deadline. Never retain "unknown" after acceptance.
631
+ if (request.accepted)
632
+ return;
633
+ const failure = request.rejection ?? err;
634
+ if (failure instanceof LiveStatusEffectSuppressedError)
635
+ return;
636
+ const unknown = !(failure instanceof LiveStatusEffectRejectedError);
637
+ if (unknown) {
638
+ this.unknownEdits.add(request);
639
+ this.callbacks.warn?.("live status edit outcome unknown (not replayed)", failure);
351
640
  return;
352
- await new Promise((r) => setTimeout(r, 300 * (i + 1)));
641
+ }
642
+ this.consecutiveEditFailures++;
643
+ this.callbacks.warn?.("live status edit failed", failure);
644
+ if (request.priority && i + 1 < attempts) {
645
+ await new Promise(resolve => setTimeout(resolve, 300 * (i + 1)));
646
+ request.started = false;
647
+ request.settled = false;
648
+ request.rejection = undefined;
649
+ }
650
+ else if (!request.priority && this.consecutiveEditFailures >= MAX_CONSECUTIVE_EDIT_FAILURES) {
651
+ this.disabled = true;
652
+ this.stopTimers();
653
+ }
353
654
  }
354
655
  }
355
656
  }
@@ -384,17 +685,21 @@ export class LiveStatusController {
384
685
  title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
385
686
  else if (this.waitingForResult)
386
687
  title = en ? `${this.opts.botName} waiting (card frozen)` : `${this.opts.botName} 等待后续结果(已停止刷新)`;
688
+ else if (this.state === "queued")
689
+ title = en ? `${this.opts.botName} received · queued` : `${this.opts.botName} 已收件 · 排队等待`;
690
+ else if (this.state === "admitted")
691
+ title = en ? `${this.opts.botName} accepted · waiting` : `${this.opts.botName} 已接收 · 等待活动`;
387
692
  else
388
693
  title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
389
694
  // On a clean finish (done / NO_REPLY) show only the compact summary. On a
390
695
  // failure (error / killed / timeout) keep the recent activity window too, so
391
696
  // the last few steps before the failure are visible for debugging.
392
697
  let lines;
393
- if (this.state === "running") {
394
- lines = [...this.lines];
698
+ if (this.state !== "done" && this.state !== "failed") {
699
+ lines = this.lines.map(line => ({ ...line }));
395
700
  }
396
701
  else if (this.state === "failed") {
397
- lines = [...this.lines, this.buildSummaryLine(en)];
702
+ lines = [...this.lines.map(line => ({ ...line })), this.buildSummaryLine(en)];
398
703
  }
399
704
  else {
400
705
  lines = [this.buildSummaryLine(en)];
@@ -408,6 +713,7 @@ export class LiveStatusController {
408
713
  toolCalls: this.toolCallCount,
409
714
  noReply: this.noReplyResult,
410
715
  state: this.state,
716
+ phase: this.state === "done" || this.state === "failed" ? this.state : this.waitingForResult ? "waiting" : this.state === "running" ? "working" : this.state,
411
717
  };
412
718
  }
413
719
  buildSummaryLine(en) {