@yeaft/webchat-agent 1.0.170 → 1.0.171

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.170",
3
+ "version": "1.0.171",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -77,6 +77,9 @@ import {
77
77
  /** Maximum auto-continue turns when stopReason is 'max_tokens'. */
78
78
  const MAX_CONTINUE_TURNS = 3;
79
79
 
80
+ /** Bound the best-effort post-turn AMS LLM call independently of the user turn. */
81
+ const AMS_ADJUST_TIMEOUT_MS = 30_000;
82
+
80
83
  // ─── LLM retry policy defaults ──────────────────────────────────
81
84
  // Hard-coded floor / ceiling for retry behaviour. The engine reads the
82
85
  // effective policy from `config.llmRetry` so users can dial these via
@@ -343,7 +346,7 @@ export function shouldAllowGroupReflection({
343
346
 
344
347
  /**
345
348
  * @typedef {{ type: 'turn_start', turnNumber: number }} TurnStartEvent
346
- * @typedef {{ type: 'turn_end', turnNumber: number, stopReason: string }} TurnEndEvent
349
+ * @typedef {{ type: 'turn_end', turnNumber: number, stopReason: string, terminal?: boolean }} TurnEndEvent
347
350
  * @typedef {{ type: 'tool_start', id: string, name: string, input: object }} ToolStartEvent
348
351
  * @typedef {{ type: 'tool_end', id: string, name: string, output: string, isError: boolean }} ToolEndEvent
349
352
  * @typedef {{ type: 'consolidate', archivedCount: number, extractedCount: number }} ConsolidateEvent
@@ -548,6 +551,21 @@ export class Engine {
548
551
  */
549
552
  #pendingTaskResultUpdates = [];
550
553
 
554
+ /**
555
+ * Terminal task results accepted by this Engine but not yet consumed by a
556
+ * successful adapter loop. Ownership stays with the Engine until delivery
557
+ * is acknowledged; abort/retirement hands these payloads back to the bridge
558
+ * for a new-turn rescue.
559
+ * @type {Map<string, {content:string|Array, preview:string, sessionId?:string, vpId?:string, threadId?:string, taskKind?:string, taskStatus?:string}>}
560
+ */
561
+ #acceptedAsyncTaskResults = new Map();
562
+
563
+ /** Task results already spliced into conversationMessages for the next request. */
564
+ #pendingAsyncTaskConfirmIds = new Set();
565
+
566
+ /** Reject new same-turn deliveries once the current query starts closing. */
567
+ #asyncTaskDeliveryClosed = true;
568
+
551
569
  /**
552
570
  * Async task ownership metadata captured when a tool registers a
553
571
  * background task. Keyed by taskId so terminal events can update the
@@ -576,7 +594,12 @@ export class Engine {
576
594
  * `toolCtx.registerAsyncTask` and the engine waits locally; web-bridge
577
595
  * fallback (legacy `scheduleTaskResultReentry` → new turn) handles the
578
596
  * post-run case.
579
- * @type {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null}
597
+ * @type {{
598
+ * onRegister?: (taskId:string, engine:Engine) => void,
599
+ * onUnregister?: (taskId:string, engine:Engine) => void,
600
+ * onConsumed?: (taskId:string, engine:Engine) => void,
601
+ * onUndelivered?: (taskId:string, delivery:object, engine:Engine) => void,
602
+ * } | null}
580
603
  */
581
604
  #asyncTaskCoordinator = null;
582
605
 
@@ -690,6 +713,7 @@ export class Engine {
690
713
  if (!this.#currentAbortCtrl) return false;
691
714
  if (this.#currentAbortCtrl.signal.aborted) return false;
692
715
  this.#abortReason = reason || 'user';
716
+ this.retireAsyncTasks(`query_${this.#abortReason}`);
693
717
  try {
694
718
  this.#currentAbortCtrl.abort();
695
719
  } catch {
@@ -961,15 +985,30 @@ export class Engine {
961
985
  userMsg: args.userMsg,
962
986
  assistantReply: args.assistantReply,
963
987
  runLLM: async (prompt) => {
964
- const out = await this.#adapter.call({
965
- model: this.#fastConfig.model,
966
- system: (String(this.#config?.language || '').toLowerCase().startsWith('zh')
967
- ? '你是记忆管理子程序。请按要求只回复一个 JSON 对象,不要输出额外说明。'
968
- : 'You are a memory-management subroutine. Reply with a single JSON object as instructed.'),
969
- messages: [{ role: 'user', content: prompt }],
970
- maxTokens: 1024,
988
+ const maintenanceCtrl = new AbortController();
989
+ let timeout = null;
990
+ const timedOut = new Promise((_, reject) => {
991
+ timeout = setTimeout(() => {
992
+ maintenanceCtrl.abort('ams_adjust_timeout');
993
+ reject(new LLMAbortError());
994
+ }, AMS_ADJUST_TIMEOUT_MS);
995
+ if (timeout && typeof timeout.unref === 'function') timeout.unref();
971
996
  });
972
- return out?.text || '';
997
+ try {
998
+ const request = this.#adapter.call({
999
+ model: this.#fastConfig.model,
1000
+ system: (String(this.#config?.language || '').toLowerCase().startsWith('zh')
1001
+ ? '你是记忆管理子程序。请按要求只回复一个 JSON 对象,不要输出额外说明。'
1002
+ : 'You are a memory-management subroutine. Reply with a single JSON object as instructed.'),
1003
+ messages: [{ role: 'user', content: prompt }],
1004
+ maxTokens: 1024,
1005
+ signal: maintenanceCtrl.signal,
1006
+ });
1007
+ const out = await Promise.race([request, timedOut]);
1008
+ return out?.text || '';
1009
+ } finally {
1010
+ if (timeout) clearTimeout(timeout);
1011
+ }
973
1012
  },
974
1013
  });
975
1014
  if (result?.ran) {
@@ -1578,10 +1617,41 @@ export class Engine {
1578
1617
  : this.#formatTaskResultUpdateContent(toolMsg.content);
1579
1618
  toolMsg.content = `${prior}\n\n${appendText}`;
1580
1619
  applied.push(update);
1620
+ if (this.#acceptedAsyncTaskResults.has(update.taskId)) {
1621
+ this.#pendingAsyncTaskConfirmIds.add(update.taskId);
1622
+ }
1581
1623
  }
1582
1624
  return applied;
1583
1625
  }
1584
1626
 
1627
+ #confirmAsyncTaskResults(taskIds) {
1628
+ if (!Array.isArray(taskIds) || taskIds.length === 0) return;
1629
+ for (const taskId of taskIds) {
1630
+ this.#pendingAsyncTaskConfirmIds.delete(taskId);
1631
+ if (!this.#acceptedAsyncTaskResults.delete(taskId)) continue;
1632
+ try {
1633
+ if (typeof this.#asyncTaskCoordinator?.onConsumed === 'function') {
1634
+ this.#asyncTaskCoordinator.onConsumed(taskId, this);
1635
+ } else {
1636
+ this.#asyncTaskCoordinator?.onUnregister?.(taskId, this);
1637
+ }
1638
+ } catch { /* best-effort */ }
1639
+ }
1640
+ }
1641
+
1642
+ #releaseUndeliveredAsyncTaskResults(reason = 'query_closed') {
1643
+ if (this.#acceptedAsyncTaskResults.size === 0) return 0;
1644
+ const deliveries = Array.from(this.#acceptedAsyncTaskResults.entries());
1645
+ this.#acceptedAsyncTaskResults.clear();
1646
+ this.#pendingAsyncTaskConfirmIds.clear();
1647
+ for (const [taskId, delivery] of deliveries) {
1648
+ try {
1649
+ this.#asyncTaskCoordinator?.onUndelivered?.(taskId, { ...delivery, reason }, this);
1650
+ } catch { /* rescue plumbing must not break query teardown */ }
1651
+ }
1652
+ return deliveries.length;
1653
+ }
1654
+
1585
1655
  #drainPendingUserMessages(drainPendingUserMessages) {
1586
1656
  const pending = [];
1587
1657
  if (typeof drainPendingUserMessages === 'function') {
@@ -1611,11 +1681,15 @@ export class Engine {
1611
1681
  const preview = typeof item.preview === 'string'
1612
1682
  ? item.preview
1613
1683
  : (typeof content === 'string' ? content : '[content blocks]');
1684
+ const taskId = typeof item.taskId === 'string' ? item.taskId : undefined;
1685
+ if (taskId && this.#acceptedAsyncTaskResults.has(taskId)) {
1686
+ this.#pendingAsyncTaskConfirmIds.add(taskId);
1687
+ }
1614
1688
  return {
1615
1689
  content,
1616
1690
  preview,
1617
1691
  internal: Boolean(item.internal),
1618
- taskId: typeof item.taskId === 'string' ? item.taskId : undefined,
1692
+ taskId,
1619
1693
  };
1620
1694
  })
1621
1695
  .filter(Boolean);
@@ -1695,6 +1769,9 @@ export class Engine {
1695
1769
  const abortCtrl = new AbortController();
1696
1770
  this.#currentAbortCtrl = abortCtrl;
1697
1771
  this.#abortReason = null;
1772
+ this.#asyncTaskDeliveryClosed = false;
1773
+ this.#acceptedAsyncTaskResults.clear();
1774
+ this.#pendingAsyncTaskConfirmIds.clear();
1698
1775
 
1699
1776
  const onExternalAbort = () => {
1700
1777
  if (!abortCtrl.signal.aborted) {
@@ -1702,12 +1779,14 @@ export class Engine {
1702
1779
  // external trigger. Callers that pass a signal without invoking
1703
1780
  // engine.abort() get the neutral tag 'external'.
1704
1781
  if (!this.#abortReason) this.#abortReason = 'external';
1782
+ this.retireAsyncTasks('query_external');
1705
1783
  try { abortCtrl.abort(); } catch { /* ignore */ }
1706
1784
  }
1707
1785
  };
1708
1786
  if (signal) {
1709
1787
  if (signal.aborted) {
1710
1788
  this.#abortReason = 'external';
1789
+ this.retireAsyncTasks('query_external');
1711
1790
  try { abortCtrl.abort(); } catch { /* ignore */ }
1712
1791
  } else {
1713
1792
  signal.addEventListener('abort', onExternalAbort, { once: true });
@@ -1723,24 +1802,13 @@ export class Engine {
1723
1802
  if (signal) {
1724
1803
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
1725
1804
  }
1805
+ this.retireAsyncTasks(abortCtrl.signal.aborted ? 'query_aborted' : 'query_closed');
1726
1806
  // Clear current-run state so engine.isRunning flips back to false
1727
1807
  // and a subsequent query() starts with a clean slate.
1728
1808
  this.#currentAbortCtrl = null;
1729
1809
  this.#abortReason = null;
1730
1810
  this.#currentThreadId = MAIN_THREAD_ID;
1731
1811
  this.#pendingUserMessages.length = 0;
1732
- // Hand back any async tasks still on the books to the coordinator so
1733
- // a late terminal event falls through to the legacy rescue path
1734
- // (new turn) instead of being silently swallowed. The coordinator
1735
- // is responsible for keeping its owner map in sync.
1736
- if (this.#pendingAsyncTaskIds.size > 0) {
1737
- const leftover = Array.from(this.#pendingAsyncTaskIds);
1738
- this.#pendingAsyncTaskIds.clear();
1739
- for (const tid of leftover) {
1740
- this.#asyncTaskToolMeta.delete(tid);
1741
- try { this.#asyncTaskCoordinator?.onUnregister?.(tid); } catch { /* ignore */ }
1742
- }
1743
- }
1744
1812
  this.#asyncTaskToolMeta.clear();
1745
1813
  this.#pendingTaskResultMessages.length = 0;
1746
1814
  this.#pendingTaskResultUpdates.length = 0;
@@ -2349,7 +2417,12 @@ export class Engine {
2349
2417
  }
2350
2418
  }
2351
2419
 
2352
- // Stream from adapter
2420
+ // Snapshot task results carried by this exact request. Request start
2421
+ // is not delivery: fetch may remain pending and then be aborted before
2422
+ // the provider processes anything. Ack only after a normal stream end
2423
+ // that included the provider's terminal stop event.
2424
+ const requestAsyncTaskIds = Array.from(this.#pendingAsyncTaskConfirmIds);
2425
+ let sawProviderStop = false;
2353
2426
  for await (const event of this.#adapter.stream({
2354
2427
  model: currentModel,
2355
2428
  system: systemPrompt,
@@ -2428,6 +2501,7 @@ export class Engine {
2428
2501
  break;
2429
2502
  }
2430
2503
  case 'stop':
2504
+ sawProviderStop = true;
2431
2505
  stopReason = event.stopReason;
2432
2506
  yield event;
2433
2507
  break;
@@ -2454,6 +2528,19 @@ export class Engine {
2454
2528
  }
2455
2529
  }
2456
2530
  }
2531
+ // Some proxies resolve the SSE body cleanly after AbortSignal instead
2532
+ // of throwing. Do not treat that truncated stream as a successful
2533
+ // model response or let it reach stop hooks/persistence.
2534
+ if (signal?.aborted) {
2535
+ throw new LLMAbortError();
2536
+ }
2537
+ // A provider terminal stop plus normal stream completion proves that
2538
+ // the request containing these task results completed successfully.
2539
+ // Retryable errors, aborts, idle timeouts, and truncated streams keep
2540
+ // escrow so retry or final rescue can deliver the payload.
2541
+ if (sawProviderStop) {
2542
+ this.#confirmAsyncTaskResults(requestAsyncTaskIds);
2543
+ }
2457
2544
  traceRequest('llm.request_complete', {
2458
2545
  durationMs: perfNowMs() - requestPerfStart,
2459
2546
  ok: true,
@@ -2880,6 +2967,11 @@ export class Engine {
2880
2967
  aborted: Boolean(signal?.aborted),
2881
2968
  remainingTaskIds: Array.from(this.#pendingAsyncTaskIds),
2882
2969
  };
2970
+ if (signal?.aborted) {
2971
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2972
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2973
+ break;
2974
+ }
2883
2975
  if (!signal?.aborted) {
2884
2976
  const taskResultUpdatesAfterAsyncWait = this.#drainPendingTaskResultUpdates(conversationMessages);
2885
2977
  if (taskResultUpdatesAfterAsyncWait.length > 0) {
@@ -2916,10 +3008,9 @@ export class Engine {
2916
3008
  continue;
2917
3009
  }
2918
3010
  }
2919
- // If we fall through here, either abort fired or the wait
2920
- // exited with no payload (all tasks released themselves via
2921
- // engine teardown / unregister with no notification). Drop into
2922
- // the regular end_turn path.
3011
+ // If we fall through here, task ownership was released without a
3012
+ // payload (engine teardown / unregister with no notification). Drop
3013
+ // into the regular end_turn path.
2923
3014
  }
2924
3015
 
2925
3016
  // If no tool calls, we're done
@@ -2927,7 +3018,7 @@ export class Engine {
2927
3018
  if (pendingSubAgentNotifs.length > 0) {
2928
3019
  acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
2929
3020
  }
2930
- yield { type: 'turn_end', turnNumber, stopReason, threadId };
3021
+ yield { type: 'turn_end', turnNumber, stopReason, threadId, terminal: true };
2931
3022
 
2932
3023
  // ─── Post-query: StopHooks or Legacy ─────────────
2933
3024
  if (this.#config._readOnly) {
@@ -3593,12 +3684,44 @@ export class Engine {
3593
3684
  * Install the coordinator that lets an external dispatcher (web-bridge)
3594
3685
  * route a background task's terminal event back to THIS engine while
3595
3686
  * its query() is still running. Pass `null` to detach.
3596
- * @param {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null} coord
3687
+ * @param {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string, engine:Engine) => void, onConsumed?: (taskId:string, engine:Engine) => void, onUndelivered?: (taskId:string, delivery:object, engine:Engine) => void } | null} coord
3597
3688
  */
3598
3689
  setAsyncTaskCoordinator(coord) {
3599
3690
  this.#asyncTaskCoordinator = (coord && typeof coord === 'object') ? coord : null;
3600
3691
  }
3601
3692
 
3693
+ /**
3694
+ * Close same-turn task delivery for this Engine. Accepted-but-undrained
3695
+ * results are handed to the coordinator for exactly-once rescue; tasks that
3696
+ * have not completed are merely unregistered so a future terminal event
3697
+ * falls through to the bridge rescue path.
3698
+ *
3699
+ * @param {string} [reason]
3700
+ * @param {{ rescue?: boolean }} [opts]
3701
+ * @returns {number} number of accepted results released or discarded
3702
+ */
3703
+ retireAsyncTasks(reason = 'engine_retired', { rescue = true } = {}) {
3704
+ this.#asyncTaskDeliveryClosed = true;
3705
+ const released = rescue
3706
+ ? this.#releaseUndeliveredAsyncTaskResults(reason)
3707
+ : (() => {
3708
+ const dropped = this.#acceptedAsyncTaskResults.size;
3709
+ this.#acceptedAsyncTaskResults.clear();
3710
+ this.#pendingAsyncTaskConfirmIds.clear();
3711
+ return dropped;
3712
+ })();
3713
+ if (this.#pendingAsyncTaskIds.size > 0) {
3714
+ const pending = Array.from(this.#pendingAsyncTaskIds);
3715
+ this.#pendingAsyncTaskIds.clear();
3716
+ for (const taskId of pending) {
3717
+ this.#asyncTaskToolMeta.delete(taskId);
3718
+ try { this.#asyncTaskCoordinator?.onUnregister?.(taskId, this); } catch { /* best-effort */ }
3719
+ }
3720
+ }
3721
+ this.#wakeAsyncTaskWaiters();
3722
+ return released;
3723
+ }
3724
+
3602
3725
  /**
3603
3726
  * True iff the currently-running query is holding pending async tasks.
3604
3727
  * Used by external probes (web-bridge fallback dispatcher) to decide
@@ -3633,11 +3756,12 @@ export class Engine {
3633
3756
  *
3634
3757
  * @param {string} taskId
3635
3758
  * @param {string|Array} content — pre-formatted task result body
3636
- * @param {{ preview?: string }} [opts]
3759
+ * @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string }} [opts]
3637
3760
  * @returns {boolean}
3638
3761
  */
3639
3762
  notifyAsyncTaskCompleted(taskId, content, opts = {}) {
3640
3763
  if (!this.ownsPendingAsyncTask(taskId)) return false;
3764
+ if (this.#asyncTaskDeliveryClosed || !this.#currentAbortCtrl || this.#currentAbortCtrl.signal.aborted) return false;
3641
3765
  if (typeof content !== 'string' && !Array.isArray(content)) return false;
3642
3766
  if (typeof content === 'string' && !content.trim()) return false;
3643
3767
  // Defensive: an empty content-block array would splice as a wire-valid
@@ -3646,11 +3770,20 @@ export class Engine {
3646
3770
  // always emit a non-empty string today; this guards future refactors.
3647
3771
  if (Array.isArray(content) && content.length === 0) return false;
3648
3772
  this.#pendingAsyncTaskIds.delete(taskId);
3649
- try { this.#asyncTaskCoordinator?.onUnregister?.(taskId); } catch { /* coord must not throw into engine */ }
3650
3773
  const preview = typeof opts.preview === 'string'
3651
3774
  ? opts.preview
3652
3775
  : (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
3653
3776
  const meta = this.#asyncTaskToolMeta.get(taskId) || {};
3777
+ const delivery = {
3778
+ content,
3779
+ preview,
3780
+ sessionId: opts.sessionId,
3781
+ vpId: opts.vpId,
3782
+ threadId: opts.threadId || meta.threadId,
3783
+ taskKind: opts.taskKind,
3784
+ taskStatus: opts.taskStatus,
3785
+ };
3786
+ this.#acceptedAsyncTaskResults.set(taskId, delivery);
3654
3787
  this.#asyncTaskToolMeta.delete(taskId);
3655
3788
  if (typeof meta.toolCallId === 'string' && meta.toolCallId) {
3656
3789
  this.#pendingTaskResultUpdates.push({
@@ -341,6 +341,54 @@ const vpAborts = new Map();
341
341
  */
342
342
  const asyncTaskOwners = new Map();
343
343
 
344
+ function deleteAsyncTaskOwnerIfMatch(taskId, engine) {
345
+ if (typeof taskId !== 'string' || !taskId) return false;
346
+ if (asyncTaskOwners.get(taskId) !== engine) return false;
347
+ asyncTaskOwners.delete(taskId);
348
+ return true;
349
+ }
350
+
351
+ /**
352
+ * Retire one cached VP Engine without letting stale task ownership leak into
353
+ * its replacement. Non-destructive retirement rescues accepted-but-undrained
354
+ * terminal results; destructive Session/roster removal explicitly discards
355
+ * them so a deleted runtime is not recreated by a rescue turn.
356
+ */
357
+ function retireCachedVpEngine(key, {
358
+ reason = 'engine_retired',
359
+ rescue = true,
360
+ expectedEngine = null,
361
+ } = {}) {
362
+ const cachedEngine = vpEngines.get(key) || null;
363
+ const engine = expectedEngine || cachedEngine;
364
+ if (!engine) {
365
+ if (!vpEngines.has(key)) vpEngineConfigKeys.delete(key);
366
+ return null;
367
+ }
368
+
369
+ // Destructive removal must discard accepted payloads before abort(), whose
370
+ // default is to rescue. Config changes and watchdog retirement keep rescue.
371
+ if (!rescue) {
372
+ try { engine.retireAsyncTasks?.(reason, { rescue: false }); } catch { /* best-effort */ }
373
+ }
374
+ let aborted = false;
375
+ try { aborted = engine.abort?.(reason) === true; } catch { /* best-effort */ }
376
+ if (!aborted) {
377
+ try { engine.retireAsyncTasks?.(reason, { rescue }); } catch { /* best-effort */ }
378
+ }
379
+
380
+ // Identity guard: a stale promise may retire after a replacement Engine was
381
+ // cached under the same key. Never delete the replacement or its config.
382
+ if (vpEngines.get(key) === engine) {
383
+ vpEngines.delete(key);
384
+ vpEngineConfigKeys.delete(key);
385
+ }
386
+ for (const [taskId, ownerEngine] of asyncTaskOwners) {
387
+ if (ownerEngine === engine) deleteAsyncTaskOwnerIfMatch(taskId, engine);
388
+ }
389
+ return engine;
390
+ }
391
+
344
392
  /**
345
393
  * Build a coordinator for a freshly-constructed engine. The coordinator
346
394
  * keeps `asyncTaskOwners` in sync so a `taskManager` `completed` event
@@ -351,17 +399,42 @@ const asyncTaskOwners = new Map();
351
399
  * inherit a coordinator that still associates their tasks with the
352
400
  * sub-engine, not the parent.
353
401
  *
354
- * @returns {{ onRegister: (taskId: string, engine: import('./engine.js').Engine) => void, onUnregister: (taskId: string) => void }}
402
+ * @returns {{
403
+ * onRegister: (taskId: string, engine: import('./engine.js').Engine) => void,
404
+ * onUnregister: (taskId: string, engine: import('./engine.js').Engine) => void,
405
+ * onConsumed: (taskId: string, engine: import('./engine.js').Engine) => void,
406
+ * onUndelivered: (taskId: string, delivery: object, engine: import('./engine.js').Engine) => void,
407
+ * }}
355
408
  */
356
409
  function buildAsyncTaskCoordinator() {
410
+ const deleteOwnerIfMatch = (taskId, engine) => {
411
+ if (typeof taskId !== 'string' || !taskId) return false;
412
+ if (asyncTaskOwners.get(taskId) !== engine) return false;
413
+ asyncTaskOwners.delete(taskId);
414
+ return true;
415
+ };
357
416
  return {
358
417
  onRegister(taskId, engine) {
359
418
  if (typeof taskId !== 'string' || !taskId) return;
360
419
  asyncTaskOwners.set(taskId, engine);
361
420
  },
362
- onUnregister(taskId) {
363
- if (typeof taskId !== 'string' || !taskId) return;
364
- asyncTaskOwners.delete(taskId);
421
+ onUnregister(taskId, engine) {
422
+ deleteOwnerIfMatch(taskId, engine);
423
+ },
424
+ onConsumed(taskId, engine) {
425
+ deleteOwnerIfMatch(taskId, engine);
426
+ },
427
+ onUndelivered(taskId, delivery, engine) {
428
+ if (!deleteOwnerIfMatch(taskId, engine)) return;
429
+ scheduleTaskResultRescue({
430
+ taskId,
431
+ sessionId: delivery?.sessionId || engine?.sessionId || null,
432
+ vpId: delivery?.vpId || engine?.vpId || null,
433
+ threadId: delivery?.threadId || engine?.currentThreadId || 'main',
434
+ content: delivery?.content,
435
+ taskKind: delivery?.taskKind,
436
+ taskStatus: delivery?.taskStatus,
437
+ });
365
438
  },
366
439
  };
367
440
  }
@@ -766,13 +839,15 @@ function queryTimeoutMsForSession(sessionId = null) {
766
839
  * Without a second-stage escalation the typing dots hang forever —
767
840
  * exactly the "halts mid-execution with no turn_end" symptom.
768
841
  *
769
- * The driver loop wraps `await runVpTurn(...)` in a Promise.race against
770
- * this grace-window timer. If runVpTurn doesn't return within
771
- * active query timeout + ESCALATE_AFTER_ABORT_MS, the driver forces its
772
- * `finally` block (vp_typing_end + group_message), emits a synthetic
773
- * `result{stopped:true}` so the frontend leaves its in-flight state,
774
- * and moves on. The hung tool promise leaks (JS lacks cooperative
775
- * promise cancellation) but the user-facing turn is closed.
842
+ * The driver starts this grace-window timer only after `vpAbort.signal`
843
+ * fires. Starting it when the turn is enqueued would turn the activity-based
844
+ * silence watchdog into a hard total-duration limit and kill healthy turns
845
+ * that keep producing LLM/tool events for several minutes.
846
+ *
847
+ * If runVpTurn still does not return within the grace period after abort,
848
+ * the driver emits a synthetic `result{stopped:true}`, closes the visible
849
+ * turn, and moves on. The hung tool promise remains observed in the
850
+ * background because JavaScript promises have no forced cancellation.
776
851
  *
777
852
  * 15s is wide enough that legitimate "abort took a moment to propagate"
778
853
  * paths (network teardown, finally cleanup) finish first; tight enough
@@ -1273,6 +1348,21 @@ export function __testGetOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1273
1348
  return getOrCreateVpEngine(sessionId, vpId, threadId);
1274
1349
  }
1275
1350
 
1351
+ /** Test-only: retire one cached VP Engine through the production helper. */
1352
+ export function __testRetireVpEngine({
1353
+ sessionId,
1354
+ vpId,
1355
+ threadId = 'main',
1356
+ reason = 'test_retire',
1357
+ rescue = true,
1358
+ expectedEngine = null,
1359
+ }) {
1360
+ return retireCachedVpEngine(threadKey(sessionId, vpId, threadId), {
1361
+ reason,
1362
+ rescue,
1363
+ expectedEngine,
1364
+ });
1365
+ }
1276
1366
 
1277
1367
  /** Test-only: inspect runtime thread rows for a VP. */
1278
1368
  export function __testGetVpThreads(sessionId, vpId) {
@@ -1342,11 +1432,7 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1342
1432
  const configKey = engineConfigKey(effectiveConfig);
1343
1433
  let eng = vpEngines.get(key);
1344
1434
  if (eng && vpEngineConfigKeys.get(key) === configKey) return eng;
1345
- if (eng) {
1346
- try { eng.abort?.('config_changed'); } catch { /* best-effort */ }
1347
- vpEngines.delete(key);
1348
- vpEngineConfigKeys.delete(key);
1349
- }
1435
+ if (eng) retireCachedVpEngine(key, { reason: 'config_changed', rescue: true, expectedEngine: eng });
1350
1436
  eng = new Engine({
1351
1437
  adapter: session.adapter,
1352
1438
  trace: session.trace,
@@ -1484,6 +1570,37 @@ function formatTaskResultForVp(task) {
1484
1570
  return lines.join('\n');
1485
1571
  }
1486
1572
 
1573
+ function scheduleTaskResultRescue({ taskId, sessionId, vpId, threadId = 'main', content, taskKind, taskStatus }) {
1574
+ if (!sessionId || !vpId || !taskId) return false;
1575
+ const text = typeof content === 'string'
1576
+ ? content
1577
+ : (() => { try { return JSON.stringify(content); } catch { return String(content); } })();
1578
+ if (!text.trim()) return false;
1579
+ const msgId = `task_result_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
1580
+ queueMicrotask(() => {
1581
+ enqueueForVp(sessionId, vpId, {
1582
+ sessionId,
1583
+ taskId,
1584
+ trigger: 'task_result',
1585
+ _promptSuffix: '',
1586
+ msg: {
1587
+ id: msgId,
1588
+ from: 'tool',
1589
+ role: 'assistant',
1590
+ text,
1591
+ meta: {
1592
+ injectedBy: 'task_result',
1593
+ taskId,
1594
+ taskKind,
1595
+ taskStatus,
1596
+ sourceThreadId: threadId,
1597
+ },
1598
+ },
1599
+ });
1600
+ });
1601
+ return true;
1602
+ }
1603
+
1487
1604
  function scheduleTaskResultReentry(event) {
1488
1605
  if (!event || event.event !== 'completed' || !event.task) return;
1489
1606
  const task = event.task;
@@ -1510,38 +1627,27 @@ function scheduleTaskResultReentry(event) {
1510
1627
  try {
1511
1628
  const accepted = ownerEngine.notifyAsyncTaskCompleted(task.id, formatted, {
1512
1629
  preview: `task ${task.kind || 'tool'} ${task.status}`,
1630
+ sessionId,
1631
+ vpId,
1632
+ threadId,
1633
+ taskKind: task.kind,
1634
+ taskStatus: task.status,
1513
1635
  });
1514
- if (accepted) {
1515
- asyncTaskOwners.delete(task.id);
1516
- return;
1517
- }
1636
+ if (accepted) return;
1518
1637
  } catch {
1519
1638
  // Same-turn delivery is best-effort. Fall through to the legacy
1520
1639
  // rescue path so we never drop a terminal event on the floor.
1521
1640
  }
1522
1641
  }
1523
1642
 
1524
- const msgId = `task_result_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
1525
- queueMicrotask(() => {
1526
- enqueueForVp(sessionId, vpId, {
1527
- sessionId,
1528
- taskId: task.id,
1529
- trigger: 'task_result',
1530
- _promptSuffix: '',
1531
- msg: {
1532
- id: msgId,
1533
- from: 'tool',
1534
- role: 'assistant',
1535
- text: formatted,
1536
- meta: {
1537
- injectedBy: 'task_result',
1538
- taskId: task.id,
1539
- taskKind: task.kind,
1540
- taskStatus: task.status,
1541
- sourceThreadId: threadId,
1542
- },
1543
- },
1544
- });
1643
+ scheduleTaskResultRescue({
1644
+ taskId: task.id,
1645
+ sessionId,
1646
+ vpId,
1647
+ threadId,
1648
+ content: formatted,
1649
+ taskKind: task.kind,
1650
+ taskStatus: task.status,
1545
1651
  });
1546
1652
  }
1547
1653
 
@@ -2606,12 +2712,12 @@ export function handleYeaftUpdateSessionConfig(msg) {
2606
2712
  if (!partial) throw new SessionConfigError('invalid_patch', 'config object required');
2607
2713
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2608
2714
  const savedConfig = updateSessionConfig(yeaftDir, sessionId, partial);
2609
- // Drop cached engines so the next VP turn rebuilds with the new model.
2715
+ // Retire cached engines so accepted terminal task results are rescued
2716
+ // before the next VP turn rebuilds with the new model.
2610
2717
  const prefix = `${sessionId}::`;
2611
2718
  for (const k of Array.from(vpEngines.keys())) {
2612
2719
  if (k.startsWith(prefix)) {
2613
- vpEngines.delete(k);
2614
- vpEngineConfigKeys.delete(k);
2720
+ retireCachedVpEngine(k, { reason: 'session_config_changed', rescue: true });
2615
2721
  }
2616
2722
  }
2617
2723
  invalidateGroupContext(sessionId);
@@ -2670,8 +2776,7 @@ export function handleYeaftDeleteSession(msg) {
2670
2776
  const prefix = `${sessionId}::`;
2671
2777
  for (const k of Array.from(vpEngines.keys())) {
2672
2778
  if (k.startsWith(prefix)) {
2673
- vpEngines.delete(k);
2674
- vpEngineConfigKeys.delete(k);
2779
+ retireCachedVpEngine(k, { reason: 'session_deleted', rescue: false });
2675
2780
  }
2676
2781
  }
2677
2782
  sendSessionCrudResult({
@@ -2718,8 +2823,7 @@ export function handleYeaftSessionRemoveMember(msg) {
2718
2823
  const removedPrefix = `${sessionId}::${vpId}::`;
2719
2824
  for (const key of Array.from(vpEngines.keys())) {
2720
2825
  if (key.startsWith(removedPrefix)) {
2721
- vpEngines.delete(key);
2722
- vpEngineConfigKeys.delete(key);
2826
+ retireCachedVpEngine(key, { reason: 'session_member_removed', rescue: false });
2723
2827
  }
2724
2828
  }
2725
2829
  sendSessionCrudResult({ op: 'remove_member', requestId, ok: true, session: group });
@@ -2998,14 +3102,18 @@ function queueStreamTextDelta(hctx, text, envelope) {
2998
3102
  * todos, debug cards, and persistence all share the same boundary.
2999
3103
  *
3000
3104
  * @param {object} event — engine event (text_delta / tool_call / …)
3001
- * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, thinkingBlocksAccum?:Array, resetQueryTimer:Function, sessionId?:string, vpId?:string, turnId?:string}} hctx
3105
+ * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, thinkingBlocksAccum?:Array, resetQueryTimer:Function, pauseQueryTimer?:Function, markEngineTerminal?:Function, sessionId?:string, vpId?:string, turnId?:string}} hctx
3002
3106
  */
3003
3107
  export function __testHandleEngineEvent(event, hctx) {
3004
3108
  return handleEngineEvent(event, hctx);
3005
3109
  }
3006
3110
 
3007
3111
  function handleEngineEvent(event, hctx) {
3008
- hctx.resetQueryTimer();
3112
+ const terminalTurnEnd = event.type === 'turn_end' && event.terminal === true;
3113
+ const managesQueryTimer = terminalTurnEnd
3114
+ || event.type === 'async_task_wait_start'
3115
+ || event.type === 'async_task_wait_end';
3116
+ if (!managesQueryTimer) hctx.resetQueryTimer();
3009
3117
  const envelope = {
3010
3118
  sessionId: hctx.sessionId,
3011
3119
  vpId: hctx.vpId,
@@ -3171,13 +3279,33 @@ function handleEngineEvent(event, hctx) {
3171
3279
  // No UI action needed; outer loop sends the final result.
3172
3280
  break;
3173
3281
 
3282
+ case 'aborted':
3283
+ if (typeof hctx.markEngineTerminal === 'function') {
3284
+ hctx.markEngineTerminal('aborted', { reason: event.reason || 'external' });
3285
+ }
3286
+ break;
3287
+
3174
3288
  case 'turn_end':
3175
- // Most engine turn_end events are internal loop boundaries. A normal
3176
- // tool_use stop means "run tools, then call the adapter again", so it
3177
- // must NOT end the VP's visible turn. route_forward is different: the
3178
- // tool has handed control to another VP and Engine.query will not
3179
- // stream more text for this VP. Settle the current VP immediately so
3180
- // the roster row does not sit on "thinking" until later result cleanup.
3289
+ // Most engine turn_end events are internal loop boundaries. Only the
3290
+ // explicit terminal event precedes post-turn persistence/maintenance;
3291
+ // stop the user-query silence watchdog before that best-effort work.
3292
+ if (event.terminal && typeof hctx.pauseQueryTimer === 'function') {
3293
+ hctx.pauseQueryTimer();
3294
+ }
3295
+ // A normal tool_use stop means "run tools, then call the adapter again",
3296
+ // so it must NOT end the VP's visible turn. An aborted turn is terminal
3297
+ // too, but the outer runVpTurn boundary owns its single stopped result
3298
+ // and vp_turn_end emission.
3299
+ if (event.stopReason === 'aborted') {
3300
+ if (typeof hctx.markEngineTerminal === 'function') {
3301
+ hctx.markEngineTerminal('aborted', event.detail || null);
3302
+ }
3303
+ break;
3304
+ }
3305
+ // route_forward is different: the tool has handed control to another
3306
+ // VP and Engine.query will not stream more text for this VP. Settle the
3307
+ // current VP immediately so the roster row does not sit on "thinking"
3308
+ // until later result cleanup.
3181
3309
  if (event.stopReason === 'tool_handoff' && event.detail?.kind === 'route_forward') {
3182
3310
  try {
3183
3311
  if (hctx.thread) {
@@ -3374,6 +3502,10 @@ function handleEngineEvent(event, hctx) {
3374
3502
  // namespaced under `vp_async_task_*` to match the existing
3375
3503
  // `vp_thread_*` / `vp_typing_*` event family.
3376
3504
  case 'async_task_wait_start':
3505
+ // The engine is intentionally parked on a tracked background task, not
3506
+ // waiting on the LLM. Pause the LLM-silence watchdog until the task (or
3507
+ // an explicit abort) wakes the turn.
3508
+ if (typeof hctx.pauseQueryTimer === 'function') hctx.pauseQueryTimer();
3377
3509
  sendSessionEvent({
3378
3510
  type: 'vp_async_task_wait_start',
3379
3511
  turnId: event.turnId,
@@ -3385,6 +3517,7 @@ function handleEngineEvent(event, hctx) {
3385
3517
  break;
3386
3518
 
3387
3519
  case 'async_task_wait_end':
3520
+ if (!event.aborted && typeof hctx.resetQueryTimer === 'function') hctx.resetQueryTimer();
3388
3521
  sendSessionEvent({
3389
3522
  type: 'vp_async_task_wait_end',
3390
3523
  turnId: event.turnId,
@@ -4065,41 +4198,34 @@ function startSessionLoadInBackground({ sessionId = null, sessionMeta = null, pe
4065
4198
  }
4066
4199
 
4067
4200
  /**
4068
- * Wrap {@link runVpTurn} with a hard escalation deadline.
4201
+ * Wrap {@link runVpTurn} with a second-stage abort escalation.
4069
4202
  *
4070
- * The first-line defense is the in-turn watchdog inside runVpTurn: at
4071
- * {@link QUERY_TIMEOUT_MS} of silence it calls `vpAbort.abort()`. When
4072
- * adapters and tools cooperate with AbortSignal that's enough — the
4073
- * engine throws AbortError, the catch handler emits `result{stopped:true}`,
4074
- * and the driver's `finally` emits `vp_typing_end`.
4203
+ * The in-turn watchdog is activity based: every engine event resets its
4204
+ * silence timer, and only a genuinely silent turn calls `vpAbort.abort()`.
4205
+ * This wrapper must therefore start its grace period from that abort signal,
4206
+ * not from turn enqueue. A fixed enqueue deadline incorrectly terminates
4207
+ * healthy long-running turns even while the LLM and tools keep making
4208
+ * progress.
4075
4209
  *
4076
- * This wrapper is the second-line defense for the "tool ignores signal"
4077
- * failure mode. If runVpTurn doesn't return within
4078
- * QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS we synthesize a clean exit:
4079
- * emit a synthetic `result{stopped:true}` so the frontend leaves its
4080
- * in-flight state, log loudly so operators know a tool is stuck, and
4081
- * resolve. The hung promise leaks (the engine generator is permanently
4082
- * blocked on a tool that ignores cancellation) but the user-facing turn
4083
- * is closed and the next message can flow. Resolving the wrapper is
4084
- * preferred over rejecting because the driver's catch already logs a
4085
- * warning — we want a single, unambiguous "watchdog escalated" line in
4086
- * the log instead of layered noise.
4087
- *
4088
- * Tool-level timeouts (see registry.js DEFAULT_TOOL_TIMEOUT_MS) are the
4089
- * real cure: this wrapper should rarely fire because no tool should be
4090
- * able to block longer than its budget. It exists as belt-and-suspenders
4091
- * for tools that legitimately disable timeouts (long-running internal
4092
- * helpers) or for adapter implementations that ignore signal.
4210
+ * If an adapter or tool ignores AbortSignal and the turn still has not
4211
+ * returned after {@link ESCALATE_AFTER_ABORT_MS}, emit one synthetic terminal
4212
+ * result and unblock the per-VP driver. The dangling promise may eventually
4213
+ * settle, so `escalationState` prevents it from emitting a second terminal
4214
+ * result or overwriting the state of a newer turn.
4093
4215
  */
4094
4216
  async function runVpTurnWithEscalation(args) {
4095
- const { sessionId, vpId, turnId, threadId, thread } = args;
4096
- const queryTimeoutMs = queryTimeoutMsForSession(sessionId);
4097
- const deadlineMs = queryTimeoutMs + ESCALATE_AFTER_ABORT_MS;
4098
- await raceWithEscalation(runVpTurn(args), {
4099
- deadlineMs,
4217
+ const { sessionId, vpId, turnId, threadId, thread, vpAbort } = args;
4218
+ const escalationState = { escalated: false, terminalEmitted: false };
4219
+
4220
+ await raceWithEscalation(runVpTurn({ ...args, escalationState }), {
4221
+ signal: vpAbort?.signal,
4222
+ graceMs: ESCALATE_AFTER_ABORT_MS,
4100
4223
  onEscalate: () => {
4224
+ if (escalationState.escalated) return;
4225
+ escalationState.escalated = true;
4226
+ escalationState.terminalEmitted = true;
4101
4227
  console.error(
4102
- `[Yeaft] runVpTurn watchdog escalation: VP ${vpId} did not return ${deadlineMs}ms after enqueue — emitting synthetic stop and unblocking driver`,
4228
+ `[Yeaft] runVpTurn abort escalation: session=${sessionId || ''} vp=${vpId || ''} thread=${threadId || 'main'} turn=${turnId || ''} did not return ${ESCALATE_AFTER_ABORT_MS}ms after abort — emitting synthetic stop and unblocking driver`,
4103
4229
  );
4104
4230
  try {
4105
4231
  sendSessionOutputFrame(
@@ -4107,62 +4233,98 @@ async function runVpTurnWithEscalation(args) {
4107
4233
  { sessionId, vpId, turnId, threadId },
4108
4234
  );
4109
4235
  } catch { /* never crash WS pipeline */ }
4110
- // vp-status: when the watchdog escalates, `runVpTurn`'s inner
4111
- // promise is still dangling (the adapter is ignoring `signal`)
4112
- // and its outer `finally` won't run until the adapter eventually
4113
- // returns — which may be never. Settle the broker here so the
4114
- // row drops to idle in lockstep with the synthetic stop frame.
4115
- // This is the exact failure mode the watchdog exists for; not
4116
- // settling here would re-introduce the "stuck on streaming" bug
4117
- // the whole PR is meant to fix.
4236
+ if (ctx.CONFIG) {
4237
+ recordAgentPerfTrace(ctx.CONFIG, {
4238
+ traceId: turnId || `abort-${Date.now()}`,
4239
+ phase: 'vp.abort_escalation',
4240
+ sessionId,
4241
+ vpId,
4242
+ turnId,
4243
+ threadId: threadId || 'main',
4244
+ ok: false,
4245
+ detail: { graceMs: ESCALATE_AFTER_ABORT_MS },
4246
+ });
4247
+ }
4248
+ try {
4249
+ sendSessionEvent({
4250
+ type: 'vp_turn_end',
4251
+ sessionId,
4252
+ vpId,
4253
+ threadId: threadId || 'main',
4254
+ turnId,
4255
+ reason: 'aborted',
4256
+ detail: { reason: 'abort_escalation', graceMs: ESCALATE_AFTER_ABORT_MS },
4257
+ ts: Date.now(),
4258
+ }, { sessionId, vpId, threadId: threadId || 'main', turnId });
4259
+ } catch { /* never crash WS pipeline */ }
4118
4260
  try {
4119
4261
  getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
4120
4262
  } catch (err) {
4121
- console.warn('[Yeaft] vp-status settleIdle (escalation) failed:', err?.message || err);
4263
+ console.warn('[Yeaft] vp-status settleIdle (abort escalation) failed:', err?.message || err);
4264
+ }
4265
+ const staleEngine = escalationState.engine || null;
4266
+ const engineKey = escalationState.engineKey || threadKey(sessionId, vpId, threadId);
4267
+ if (staleEngine) {
4268
+ retireCachedVpEngine(engineKey, {
4269
+ reason: 'abort_escalation',
4270
+ rescue: true,
4271
+ expectedEngine: staleEngine,
4272
+ });
4273
+ }
4274
+ if (thread?.engine === staleEngine) thread.engine = null;
4275
+ if (thread) {
4276
+ thread.status = 'idle';
4277
+ thread.updatedAt = Date.now();
4122
4278
  }
4123
4279
  },
4124
4280
  });
4125
4281
  }
4126
4282
 
4127
4283
  /**
4128
- * Race `inner` against a deadline timer. If `inner` resolves/rejects first,
4129
- * the timer is cleared and the result of `inner` is returned. If the timer
4130
- * wins, `onEscalate` is called and the wrapper resolves cleanly — the inner
4131
- * promise is left dangling (JS has no promise cancellation) but the caller
4132
- * is unblocked.
4133
- *
4134
- * `onEscalate` MUST be synchronous. We swallow synchronous throws so a
4135
- * torn-down WS pipeline can't crash the watchdog, but a Promise rejection
4136
- * from an async `onEscalate` would leak past this `catch`.
4137
- *
4138
- * Pure helper, no module-level state, exported as `__testRaceWithEscalation`
4139
- * so the contract can be unit-tested in isolation. Inner errors propagate
4140
- * (a tool that throws still surfaces through `runVpTurn`'s normal catch).
4284
+ * Race `inner` against a grace timer that starts only after `signal` aborts.
4285
+ * Resolving or rejecting `inner` first removes the abort listener and timer.
4286
+ * If the grace timer wins, `onEscalate` is called synchronously and the
4287
+ * wrapper resolves; the underlying promise remains observed by
4288
+ * `Promise.race`, so a later rejection cannot become unhandled.
4141
4289
  *
4142
4290
  * @template T
4143
4291
  * @param {Promise<T>} inner
4144
- * @param {{ deadlineMs: number, onEscalate: () => void }} opts
4292
+ * @param {{ signal?: AbortSignal, graceMs: number, onEscalate: () => void }} opts
4145
4293
  * @returns {Promise<T|void>}
4146
4294
  */
4147
- async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
4148
- let escalateTimer = null;
4295
+ async function raceWithEscalation(inner, { signal, graceMs, onEscalate }) {
4296
+ let escalationTimer = null;
4297
+ let settled = false;
4298
+ let scheduleEscalation = null;
4299
+
4149
4300
  const escalation = new Promise((resolve) => {
4150
- escalateTimer = setTimeout(() => {
4151
- try { onEscalate(); } catch { /* never throw out of the watchdog */ }
4152
- resolve();
4153
- }, deadlineMs);
4154
- // `unref()` lets a pending escalation timer not hold the Node event
4155
- // loop open (e.g. during graceful shutdown). Browsers / non-Node
4156
- // runtimes don't expose it, hence the typeof guard. Node's own
4157
- // `Timeout.unref()` does not throw, so no try/catch is needed.
4158
- if (escalateTimer && typeof escalateTimer.unref === 'function') {
4159
- escalateTimer.unref();
4301
+ scheduleEscalation = () => {
4302
+ if (settled || escalationTimer) return;
4303
+ escalationTimer = setTimeout(() => {
4304
+ escalationTimer = null;
4305
+ try { onEscalate(); } catch { /* never throw out of the watchdog */ }
4306
+ resolve();
4307
+ }, Math.max(0, Number(graceMs) || 0));
4308
+ if (escalationTimer && typeof escalationTimer.unref === 'function') {
4309
+ escalationTimer.unref();
4310
+ }
4311
+ };
4312
+
4313
+ if (signal?.aborted) {
4314
+ scheduleEscalation();
4315
+ } else if (signal) {
4316
+ signal.addEventListener('abort', scheduleEscalation, { once: true });
4160
4317
  }
4161
4318
  });
4319
+
4162
4320
  try {
4163
4321
  return await Promise.race([inner, escalation]);
4164
4322
  } finally {
4165
- clearTimeout(escalateTimer);
4323
+ settled = true;
4324
+ if (escalationTimer) clearTimeout(escalationTimer);
4325
+ if (signal && scheduleEscalation) {
4326
+ signal.removeEventListener('abort', scheduleEscalation);
4327
+ }
4166
4328
  }
4167
4329
  }
4168
4330
 
@@ -4187,7 +4349,7 @@ async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
4187
4349
  *
4188
4350
  * @param {{ prompt: string, sessionId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
4189
4351
  */
4190
- async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId = 'main', thread = null, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
4352
+ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId = 'main', thread = null, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot, escalationState = null }) {
4191
4353
  if (!prompt?.trim()) return;
4192
4354
 
4193
4355
  const perfTraceId = typeof inboundEnvelope?._perfTraceId === 'string' && inboundEnvelope._perfTraceId.trim()
@@ -4220,11 +4382,22 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4220
4382
  let turnEndReason = 'end_turn';
4221
4383
  let turnEndEmitted = false;
4222
4384
  let turnEndDetail = null;
4385
+ let engineTerminalReason = null;
4386
+ let engineTerminalDetail = null;
4223
4387
  let handlerCtx = null;
4224
- const markTurnEnd = (reason) => { turnEndEmitted = true; turnEndReason = reason; };
4388
+ const markEngineTerminal = (reason, detail = null) => {
4389
+ engineTerminalReason = reason;
4390
+ if (detail) engineTerminalDetail = detail;
4391
+ };
4392
+ const markTurnEnd = (reason) => {
4393
+ turnEndEmitted = true;
4394
+ turnEndReason = reason;
4395
+ if (escalationState) escalationState.terminalEmitted = true;
4396
+ };
4225
4397
  const emitVpTurnEnd = (reason, detail = null) => {
4226
- if (turnEndEmitted) return;
4398
+ if (turnEndEmitted || escalationState?.terminalEmitted) return;
4227
4399
  turnEndEmitted = true;
4400
+ if (escalationState) escalationState.terminalEmitted = true;
4228
4401
  try {
4229
4402
  sendSessionEvent({
4230
4403
  type: 'vp_turn_end',
@@ -4241,6 +4414,15 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4241
4414
  console.warn('[Yeaft] vp_turn_end emit failed:', err?.message || err);
4242
4415
  }
4243
4416
  };
4417
+ const finishAbortedTurn = (detail = null) => {
4418
+ flushStreamTextBatch(handlerCtx, envelope, { resetImmediate: true });
4419
+ sendSessionOutputFrame({
4420
+ type: 'result',
4421
+ result_text: '',
4422
+ stopped: true,
4423
+ }, envelope);
4424
+ emitVpTurnEnd('aborted', detail);
4425
+ };
4244
4426
 
4245
4427
  try {
4246
4428
  if (session?.dreamScheduler) {
@@ -4249,8 +4431,12 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4249
4431
 
4250
4432
  let queryTimer = null;
4251
4433
  const queryTimeoutMs = queryTimeoutMsForSession(sessionId);
4252
- const resetQueryTimer = () => {
4434
+ const pauseQueryTimer = () => {
4253
4435
  if (queryTimer) clearTimeout(queryTimer);
4436
+ queryTimer = null;
4437
+ };
4438
+ const resetQueryTimer = () => {
4439
+ pauseQueryTimer();
4254
4440
  queryTimer = setTimeout(() => {
4255
4441
  if (!vpAbort.signal.aborted) {
4256
4442
  console.error(`[Yeaft] query timeout after ${queryTimeoutMs / 1000}s of silence — aborting VP ${vpId}`);
@@ -4296,6 +4482,10 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4296
4482
  const projectRuntime = getProjectRuntimeForTurn(turnSessionMeta);
4297
4483
 
4298
4484
  vpEngine = getOrCreateVpEngine(sessionId, vpId, threadId);
4485
+ if (escalationState) {
4486
+ escalationState.engine = vpEngine;
4487
+ escalationState.engineKey = threadKey(sessionId, vpId, threadId);
4488
+ }
4299
4489
  if (projectRuntime) {
4300
4490
  vpEngine.setRuntimeManagers?.({
4301
4491
  skillManager: projectRuntime.skillManager,
@@ -4318,6 +4508,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4318
4508
  toolResultsAccum,
4319
4509
  thinkingBlocksAccum,
4320
4510
  resetQueryTimer,
4511
+ pauseQueryTimer,
4321
4512
  sessionId,
4322
4513
  vpId,
4323
4514
  turnId,
@@ -4327,6 +4518,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4327
4518
  prompt,
4328
4519
  includeInitialPrompt: !inboundIsInternal,
4329
4520
  skipPartialHistory: false,
4521
+ markEngineTerminal,
4330
4522
  markTurnEnd,
4331
4523
  };
4332
4524
  // Always trim the snapshot before passing to engine.query. This is
@@ -4411,6 +4603,10 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4411
4603
  },
4412
4604
  ...queryOpts,
4413
4605
  })) {
4606
+ // An escalated turn is detached from the per-VP driver. Its stale
4607
+ // promise may still resume if a provider/tool ignored AbortSignal;
4608
+ // never let those late events mutate UI or runtime state.
4609
+ if (escalationState?.escalated) continue;
4414
4610
  if (perfTraceId && !firstEngineEvent) {
4415
4611
  firstEngineEvent = true;
4416
4612
  recordAgentPerfTrace(ctx.CONFIG, {
@@ -4439,6 +4635,13 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4439
4635
  });
4440
4636
  }
4441
4637
 
4638
+ if (escalationState?.escalated) return;
4639
+
4640
+ if (engineTerminalReason === 'aborted') {
4641
+ finishAbortedTurn(engineTerminalDetail);
4642
+ return;
4643
+ }
4644
+
4442
4645
  flushStreamTextBatch(handlerCtx, envelope, { resetImmediate: true });
4443
4646
 
4444
4647
  // Turn completed — atomically append this VP's output to shared history.
@@ -4466,31 +4669,28 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4466
4669
  });
4467
4670
  }
4468
4671
 
4469
- sendSessionOutputFrame({
4470
- type: 'assistant',
4471
- message: { content: [] },
4472
- }, envelope);
4473
- sendSessionOutputFrame({
4474
- type: 'result',
4475
- result_text: '',
4476
- }, envelope);
4477
- // Normal end-of-turn (no route_forward, no abort, no error). Emit
4478
- // the message-status terminal so the web client can flip the
4479
- // assistant message status from 'pending' 'completed'.
4480
- emitVpTurnEnd('end_turn');
4672
+ if (!escalationState?.escalated) {
4673
+ sendSessionOutputFrame({
4674
+ type: 'assistant',
4675
+ message: { content: [] },
4676
+ }, envelope);
4677
+ sendSessionOutputFrame({
4678
+ type: 'result',
4679
+ result_text: '',
4680
+ }, envelope);
4681
+ // Normal end-of-turn (no route_forward, no abort, no error). Emit
4682
+ // the message-status terminal so the web client can flip the
4683
+ // assistant message status from 'pending' → 'completed'.
4684
+ emitVpTurnEnd('end_turn');
4685
+ }
4481
4686
  } finally {
4482
4687
  if (queryTimer) clearTimeout(queryTimer);
4483
4688
  }
4484
4689
  } catch (err) {
4690
+ if (escalationState?.escalated) return;
4485
4691
  const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
4486
4692
  if (isAbort) {
4487
- flushStreamTextBatch(handlerCtx, envelope, { resetImmediate: true });
4488
- sendSessionOutputFrame({
4489
- type: 'result',
4490
- result_text: '',
4491
- stopped: true,
4492
- }, envelope);
4493
- emitVpTurnEnd('aborted');
4693
+ if (!escalationState?.escalated) finishAbortedTurn();
4494
4694
  return;
4495
4695
  }
4496
4696
 
@@ -4564,7 +4764,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4564
4764
  // starts, so the user can see something failed instead of a silent
4565
4765
  // green-state turn end. Wrapped in its own try so a broker bug
4566
4766
  // can't mask the original error.
4567
- if (turnEndReason !== 'errored') {
4767
+ if (!escalationState?.escalated && turnEndReason !== 'errored') {
4568
4768
  try {
4569
4769
  getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
4570
4770
  } catch (err) {
@@ -4581,11 +4781,11 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4581
4781
  // a live thread and route the new query as "related" — orphaning
4582
4782
  // the message in `pendingQueries` because no engine was running
4583
4783
  // to drain it. Always settle to 'idle' here.
4584
- if (thread) {
4784
+ if (!escalationState?.escalated && thread) {
4585
4785
  thread.status = 'idle';
4586
4786
  thread.updatedAt = Date.now();
4587
4787
  }
4588
- if (perfTraceId) {
4788
+ if (perfTraceId && !escalationState?.escalated) {
4589
4789
  recordAgentPerfTrace(ctx.CONFIG, {
4590
4790
  traceId: perfTraceId,
4591
4791
  phase: 'vp.turn_total',