@yeaft/webchat-agent 1.0.169 → 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/index.js +1 -1
- package/package.json +1 -1
- package/yeaft/engine.js +165 -32
- package/yeaft/web-bridge.js +347 -147
package/index.js
CHANGED
|
@@ -117,7 +117,7 @@ async function detectCapabilities() {
|
|
|
117
117
|
// agent build can speak plaintext WS frames. New servers see this and
|
|
118
118
|
// flip `agent.encryptOutbound = false`, stopping outbound encryption
|
|
119
119
|
// to this peer. Old servers ignore the unknown capability token.
|
|
120
|
-
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'work_center'];
|
|
120
|
+
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'work_center', 'session_history_search'];
|
|
121
121
|
if (process.platform === 'linux') capabilities.push('work_item_attachments');
|
|
122
122
|
const pty = await loadNodePty();
|
|
123
123
|
if (pty) capabilities.push('terminal');
|
package/package.json
CHANGED
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 {{
|
|
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
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
2920
|
-
//
|
|
2921
|
-
//
|
|
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({
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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 {{
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
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
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3176
|
-
//
|
|
3177
|
-
//
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
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
|
|
4201
|
+
* Wrap {@link runVpTurn} with a second-stage abort escalation.
|
|
4069
4202
|
*
|
|
4070
|
-
* The
|
|
4071
|
-
*
|
|
4072
|
-
*
|
|
4073
|
-
*
|
|
4074
|
-
*
|
|
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
|
-
*
|
|
4077
|
-
*
|
|
4078
|
-
*
|
|
4079
|
-
*
|
|
4080
|
-
*
|
|
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
|
|
4097
|
-
|
|
4098
|
-
await raceWithEscalation(runVpTurn(args), {
|
|
4099
|
-
|
|
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
|
|
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
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
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
|
|
4129
|
-
*
|
|
4130
|
-
* wins, `onEscalate` is called and the
|
|
4131
|
-
*
|
|
4132
|
-
*
|
|
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 {{
|
|
4292
|
+
* @param {{ signal?: AbortSignal, graceMs: number, onEscalate: () => void }} opts
|
|
4145
4293
|
* @returns {Promise<T|void>}
|
|
4146
4294
|
*/
|
|
4147
|
-
async function raceWithEscalation(inner, {
|
|
4148
|
-
let
|
|
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
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
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
|
-
|
|
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',
|