mixdog 0.9.39 → 0.9.40
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 +1 -1
- package/scripts/agent-terminal-reap-test.mjs +6 -6
- package/scripts/execution-resume-esc-integration-test.mjs +4 -2
- package/scripts/steering-drain-buckets-test.mjs +140 -5
- package/scripts/tui-transcript-perf-test.mjs +279 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
- package/src/runtime/shared/buffered-appender.mjs +13 -2
- package/src/session-runtime/config-helpers.mjs +6 -6
- package/src/session-runtime/lifecycle-api.mjs +4 -0
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/standalone/agent-tool.mjs +8 -1
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +9 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- package/src/tui/dist/index.mjs +354 -163
- package/src/tui/engine/agent-job-feed.mjs +76 -6
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine/tool-card-results.mjs +10 -5
- package/src/tui/engine/turn.mjs +96 -36
- package/src/tui/engine.mjs +136 -37
- package/src/tui/index.jsx +2 -2
|
@@ -59,11 +59,13 @@ export function createAgentJobFeed({
|
|
|
59
59
|
getPending,
|
|
60
60
|
agentStatusState,
|
|
61
61
|
displayedExecutionNotificationKeys,
|
|
62
|
+
itemIndexById,
|
|
62
63
|
pushNotice,
|
|
63
64
|
now = () => Date.now(),
|
|
64
65
|
executionResumeTombstoneTtlMs = 30_000,
|
|
65
66
|
executionResumeTombstoneLimit = 128,
|
|
66
67
|
}) {
|
|
68
|
+
const executionDedupLimit = 256;
|
|
67
69
|
let executionResumeKickDeferred = false;
|
|
68
70
|
// Completion keys explicitly abandoned by Esc. Tombstones are per-feed
|
|
69
71
|
// (therefore per TUI session), short-lived, and bounded: they catch a late
|
|
@@ -74,6 +76,60 @@ export function createAgentJobFeed({
|
|
|
74
76
|
// or has reached its final body. A preview must not permanently suppress the
|
|
75
77
|
// later body, while repeated body retries remain idempotent.
|
|
76
78
|
const displayedExecutionResponseStates = new Map();
|
|
79
|
+
const terminalExecutionNotificationKeys = new Set();
|
|
80
|
+
const terminalExecutionResponseKeys = new Set();
|
|
81
|
+
const executionNotificationKeys = new Map();
|
|
82
|
+
|
|
83
|
+
const clearExecutionDedupState = () => {
|
|
84
|
+
displayedExecutionNotificationKeys.clear();
|
|
85
|
+
displayedExecutionResponseStates.clear();
|
|
86
|
+
terminalExecutionNotificationKeys.clear();
|
|
87
|
+
terminalExecutionResponseKeys.clear();
|
|
88
|
+
executionNotificationKeys.clear();
|
|
89
|
+
};
|
|
90
|
+
const rememberDisplayedExecutionNotificationKey = (key, terminal = false, executionId = '') => {
|
|
91
|
+
if (!key) return;
|
|
92
|
+
displayedExecutionNotificationKeys.delete(key);
|
|
93
|
+
if (executionId) executionNotificationKeys.set(key, executionId);
|
|
94
|
+
if (terminal) terminalExecutionNotificationKeys.add(key);
|
|
95
|
+
else terminalExecutionNotificationKeys.delete(key);
|
|
96
|
+
while (displayedExecutionNotificationKeys.size >= executionDedupLimit) {
|
|
97
|
+
const oldestTerminal = [...displayedExecutionNotificationKeys]
|
|
98
|
+
.find((candidate) => terminalExecutionNotificationKeys.has(candidate));
|
|
99
|
+
if (oldestTerminal == null) break;
|
|
100
|
+
displayedExecutionNotificationKeys.delete(oldestTerminal);
|
|
101
|
+
terminalExecutionNotificationKeys.delete(oldestTerminal);
|
|
102
|
+
executionNotificationKeys.delete(oldestTerminal);
|
|
103
|
+
}
|
|
104
|
+
displayedExecutionNotificationKeys.add(key);
|
|
105
|
+
};
|
|
106
|
+
const promoteExecutionNotificationKeys = (executionId) => {
|
|
107
|
+
if (!executionId) return;
|
|
108
|
+
for (const [key, keyExecutionId] of executionNotificationKeys) {
|
|
109
|
+
if (keyExecutionId !== executionId || !displayedExecutionNotificationKeys.has(key)) continue;
|
|
110
|
+
terminalExecutionNotificationKeys.add(key);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const promoteExecutionDedupState = (executionId) => {
|
|
114
|
+
if (!executionId) return;
|
|
115
|
+
promoteExecutionNotificationKeys(executionId);
|
|
116
|
+
const responseState = displayedExecutionResponseStates.get(executionId);
|
|
117
|
+
if (responseState) rememberDisplayedExecutionResponseState(executionId, responseState, true);
|
|
118
|
+
};
|
|
119
|
+
const rememberDisplayedExecutionResponseState = (key, value, terminal = false) => {
|
|
120
|
+
if (!key) return;
|
|
121
|
+
displayedExecutionResponseStates.delete(key);
|
|
122
|
+
if (terminal) terminalExecutionResponseKeys.add(key);
|
|
123
|
+
else terminalExecutionResponseKeys.delete(key);
|
|
124
|
+
while (displayedExecutionResponseStates.size >= executionDedupLimit) {
|
|
125
|
+
const oldestTerminal = [...displayedExecutionResponseStates.keys()]
|
|
126
|
+
.find((candidate) => terminalExecutionResponseKeys.has(candidate));
|
|
127
|
+
if (oldestTerminal == null) break;
|
|
128
|
+
displayedExecutionResponseStates.delete(oldestTerminal);
|
|
129
|
+
terminalExecutionResponseKeys.delete(oldestTerminal);
|
|
130
|
+
}
|
|
131
|
+
displayedExecutionResponseStates.set(key, value);
|
|
132
|
+
};
|
|
77
133
|
|
|
78
134
|
// FIFO accumulation of model-visible bodies from completions that arrived
|
|
79
135
|
// while busy (or while a pending-resume entry was already queued). A single
|
|
@@ -182,7 +238,10 @@ export function createAgentJobFeed({
|
|
|
182
238
|
// jitter into one visible item update.
|
|
183
239
|
function buildAgentJobCardPatch(itemId, text, isError = false) {
|
|
184
240
|
const parsed = parseAgentJob(text);
|
|
185
|
-
const
|
|
241
|
+
const index = itemIndexById?.get(itemId);
|
|
242
|
+
const current = Number.isInteger(index) && getState().items[index]?.id === itemId
|
|
243
|
+
? getState().items[index]
|
|
244
|
+
: null;
|
|
186
245
|
const rawDisplayText = agentJobResultText(text, parsed) || String(text ?? '').trim();
|
|
187
246
|
const displayText = isError ? toolErrorDisplay(rawDisplayText, 'agent') : rawDisplayText;
|
|
188
247
|
return {
|
|
@@ -207,13 +266,17 @@ export function createAgentJobFeed({
|
|
|
207
266
|
|
|
208
267
|
function subscribeRuntimeNotifications() {
|
|
209
268
|
if (typeof runtime.onNotification !== 'function') return null;
|
|
210
|
-
|
|
269
|
+
const unsubscribe = runtime.onNotification((event) => {
|
|
211
270
|
if (getDisposed()) return;
|
|
212
271
|
const text = String(event?.content ?? event?.text ?? event ?? '').trim();
|
|
213
272
|
if (!text) return;
|
|
214
273
|
const parsed = parseAgentJob(text);
|
|
215
274
|
const notificationKey = notificationQueueKey(event, text, parsed);
|
|
216
275
|
const delivery = resolveTuiRuntimeNotificationDelivery(event, text);
|
|
276
|
+
const executionId = String(event?.meta?.execution_id || parsed?.taskId || '').trim();
|
|
277
|
+
const status = String(event?.meta?.status || parsed?.status || '').toLowerCase();
|
|
278
|
+
const terminalStatus = /^(completed|complete|done|success|succeeded|ok|failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
|
|
279
|
+
if (terminalStatus) promoteExecutionDedupState(executionId);
|
|
217
280
|
if (delivery.action === 'ignore') return;
|
|
218
281
|
if (delivery.action === 'notice') {
|
|
219
282
|
pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: delivery.transcript === true });
|
|
@@ -226,16 +289,19 @@ export function createAgentJobFeed({
|
|
|
226
289
|
if (delivery.action === 'execution-ui') {
|
|
227
290
|
const cardKey = executionCardKey(event, text, parsed);
|
|
228
291
|
const firstDelivery = !cardKey || !displayedExecutionNotificationKeys.has(cardKey);
|
|
229
|
-
const executionId = String(event?.meta?.execution_id || parsed?.taskId || '').trim();
|
|
230
|
-
const status = String(event?.meta?.status || parsed?.status || '').toLowerCase();
|
|
231
292
|
const hasBody = /\n\s*\n[\s\S]*\S/.test(text);
|
|
232
293
|
const isFailure = /^(failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
|
|
233
294
|
const successfulPreview = !hasBody && !isFailure && /^(completed|complete|done|success|succeeded|ok)$/.test(status);
|
|
295
|
+
const terminal = terminalStatus;
|
|
234
296
|
const responseState = executionId ? displayedExecutionResponseStates.get(executionId) : '';
|
|
235
297
|
const bodyAlreadyDisplayed = responseState === 'body';
|
|
298
|
+
if (cardKey && terminal && displayedExecutionNotificationKeys.has(cardKey)) {
|
|
299
|
+
rememberDisplayedExecutionNotificationKey(cardKey, true, executionId);
|
|
300
|
+
}
|
|
301
|
+
if (terminal) promoteExecutionDedupState(executionId);
|
|
236
302
|
if (firstDelivery && !successfulPreview && !bodyAlreadyDisplayed) {
|
|
237
|
-
if (cardKey)
|
|
238
|
-
if (executionId)
|
|
303
|
+
if (cardKey) rememberDisplayedExecutionNotificationKey(cardKey, terminal, executionId);
|
|
304
|
+
if (executionId) rememberDisplayedExecutionResponseState(executionId, hasBody ? 'body' : 'preview', terminal);
|
|
239
305
|
// Execution completions are inbound agent responses. The engine keeps
|
|
240
306
|
// their aggregation tail-safe (only an immediately-adjacent inbound
|
|
241
307
|
// response of the same preview/body phase can merge); the fallback
|
|
@@ -332,6 +398,9 @@ export function createAgentJobFeed({
|
|
|
332
398
|
enqueue(modelContent, enqueueOpts);
|
|
333
399
|
return true;
|
|
334
400
|
});
|
|
401
|
+
return () => {
|
|
402
|
+
try { unsubscribe?.(); } finally { clearExecutionDedupState(); }
|
|
403
|
+
};
|
|
335
404
|
}
|
|
336
405
|
|
|
337
406
|
return {
|
|
@@ -342,5 +411,6 @@ export function createAgentJobFeed({
|
|
|
342
411
|
updateAgentJobCard,
|
|
343
412
|
buildAgentJobCardPatch,
|
|
344
413
|
subscribeRuntimeNotifications,
|
|
414
|
+
clearExecutionDedupState,
|
|
345
415
|
};
|
|
346
416
|
}
|
|
@@ -24,7 +24,7 @@ export function createEngineApi(bag) {
|
|
|
24
24
|
|
|
25
25
|
export function createEngineApiA(bag) {
|
|
26
26
|
const {
|
|
27
|
-
runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick, discardExecutionPendingResume,
|
|
27
|
+
runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, settleStreamingTail, clearStreamingTail, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick, discardExecutionPendingResume,
|
|
28
28
|
} = bag;
|
|
29
29
|
return {
|
|
30
30
|
getState: () => getState(),
|
|
@@ -617,6 +617,12 @@ export function createEngineApiA(bag) {
|
|
|
617
617
|
if (flags.disposed) return;
|
|
618
618
|
if (!getState().busy) return; // normal abort settled
|
|
619
619
|
if (flags.leadTurnEpoch !== abortEpoch) return; // a newer turn owns the store
|
|
620
|
+
const streamingTail = getState().streamingTail;
|
|
621
|
+
if (streamingTail?.text?.trim()) {
|
|
622
|
+
settleStreamingTail?.(streamingTail.id, {});
|
|
623
|
+
} else {
|
|
624
|
+
clearStreamingTail?.();
|
|
625
|
+
}
|
|
620
626
|
// Bump the epoch FIRST so the still-stuck turn's eventual finally becomes
|
|
621
627
|
// a no-op for shared getState() writes and cannot corrupt the handoff.
|
|
622
628
|
flags.leadTurnEpoch = (Number(flags.leadTurnEpoch) || 0) + 1;
|
|
@@ -8,7 +8,7 @@ import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersi
|
|
|
8
8
|
|
|
9
9
|
export function createSessionFlow(bag) {
|
|
10
10
|
const {
|
|
11
|
-
runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
|
|
11
|
+
runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, clearExecutionDedupState, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
|
|
12
12
|
} = bag;
|
|
13
13
|
|
|
14
14
|
// Upper bound on the awaited compacting clear. requireCompactSuccess makes
|
|
@@ -116,6 +116,7 @@ export function createSessionFlow(bag) {
|
|
|
116
116
|
if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
|
|
117
117
|
batch.push(entry);
|
|
118
118
|
pending.splice(i, 1);
|
|
119
|
+
if (entry.mode === 'task-notification' && entry.key) pendingNotificationKeys.delete(entry.key);
|
|
119
120
|
if (batch.length >= limit) break;
|
|
120
121
|
} else {
|
|
121
122
|
i += 1;
|
|
@@ -498,6 +499,7 @@ export function createSessionFlow(bag) {
|
|
|
498
499
|
getState().busy = false;
|
|
499
500
|
pendingNotificationKeys.clear();
|
|
500
501
|
displayedExecutionNotificationKeys.clear();
|
|
502
|
+
clearExecutionDedupState?.();
|
|
501
503
|
};
|
|
502
504
|
// Post-success UI sync shared by the normal clear path and a late-fulfilling
|
|
503
505
|
// abandoned compacting clear, so the UI always matches the cleared runtime
|
|
@@ -34,7 +34,12 @@ export function createToolCardResults({
|
|
|
34
34
|
updateAgentJobCard,
|
|
35
35
|
buildAgentJobCardPatch,
|
|
36
36
|
agentStatusState,
|
|
37
|
+
itemIndexById,
|
|
37
38
|
}) {
|
|
39
|
+
const itemById = (id) => {
|
|
40
|
+
const index = itemIndexById?.get(id);
|
|
41
|
+
return Number.isInteger(index) ? getState().items[index]?.id === id ? getState().items[index] : null : null;
|
|
42
|
+
};
|
|
38
43
|
// A finalized/failed non-aggregate card must never carry an empty body:
|
|
39
44
|
// an empty-body error card is classified fully-failed-with-no-body upstream
|
|
40
45
|
// (transcript-tool-failures) and null-renders (card disappears). Stamp a
|
|
@@ -47,10 +52,10 @@ export function createToolCardResults({
|
|
|
47
52
|
return 'Failed';
|
|
48
53
|
}
|
|
49
54
|
function patchToolItem(id, patch) {
|
|
50
|
-
const prev =
|
|
55
|
+
const prev = itemById(id);
|
|
51
56
|
const ok = patchItem(id, patch);
|
|
52
57
|
if (!ok || !prev) return ok;
|
|
53
|
-
const next =
|
|
58
|
+
const next = itemById(id);
|
|
54
59
|
if (next && next !== prev) carryTranscriptMeasuredRowsCache(prev, next);
|
|
55
60
|
return ok;
|
|
56
61
|
}
|
|
@@ -107,7 +112,7 @@ export function createToolCardResults({
|
|
|
107
112
|
const detailText = errors > 0 || exitErrors > 0
|
|
108
113
|
? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
|
|
109
114
|
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
110
|
-
const currentItem =
|
|
115
|
+
const currentItem = itemById(card.itemId);
|
|
111
116
|
const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
|
|
112
117
|
const visualCompleted = Math.max(completed, earlyCompleted, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
|
|
113
118
|
const rawResult = aggregateRawResult(allCalls);
|
|
@@ -251,7 +256,7 @@ export function createToolCardResults({
|
|
|
251
256
|
// Cancelled aggregates MUST keep the [status: cancelled] marker on the
|
|
252
257
|
// result so terminalStatus parsing resolves to 'cancelled'. Only normal
|
|
253
258
|
// completions drop the summary; cancelled ones prepend the marker.
|
|
254
|
-
const currentItem =
|
|
259
|
+
const currentItem = itemById(card.itemId);
|
|
255
260
|
displayDetail = withCancelledResultMarker(displayDetail, currentItem);
|
|
256
261
|
}
|
|
257
262
|
patchToolItem(card.itemId, {
|
|
@@ -284,7 +289,7 @@ export function createToolCardResults({
|
|
|
284
289
|
resultText = finalizedErrorFallbackBody(resultText, exitRec?.text, exitRec?.exitCode);
|
|
285
290
|
}
|
|
286
291
|
if (cancelled) {
|
|
287
|
-
const currentItem =
|
|
292
|
+
const currentItem = itemById(card.itemId);
|
|
288
293
|
resultText = withCancelledResultMarker(resultText, currentItem);
|
|
289
294
|
}
|
|
290
295
|
patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, exitErrorCount: group.exitErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
|
package/src/tui/engine/turn.mjs
CHANGED
|
@@ -12,8 +12,25 @@ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, ass
|
|
|
12
12
|
|
|
13
13
|
export function createRunTurn(bag) {
|
|
14
14
|
const {
|
|
15
|
-
runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
|
|
15
|
+
runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
|
|
16
16
|
} = bag;
|
|
17
|
+
// Small fallbacks keep isolated createRunTurn harnesses source-compatible;
|
|
18
|
+
// the real engine supplies atomic implementations that also maintain revision.
|
|
19
|
+
const updateStreamingTail = updateStreamingTailFromStore || ((id, patch = {}) => {
|
|
20
|
+
set({ streamingTail: { ...(getState().streamingTail || {}), ...patch, kind: 'assistant', id, streaming: true } });
|
|
21
|
+
return true;
|
|
22
|
+
});
|
|
23
|
+
const settleStreamingTail = settleStreamingTailFromStore || ((id, patch = {}) => {
|
|
24
|
+
const tail = getState().streamingTail;
|
|
25
|
+
if (!tail || tail.id !== id) return false;
|
|
26
|
+
pushItem({ ...tail, ...patch, kind: 'assistant', id, streaming: false });
|
|
27
|
+
set({ streamingTail: null });
|
|
28
|
+
return true;
|
|
29
|
+
});
|
|
30
|
+
const clearStreamingTail = clearStreamingTailFromStore || ((id = null) => {
|
|
31
|
+
if (id == null || getState().streamingTail?.id === id) set({ streamingTail: null });
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
17
34
|
|
|
18
35
|
async function runTurn(userText, options = {}) {
|
|
19
36
|
const turnIndex = getState().stats.turns || 0;
|
|
@@ -63,22 +80,63 @@ export function createRunTurn(bag) {
|
|
|
63
80
|
let watchdogGraceTimer = null;
|
|
64
81
|
let lastProgressAt = startedAt;
|
|
65
82
|
let lastProgressLabel = 'start';
|
|
83
|
+
let watchdogDeferralCeilingAt = 0;
|
|
84
|
+
const configuredLeadToolMaxMs = Number(process.env.MIXDOG_LEAD_TOOL_MAX_MS);
|
|
85
|
+
const leadToolMaxMs = Number.isFinite(configuredLeadToolMaxMs) && configuredLeadToolMaxMs > 0
|
|
86
|
+
? configuredLeadToolMaxMs
|
|
87
|
+
: 30 * 60 * 1000;
|
|
66
88
|
const clearWatchdog = () => {
|
|
67
89
|
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
|
68
90
|
if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
|
|
69
91
|
};
|
|
92
|
+
const refreshWatchdogFromRuntimeLiveness = () => {
|
|
93
|
+
let liveness;
|
|
94
|
+
try { liveness = runtime.getTurnLiveness?.(); } catch { return false; }
|
|
95
|
+
if (liveness?.stage !== 'tool_running') watchdogDeferralCeilingAt = 0;
|
|
96
|
+
const progressAt = Number(liveness?.lastProgressAt);
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
if (!liveness || !Number.isFinite(progressAt) || progressAt <= now - LEAD_TURN_TIMEOUT_MS) return false;
|
|
99
|
+
|
|
100
|
+
if (liveness.stage === 'tool_running') {
|
|
101
|
+
watchdogDeferralCeilingAt = 0;
|
|
102
|
+
const toolStartedAt = Number(liveness.toolStartedAt);
|
|
103
|
+
if (!Number.isFinite(toolStartedAt) || toolStartedAt <= 0) return false;
|
|
104
|
+
const toolSelfDeadlineMs = Number(liveness.toolSelfDeadlineMs);
|
|
105
|
+
const toolCeilingMs = Math.max(
|
|
106
|
+
Number.isFinite(toolSelfDeadlineMs) && toolSelfDeadlineMs > 0 ? toolSelfDeadlineMs + 60_000 : 0,
|
|
107
|
+
leadToolMaxMs,
|
|
108
|
+
);
|
|
109
|
+
if (now - toolStartedAt >= toolCeilingMs) return false;
|
|
110
|
+
watchdogDeferralCeilingAt = toolStartedAt + toolCeilingMs;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
lastProgressAt = progressAt;
|
|
114
|
+
lastProgressLabel = `orchestrator:${String(liveness.stage || 'unknown')}`;
|
|
115
|
+
armWatchdog();
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
70
118
|
const armWatchdog = () => {
|
|
71
119
|
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
|
72
120
|
if (watchdogTripped) return;
|
|
73
|
-
const
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const remaining = Math.max(1, LEAD_TURN_TIMEOUT_MS - Math.max(0, now - lastProgressAt));
|
|
123
|
+
const ceilingRemaining = watchdogDeferralCeilingAt > 0
|
|
124
|
+
? Math.max(1, watchdogDeferralCeilingAt - now)
|
|
125
|
+
: Infinity;
|
|
126
|
+
const delay = Math.min(remaining, ceilingRemaining);
|
|
74
127
|
watchdogTimer = setTimeout(() => {
|
|
75
128
|
if (!isCurrentTurn()) return;
|
|
76
129
|
if (watchdogTripped) return;
|
|
77
|
-
const
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const idleMs = now - lastProgressAt;
|
|
78
132
|
if (idleMs < LEAD_TURN_TIMEOUT_MS) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
133
|
+
if (watchdogDeferralCeilingAt > 0 && now >= watchdogDeferralCeilingAt) {
|
|
134
|
+
if (refreshWatchdogFromRuntimeLiveness()) return;
|
|
135
|
+
} else {
|
|
136
|
+
armWatchdog();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
} else if (refreshWatchdogFromRuntimeLiveness()) return;
|
|
82
140
|
watchdogTripped = true;
|
|
83
141
|
if (_batchTimer !== null) {
|
|
84
142
|
clearTimeout(_batchTimer);
|
|
@@ -89,7 +147,7 @@ export function createRunTurn(bag) {
|
|
|
89
147
|
_pendingThinkingLastEndedAt = 0;
|
|
90
148
|
const elapsed = Date.now() - startedAt;
|
|
91
149
|
tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} idleMs=${idleMs} lastProgress=${lastProgressLabel} — aborting stuck turn`);
|
|
92
|
-
pushNotice(`Turn timed out after ${Math.round(idleMs / 1000)}s idle — aborting stuck request. Input will be released shortly if abort does not unwind.`, 'warn', { transcript: true });
|
|
150
|
+
pushNotice(`Turn timed out after ${Math.round(idleMs / 1000)}s idle (last progress: ${lastProgressLabel}) — aborting stuck request. Input will be released shortly if abort does not unwind.`, 'warn', { transcript: true });
|
|
93
151
|
try { runtime.abort('cli-react-abort-watchdog'); } catch {}
|
|
94
152
|
// Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
|
|
95
153
|
// (unwind starved), hard-release the turn after a short grace so the
|
|
@@ -105,6 +163,11 @@ export function createRunTurn(bag) {
|
|
|
105
163
|
finalizeToolHeaders();
|
|
106
164
|
clearDeferredTimers();
|
|
107
165
|
flags.flushDeferredBeforeImmediatePush = null;
|
|
166
|
+
if (currentAssistantId && currentAssistantText.trim()) {
|
|
167
|
+
settleStreamingTail(currentAssistantId, { text: currentAssistantText });
|
|
168
|
+
} else {
|
|
169
|
+
clearStreamingTail(currentAssistantId);
|
|
170
|
+
}
|
|
108
171
|
// Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
|
|
109
172
|
// a no-op for shared getState() and cannot corrupt the turn we hand off to.
|
|
110
173
|
flags.leadTurnEpoch++;
|
|
@@ -118,7 +181,7 @@ export function createRunTurn(bag) {
|
|
|
118
181
|
flushDeferredExecutionPendingResumeKick();
|
|
119
182
|
}, 5_000);
|
|
120
183
|
watchdogGraceTimer.unref?.();
|
|
121
|
-
},
|
|
184
|
+
}, delay);
|
|
122
185
|
watchdogTimer.unref?.();
|
|
123
186
|
};
|
|
124
187
|
const markTurnProgress = (label) => {
|
|
@@ -126,7 +189,6 @@ export function createRunTurn(bag) {
|
|
|
126
189
|
if (watchdogTripped) return;
|
|
127
190
|
lastProgressAt = Date.now();
|
|
128
191
|
lastProgressLabel = String(label || 'progress');
|
|
129
|
-
armWatchdog();
|
|
130
192
|
return true;
|
|
131
193
|
};
|
|
132
194
|
armWatchdog();
|
|
@@ -290,7 +352,7 @@ export function createRunTurn(bag) {
|
|
|
290
352
|
const it = newItems[i];
|
|
291
353
|
if (it?.id != null) itemIndexById.set(it.id, base + i);
|
|
292
354
|
}
|
|
293
|
-
set({ items, ...extra });
|
|
355
|
+
set({ items, structureRevision: (Number(getState().structureRevision) || 0) + 1, ...extra });
|
|
294
356
|
};
|
|
295
357
|
|
|
296
358
|
const markPromptCommitted = () => {
|
|
@@ -326,7 +388,7 @@ export function createRunTurn(bag) {
|
|
|
326
388
|
changed = true;
|
|
327
389
|
return { ...item, headerFinalized: true };
|
|
328
390
|
});
|
|
329
|
-
if (changed) set({ items });
|
|
391
|
+
if (changed) set({ items, structureRevision: (Number(getState().structureRevision) || 0) + 1 });
|
|
330
392
|
return changed;
|
|
331
393
|
};
|
|
332
394
|
|
|
@@ -450,7 +512,7 @@ export function createRunTurn(bag) {
|
|
|
450
512
|
// Seed the new row with the already-visible text so the ● gutter and the
|
|
451
513
|
// first body line appear in the SAME set()/emit() — no empty "●-only"
|
|
452
514
|
// row that scrolls once on its own and again when the body lands.
|
|
453
|
-
|
|
515
|
+
updateStreamingTail(currentAssistantId, { text: String(initialText || '') });
|
|
454
516
|
}
|
|
455
517
|
return currentAssistantId;
|
|
456
518
|
};
|
|
@@ -463,7 +525,6 @@ export function createRunTurn(bag) {
|
|
|
463
525
|
_lastNewlineIdx = -1;
|
|
464
526
|
_emittedNewlineIdx = -2;
|
|
465
527
|
_emittedVisibleText = '';
|
|
466
|
-
_cachedAssistantIndex = -1;
|
|
467
528
|
};
|
|
468
529
|
|
|
469
530
|
const commitAssistantSegment = ({ sealToolBlock = false } = {}) => {
|
|
@@ -474,7 +535,7 @@ export function createRunTurn(bag) {
|
|
|
474
535
|
}
|
|
475
536
|
if (sealToolBlock) clearAggregateContinuation();
|
|
476
537
|
const id = currentAssistantId || ensureAssistant(text);
|
|
477
|
-
|
|
538
|
+
settleStreamingTail(id, { text });
|
|
478
539
|
committedSegments.push(text);
|
|
479
540
|
closeAssistantSegment();
|
|
480
541
|
return true;
|
|
@@ -518,7 +579,6 @@ export function createRunTurn(bag) {
|
|
|
518
579
|
let _lastNewlineIdx = -1; // offset of the last completed-line '\n' found so far
|
|
519
580
|
let _emittedNewlineIdx = -2; // newline offset backing _emittedVisibleText (-2 forces first compute)
|
|
520
581
|
let _emittedVisibleText = ''; // cached visible slice for the current newline offset
|
|
521
|
-
let _cachedAssistantIndex = -1;// cached getState().items index of the streaming assistant row
|
|
522
582
|
// Engine-local streaming scalars. Neither responseLength nor thinkingText is
|
|
523
583
|
// rendered per-token by any consumer: App reads getState().thinking only as a
|
|
524
584
|
// boolean (App.jsx `!!(getState().thinking || liveSpinner?.thinking)`) and the
|
|
@@ -586,22 +646,9 @@ export function createRunTurn(bag) {
|
|
|
586
646
|
// until there is real content to paint.
|
|
587
647
|
if (currentAssistantId || streamingVisibleText.trim()) {
|
|
588
648
|
const id = ensureAssistant(streamingVisibleText);
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// added/removed) instead of a findIndex scan on every flush.
|
|
593
|
-
let index = _cachedAssistantIndex;
|
|
594
|
-
if (index < 0 || getState().items[index]?.id !== id) {
|
|
595
|
-
index = getState().items.findIndex((it) => it.id === id);
|
|
596
|
-
_cachedAssistantIndex = index;
|
|
597
|
-
}
|
|
598
|
-
if (index >= 0) {
|
|
599
|
-
const current = getState().items[index];
|
|
600
|
-
if (!Object.is(current.text, streamingVisibleText) || current.streaming !== true) {
|
|
601
|
-
const items = getState().items.slice();
|
|
602
|
-
items[index] = { ...current, text: streamingVisibleText, streaming: true };
|
|
603
|
-
patch.items = items;
|
|
604
|
-
}
|
|
649
|
+
const current = getState().streamingTail;
|
|
650
|
+
if (!current || current.id !== id || !Object.is(current.text, streamingVisibleText)) {
|
|
651
|
+
patch.streamingTail = { kind: 'assistant', id, text: streamingVisibleText, streaming: true };
|
|
605
652
|
}
|
|
606
653
|
}
|
|
607
654
|
// Only touch the spinner when there is a real reason: a visible-line
|
|
@@ -609,7 +656,7 @@ export function createRunTurn(bag) {
|
|
|
609
656
|
// pending thinking end timestamp. Refresh responseLength here so the
|
|
610
657
|
// finalize outputTokens fallback stays valid without a per-token push.
|
|
611
658
|
const responseLengthVal = assistantText.length + thinkingText.length;
|
|
612
|
-
const visibleLineChanged = patch.
|
|
659
|
+
const visibleLineChanged = patch.streamingTail !== undefined;
|
|
613
660
|
const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
|
|
614
661
|
if (getState().spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
|
|
615
662
|
patch.spinner = { ...getState().spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || getState().spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
|
|
@@ -699,7 +746,10 @@ export function createRunTurn(bag) {
|
|
|
699
746
|
const displayDetail = errors > 0 || exitErrors > 0
|
|
700
747
|
? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
|
|
701
748
|
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
702
|
-
const
|
|
749
|
+
const currentIndex = itemIndexById.get(card.itemId);
|
|
750
|
+
const currentItem = Number.isInteger(currentIndex) && getState().items[currentIndex]?.id === card.itemId
|
|
751
|
+
? getState().items[currentIndex]
|
|
752
|
+
: null;
|
|
703
753
|
const visualCompleted = Math.max(
|
|
704
754
|
completedCount,
|
|
705
755
|
Math.min(allCalls.length, Number(currentItem?.completedCount || 0)),
|
|
@@ -737,6 +787,9 @@ export function createRunTurn(bag) {
|
|
|
737
787
|
try {
|
|
738
788
|
const { result, session } = await runtime.ask(userText, {
|
|
739
789
|
drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
|
|
790
|
+
onStreamDelta: () => {
|
|
791
|
+
markTurnProgress('stream-delta');
|
|
792
|
+
},
|
|
740
793
|
onSteerMessage: (text) => {
|
|
741
794
|
if (!markTurnProgress('steer-message')) return;
|
|
742
795
|
// A suppressed live-completion twin is model-visible only; its
|
|
@@ -1087,10 +1140,10 @@ export function createRunTurn(bag) {
|
|
|
1087
1140
|
// the final provider text when it is available.
|
|
1088
1141
|
const id = currentAssistantId || ensureAssistant(finalRemainder);
|
|
1089
1142
|
currentAssistantText = finalRemainder;
|
|
1090
|
-
|
|
1143
|
+
settleStreamingTail(id, { text: finalRemainder });
|
|
1091
1144
|
} else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
|
|
1092
1145
|
const streamedText = currentAssistantText || assistantText;
|
|
1093
|
-
|
|
1146
|
+
settleStreamingTail(currentAssistantId, { text: streamedText });
|
|
1094
1147
|
}
|
|
1095
1148
|
turnFinishedNormally = true;
|
|
1096
1149
|
}
|
|
@@ -1103,7 +1156,7 @@ export function createRunTurn(bag) {
|
|
|
1103
1156
|
if (error?.name === 'SessionClosedError') {
|
|
1104
1157
|
cancelled = true;
|
|
1105
1158
|
if (assistantText.trim() && currentAssistantId) {
|
|
1106
|
-
|
|
1159
|
+
settleStreamingTail(currentAssistantId, { text: currentAssistantText || assistantText });
|
|
1107
1160
|
}
|
|
1108
1161
|
// Finalize pending tool cards so they don't stay "Running..." forever
|
|
1109
1162
|
// after cancellation. Without this, the spinner vanishes and TurnDone
|
|
@@ -1147,6 +1200,13 @@ export function createRunTurn(bag) {
|
|
|
1147
1200
|
if (isStaleUnwind) {
|
|
1148
1201
|
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared UI/state writes`);
|
|
1149
1202
|
} else {
|
|
1203
|
+
if (currentAssistantId && getState().streamingTail?.id === currentAssistantId) {
|
|
1204
|
+
if (currentAssistantText.trim()) {
|
|
1205
|
+
settleStreamingTail(currentAssistantId, { text: currentAssistantText });
|
|
1206
|
+
} else {
|
|
1207
|
+
clearStreamingTail(currentAssistantId);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1150
1210
|
const producedTranscriptItem =
|
|
1151
1211
|
getState().items.length + closingItems.length > itemsAtTurnStart;
|
|
1152
1212
|
const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
|