mixdog 0.9.53 → 0.9.55
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 +2 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/desktop-session-bridge-test.mjs +47 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/interrupted-turn-history-test.mjs +28 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/pending-completion-drop-test.mjs +160 -4
- package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
- package/scripts/process-lifecycle-test.mjs +18 -4
- package/scripts/prompt-input-parity-test.mjs +145 -0
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +91 -33
- package/scripts/provider-toolcall-test.mjs +97 -17
- package/scripts/streaming-tail-window-test.mjs +146 -0
- package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
- package/scripts/tui-runtime-load-bench.mjs +45 -0
- package/scripts/tui-store-frame-batch-test.mjs +99 -0
- package/scripts/tui-transcript-perf-test.mjs +367 -2
- package/scripts/write-backpressure-test.mjs +147 -0
- package/src/cli.mjs +5 -5
- package/src/lib/keychain-cjs.cjs +114 -25
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/memory/lib/trace-store.mjs +66 -4
- package/src/runtime/shared/buffered-appender.mjs +83 -4
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +166 -45
- package/src/runtime/shared/process-shutdown.mjs +2 -1
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/native-search.mjs +4 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +8 -3
- package/src/session-runtime/resource-api.mjs +2 -1
- package/src/session-runtime/runtime-core.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/warmup-schedulers.mjs +5 -1
- package/src/standalone/agent-tool.mjs +19 -1
- package/src/tui/App.jsx +10 -4
- package/src/tui/app/text-layout.mjs +9 -1
- package/src/tui/app/transcript-window.mjs +146 -60
- package/src/tui/app/use-mouse-input.mjs +16 -8
- package/src/tui/app/use-transcript-scroll.mjs +71 -19
- package/src/tui/app/use-transcript-window.mjs +21 -10
- package/src/tui/components/PromptInput.jsx +13 -6
- package/src/tui/dist/index.mjs +1094 -232
- package/src/tui/engine/context-state.mjs +31 -31
- package/src/tui/engine/frame-batched-store.mjs +75 -0
- package/src/tui/engine/session-api-ext.mjs +6 -1
- package/src/tui/engine/session-api.mjs +9 -3
- package/src/tui/engine/session-flow.mjs +54 -27
- package/src/tui/engine/turn.mjs +44 -3
- package/src/tui/engine.mjs +515 -36
- package/src/tui/input-editing.mjs +33 -13
- package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
- package/vendor/ink/build/ink.js +8 -16
|
@@ -103,8 +103,8 @@ const HANDSHAKE_MAX_ATTEMPTS = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT + 1;
|
|
|
103
103
|
const HANDSHAKE_BACKOFF_BASE_MS = 200;
|
|
104
104
|
const HANDSHAKE_BACKOFF_CAP_MS = 3200;
|
|
105
105
|
|
|
106
|
-
export function _classifyHandshakeError(err) {
|
|
107
|
-
return classifyHandshakeError(err);
|
|
106
|
+
export function _classifyHandshakeError(err, { retry429 = true } = {}) {
|
|
107
|
+
return classifyHandshakeError(err, { retry429 });
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|
|
@@ -518,6 +518,7 @@ export async function _acquireWithRetry({
|
|
|
518
518
|
_acquire = acquireWebSocket,
|
|
519
519
|
_sleepFn = _defaultSleep,
|
|
520
520
|
maxAttempts = HANDSHAKE_MAX_ATTEMPTS,
|
|
521
|
+
retry429 = true,
|
|
521
522
|
} = {}) {
|
|
522
523
|
let lastErr = null;
|
|
523
524
|
let lastClassifier = null;
|
|
@@ -538,7 +539,7 @@ export async function _acquireWithRetry({
|
|
|
538
539
|
return await _acquire({ auth, poolKey, cacheKey, codexHeaders, forceFresh, externalSignal });
|
|
539
540
|
} catch (err) {
|
|
540
541
|
lastErr = err;
|
|
541
|
-
const classifier = _classifyHandshakeError(err);
|
|
542
|
+
const classifier = _classifyHandshakeError(err, { retry429 });
|
|
542
543
|
lastClassifier = classifier;
|
|
543
544
|
// Permanent (or unknown → default-deny): stop immediately.
|
|
544
545
|
if (!classifier) {
|
|
@@ -762,6 +763,9 @@ export async function sendViaWebSocket({
|
|
|
762
763
|
});
|
|
763
764
|
} catch {}
|
|
764
765
|
};
|
|
766
|
+
// Only Codex OAuth follows retry_429:false. This shared transport also
|
|
767
|
+
// backs xAI, whose existing 429 handshake retry behavior must remain.
|
|
768
|
+
const retry429 = traceProvider !== 'openai-oauth';
|
|
765
769
|
|
|
766
770
|
for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
|
|
767
771
|
const handshakeStart = performance.now();
|
|
@@ -783,6 +787,7 @@ export async function sendViaWebSocket({
|
|
|
783
787
|
// No nested connect retry budget: the outer stream loop owns
|
|
784
788
|
// all retries for this logical request.
|
|
785
789
|
maxAttempts: 1,
|
|
790
|
+
retry429,
|
|
786
791
|
onRetry: (info) => {
|
|
787
792
|
handshakeRetries += 1;
|
|
788
793
|
sendSpan.handshakeRetries += 1;
|
|
@@ -801,7 +806,7 @@ export async function sendViaWebSocket({
|
|
|
801
806
|
// application error carrying the same HTTP status.
|
|
802
807
|
try { err.wsFailurePhase = 'handshake'; } catch {}
|
|
803
808
|
const classifier = err?.retryClassifier
|
|
804
|
-
|| _classifyHandshakeError(err)
|
|
809
|
+
|| _classifyHandshakeError(err, { retry429 })
|
|
805
810
|
|| (err?.code === 'EWSACQUIRETIMEOUT' ? 'acquire_timeout' : null);
|
|
806
811
|
const classifiers = [...handshakeRetryClassifiers];
|
|
807
812
|
if (classifier && !classifiers.includes(classifier)) classifiers.push(classifier);
|
|
@@ -224,11 +224,35 @@ export function retryAfterMsFromError(err) {
|
|
|
224
224
|
if (Number.isFinite(n) && n >= 0) return n
|
|
225
225
|
}
|
|
226
226
|
const retryAfter = _headerValue(headers, 'retry-after')
|
|
227
|
-
if (retryAfter
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
227
|
+
if (retryAfter != null && retryAfter !== '') {
|
|
228
|
+
const seconds = Number(retryAfter)
|
|
229
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000)
|
|
230
|
+
const dateMs = Date.parse(String(retryAfter))
|
|
231
|
+
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now())
|
|
232
|
+
}
|
|
233
|
+
// Google RPC RetryInfo encodes retryDelay as a protobuf Duration. Preserve
|
|
234
|
+
// the same precedence as Retry-After: a server-provided retry window means
|
|
235
|
+
// RESOURCE_EXHAUSTED is request-local, not a permanent quota refusal.
|
|
236
|
+
const detailLists = [
|
|
237
|
+
err?.details,
|
|
238
|
+
err?.error?.details,
|
|
239
|
+
err?.data?.error?.details,
|
|
240
|
+
]
|
|
241
|
+
for (const details of detailLists) {
|
|
242
|
+
if (!Array.isArray(details)) continue
|
|
243
|
+
for (const detail of details) {
|
|
244
|
+
const delay = detail?.retryDelay
|
|
245
|
+
if (typeof delay === 'string') {
|
|
246
|
+
const match = delay.trim().match(/^(\d+(?:\.\d+)?)s$/)
|
|
247
|
+
if (match) return Math.ceil(Number(match[1]) * 1000)
|
|
248
|
+
} else if (delay && typeof delay === 'object') {
|
|
249
|
+
const seconds = Number(delay.seconds || 0)
|
|
250
|
+
const nanos = Number(delay.nanos || 0)
|
|
251
|
+
const ms = seconds * 1000 + nanos / 1_000_000
|
|
252
|
+
if (Number.isFinite(ms) && ms >= 0) return Math.ceil(ms)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
232
256
|
return null
|
|
233
257
|
}
|
|
234
258
|
|
|
@@ -615,15 +639,16 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
|
|
|
615
639
|
|
|
616
640
|
// E) Handshake classifier (moved here from openai-oauth-ws). Default-deny:
|
|
617
641
|
// anything not recognized as transient returns null. HTTP 401 is reserved
|
|
618
|
-
// for auth recovery and 426 for immediate HTTPS fallback
|
|
619
|
-
//
|
|
620
|
-
|
|
642
|
+
// for auth recovery and 426 for immediate HTTPS fallback. The OpenAI OAuth
|
|
643
|
+
// caller opts out of 429 retries (Codex retry_429:false); all other callers
|
|
644
|
+
// retain the historical retryable UnexpectedStatus policy.
|
|
645
|
+
export function classifyHandshakeError(err, { retry429 = true } = {}) {
|
|
621
646
|
if (!err) return null
|
|
622
647
|
const code = err.code || ''
|
|
623
648
|
const msg = String(err.message || '')
|
|
624
649
|
const status = Number(err.httpStatus || 0)
|
|
625
650
|
|
|
626
|
-
if (status === 401 || status === 426) return null
|
|
651
|
+
if (status === 401 || status === 426 || (status === 429 && !retry429)) return null
|
|
627
652
|
if (status > 0) {
|
|
628
653
|
return `http_${status}`
|
|
629
654
|
}
|
|
@@ -729,6 +754,20 @@ export async function withRetry(fn, opts = {}) {
|
|
|
729
754
|
'x-should-retry',
|
|
730
755
|
)
|
|
731
756
|
if (String(shouldRetryHeader || '').toLowerCase() === 'false') throw caught
|
|
757
|
+
// Anthropic's non-standard positive override outranks ordinary status
|
|
758
|
+
// classification. Keep subscription OAuth 429 fail-fast ownership:
|
|
759
|
+
// retry429:false is the Max/Pro gate and must not wait for that window.
|
|
760
|
+
if (opts.provider === 'anthropic'
|
|
761
|
+
&& String(shouldRetryHeader || '').toLowerCase() === 'true'
|
|
762
|
+
&& !(status === 429 && opts.retry429 === false)) {
|
|
763
|
+
if (attempt === maxAttempts - 1) throw caught
|
|
764
|
+
const retryAfterMs = retryAfterMsFromError(caught)
|
|
765
|
+
if (retryAfterMs != null) {
|
|
766
|
+
nextDelayMs = Math.max(0, retryAfterMs)
|
|
767
|
+
nextDelayReason = 'retry-after'
|
|
768
|
+
}
|
|
769
|
+
continue
|
|
770
|
+
}
|
|
732
771
|
// Claude Code's optional model fallback fires on the third 529. This
|
|
733
772
|
// remains opt-in: providers pass fallbackModel only when the caller set
|
|
734
773
|
// one. The hard progress veto above must run first so fallback can never
|
|
@@ -740,15 +779,16 @@ export async function withRetry(fn, opts = {}) {
|
|
|
740
779
|
}
|
|
741
780
|
}
|
|
742
781
|
if (status === 429) {
|
|
782
|
+
if (opts.retry429 === false) throw caught
|
|
783
|
+
const ra = retryAfterMsFromError(caught)
|
|
743
784
|
// A deterministic quota refusal cannot recover by replaying the same
|
|
744
|
-
// request.
|
|
745
|
-
//
|
|
746
|
-
if (isPermanentQuotaError(caught)) throw caught
|
|
785
|
+
// request. An explicit server retry window outranks message-text quota
|
|
786
|
+
// heuristics: RESOURCE_EXHAUSTED + Retry-After/RetryInfo is transient.
|
|
787
|
+
if (ra == null && isPermanentQuotaError(caught)) throw caught
|
|
747
788
|
// Retry only this request. Admission concurrency is fixed and is never
|
|
748
789
|
// reduced by rate limits. Respect Retry-After when present; otherwise
|
|
749
790
|
// use the ordinary jittered backoff. Output/tool stamps above remain a
|
|
750
791
|
// hard replay boundary.
|
|
751
|
-
const ra = retryAfterMsFromError(caught)
|
|
752
792
|
if (attempt === maxAttempts - 1) throw caught
|
|
753
793
|
if (ra != null) {
|
|
754
794
|
nextDelayMs = Math.max(0, ra)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { createHash } from 'crypto';
|
|
7
7
|
import { getProvider } from '../../providers/registry.mjs';
|
|
8
8
|
import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
|
|
9
|
-
import { loadSession, saveSessionAsync } from '../store.mjs';
|
|
9
|
+
import { loadSession, saveSessionAsync, saveSessionAsyncDeferred } from '../store.mjs';
|
|
10
10
|
import { createAbortController } from '../../../../shared/abort-controller.mjs';
|
|
11
11
|
import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
|
|
12
12
|
import { appendAgentTrace } from '../../agent-trace.mjs';
|
|
@@ -23,7 +23,15 @@ import {
|
|
|
23
23
|
buildSessionStartBlock,
|
|
24
24
|
hasUserConversationMessage,
|
|
25
25
|
} from './prompt-utils.mjs';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
_mergePendingMessageEntries,
|
|
28
|
+
acknowledgePendingMessages,
|
|
29
|
+
finalizePendingMessageDelivery,
|
|
30
|
+
drainPendingMessages,
|
|
31
|
+
hydratePendingMessages,
|
|
32
|
+
recordPendingMessageDelivery,
|
|
33
|
+
releasePendingMessages,
|
|
34
|
+
} from './pending-messages.mjs';
|
|
27
35
|
import { persistIterationMetrics, applyAskTerminalUsageTotals } from './usage-metrics.mjs';
|
|
28
36
|
import {
|
|
29
37
|
updateSessionStage,
|
|
@@ -102,6 +110,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
102
110
|
process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
|
|
103
111
|
}
|
|
104
112
|
const unlock = await acquireSessionLock(sessionId);
|
|
113
|
+
// Start crash-spool hydration without delaying the user turn. A completed
|
|
114
|
+
// background hydration joins the id-deduped memory drain below.
|
|
115
|
+
const takeoverHydration = hydratePendingMessages(sessionId);
|
|
105
116
|
const _lockWaitedMs = Date.now() - _askStartedAt;
|
|
106
117
|
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
107
118
|
process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
|
|
@@ -126,11 +137,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
126
137
|
// preserved and the spawn/connecting startup race disappears.
|
|
127
138
|
for (;;) {
|
|
128
139
|
let _pwstTurnDrained = null;
|
|
140
|
+
let _turnPendingEntries = [];
|
|
129
141
|
// After the first turn, the next prompt comes from the drained queue.
|
|
130
142
|
// (On the first iteration _pendingTail is empty and `prompt` is the
|
|
131
143
|
// caller's original message.)
|
|
132
144
|
if (_pendingTail.length > 0) {
|
|
133
|
-
|
|
145
|
+
const pendingTurn = _pendingTail.shift();
|
|
146
|
+
prompt = pendingTurn.content;
|
|
147
|
+
_turnPendingEntries = pendingTurn.entries;
|
|
134
148
|
// Queued follow-ups are plain user turns — no caller context /
|
|
135
149
|
// prefetch is re-applied (those belonged to the original ask).
|
|
136
150
|
context = null;
|
|
@@ -144,12 +158,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
144
158
|
const _mergedPre = _mergePendingMessageEntries(_preDrained);
|
|
145
159
|
if (_mergedPre?.content) {
|
|
146
160
|
prompt = _mergedPre.content;
|
|
161
|
+
_turnPendingEntries = _preDrained;
|
|
147
162
|
context = null;
|
|
148
163
|
explicitPrefetch = null;
|
|
149
164
|
}
|
|
150
165
|
}
|
|
151
166
|
}
|
|
152
167
|
if (!hasModelVisiblePromptContent(prompt)) {
|
|
168
|
+
void takeoverHydration.then((count) => {
|
|
169
|
+
if (count <= 0) return;
|
|
170
|
+
setImmediate(() => {
|
|
171
|
+
askSession(sessionId, '', null, onToolCall, cwdOverride, null, askOpts).catch(() => {});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
153
174
|
_unlinkParentAbortListener(_getRuntimeEntry(sessionId));
|
|
154
175
|
return _result;
|
|
155
176
|
}
|
|
@@ -334,6 +355,22 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
334
355
|
_turnInterruption.recordTextDelta(chunk);
|
|
335
356
|
if (typeof askOpts?.onTextDelta === 'function') askOpts.onTextDelta(chunk);
|
|
336
357
|
};
|
|
358
|
+
const _trackTextReset = async (detail) => {
|
|
359
|
+
// Replacement is opt-in and transactional: a delta-only
|
|
360
|
+
// consumer cannot retract already exposed bytes, so absence,
|
|
361
|
+
// false, or rejection must preserve the original partial and
|
|
362
|
+
// force the provider's terminal no-replay behavior.
|
|
363
|
+
if (typeof askOpts?.onTextReset !== 'function') return false;
|
|
364
|
+
let acknowledged = false;
|
|
365
|
+
try {
|
|
366
|
+
acknowledged = await askOpts.onTextReset(detail) === true;
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
if (!acknowledged) return false;
|
|
371
|
+
_turnInterruption.tombstoneText(detail?.chars);
|
|
372
|
+
return true;
|
|
373
|
+
};
|
|
337
374
|
const _trackReasoningDelta = (chunk) => {
|
|
338
375
|
_turnInterruption.recordReasoningDelta(chunk);
|
|
339
376
|
if (typeof askOpts?.onReasoningDelta === 'function') askOpts.onReasoningDelta(chunk);
|
|
@@ -363,6 +400,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
363
400
|
fast: session.fast === true,
|
|
364
401
|
sessionId,
|
|
365
402
|
onTextDelta: _trackTextDelta,
|
|
403
|
+
onTextReset: _trackTextReset,
|
|
366
404
|
onReasoningDelta: _trackReasoningDelta,
|
|
367
405
|
onAssistantText: _trackAssistantText,
|
|
368
406
|
onAssistantMessageCommitted: () => _turnInterruption.markAssistantMessageCommitted(),
|
|
@@ -625,8 +663,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
625
663
|
trimmed: messagesDropped > 0,
|
|
626
664
|
messagesDropped,
|
|
627
665
|
};
|
|
666
|
+
// The provider accepted this queued turn: only now remove its
|
|
667
|
+
// durable ids. A crash before here leaves them for at-least-once
|
|
668
|
+
// replay; duplicate memory/spool copies share the same id.
|
|
669
|
+
recordPendingMessageDelivery(session, _turnPendingEntries);
|
|
628
670
|
_pwstTurnDrained = drainPendingMessages(sessionId);
|
|
671
|
+
let terminalRelayed = false;
|
|
629
672
|
if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
|
|
673
|
+
terminalRelayed = true;
|
|
630
674
|
try {
|
|
631
675
|
askOpts.onTerminalResult(terminalResultPreview, {
|
|
632
676
|
sessionId,
|
|
@@ -640,16 +684,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
640
684
|
// answer. This lets queued follow-up prompts resume immediately;
|
|
641
685
|
// if they need compaction, their own spinner shows compacting first.
|
|
642
686
|
// Fire-and-forget terminal save. The result is already produced and
|
|
643
|
-
// (for agent surfaces) relayed via onTerminalResult above
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
687
|
+
// (for agent surfaces) relayed via onTerminalResult above. When
|
|
688
|
+
// completion was relayed to the UI, yield before postMessage
|
|
689
|
+
// structured-clones the full session. Queued follow-up turns retain
|
|
690
|
+
// the original immediate-save ordering; they did not inject a
|
|
691
|
+
// terminal card and may mutate this same session on the next loop.
|
|
692
|
+
const saveTerminalSession = terminalRelayed
|
|
693
|
+
? saveSessionAsyncDeferred
|
|
694
|
+
: saveSessionAsync;
|
|
695
|
+
const terminalSave = saveTerminalSession(session, { expectedGeneration: askGeneration });
|
|
696
|
+
finalizePendingMessageDelivery(
|
|
697
|
+
session,
|
|
698
|
+
_turnPendingEntries,
|
|
699
|
+
terminalSave,
|
|
700
|
+
() => saveSessionAsync(session, { expectedGeneration: askGeneration }),
|
|
701
|
+
).catch((err) => {
|
|
702
|
+
try { process.stderr.write(`[session] terminal save failed: ${err?.message || err}\n`); } catch {}
|
|
652
703
|
});
|
|
704
|
+
_turnPendingEntries = [];
|
|
653
705
|
activeSession = session;
|
|
654
706
|
runtime.session = session;
|
|
655
707
|
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
@@ -666,6 +718,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
666
718
|
// live-turn alias so contextStatus() stops estimating from the
|
|
667
719
|
// stale in-flight array once the turn unwinds.
|
|
668
720
|
if (activeSession) activeSession.liveTurnMessages = null;
|
|
721
|
+
// Restore before ANY finalization path. In particular, cancellation
|
|
722
|
+
// can race the acknowledged non-streaming restart and surface as a
|
|
723
|
+
// SessionClosedError; its interruption snapshot must include the
|
|
724
|
+
// one partial response that was already exposed.
|
|
725
|
+
const restoredResetText = _turnInterruption.restoreTombstonedText();
|
|
669
726
|
if (err instanceof SessionClosedError) {
|
|
670
727
|
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
671
728
|
if (!currentRuntime?.closed) {
|
|
@@ -677,8 +734,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
677
734
|
});
|
|
678
735
|
activeSession.messages = finalized.messages;
|
|
679
736
|
if (!finalized.responsePreserved) {
|
|
737
|
+
releasePendingMessages(sessionId, _turnPendingEntries);
|
|
680
738
|
activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
|
|
681
739
|
} else {
|
|
740
|
+
recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
682
741
|
// The opaque provider continuation now points at a
|
|
683
742
|
// request that ended mid-turn. Force full transcript
|
|
684
743
|
// replay on the next send instead of reusing it.
|
|
@@ -687,23 +746,77 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
687
746
|
activeSession.updatedAt = Date.now();
|
|
688
747
|
activeSession.lastUsedAt = Date.now();
|
|
689
748
|
try {
|
|
690
|
-
|
|
749
|
+
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
750
|
+
if (finalized.responsePreserved) {
|
|
751
|
+
await finalizePendingMessageDelivery(
|
|
752
|
+
activeSession, _turnPendingEntries, durableSave,
|
|
753
|
+
() => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
|
|
754
|
+
);
|
|
755
|
+
} else {
|
|
756
|
+
await durableSave;
|
|
757
|
+
}
|
|
691
758
|
} catch { /* cancellation cleanup is best-effort */ }
|
|
692
759
|
if (currentRuntime) currentRuntime.session = activeSession;
|
|
693
|
-
}
|
|
760
|
+
} else releasePendingMessages(sessionId, _turnPendingEntries);
|
|
694
761
|
markSessionCancelled(sessionId);
|
|
695
|
-
}
|
|
762
|
+
} else releasePendingMessages(sessionId, _turnPendingEntries);
|
|
696
763
|
// Cancellation is not an error; propagate silently so callers
|
|
697
764
|
// can render it as "cancelled" rather than a red failure.
|
|
698
765
|
throw err;
|
|
699
766
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
767
|
+
// A reset acknowledgement removes the live partial before the
|
|
768
|
+
// non-streaming request starts. If that restart fails, restore the
|
|
769
|
+
// tombstone and persist the one exposed partial as interruption
|
|
770
|
+
// history. Failed/absent acknowledgements never tombstoned it.
|
|
771
|
+
const preserveProviderPartial = restoredResetText
|
|
772
|
+
|| err?.liveTextEmitted === true
|
|
773
|
+
|| err?.unsafeToRetry === true;
|
|
774
|
+
if (preserveProviderPartial && activeSession && _turnInterruption.hasResponseStarted()) {
|
|
775
|
+
const finalized = _turnInterruption.finalize({
|
|
776
|
+
turnOutgoing: _turnOutgoing || activeSession.messages,
|
|
777
|
+
currentUserContent: cancelledUserTurnContent,
|
|
778
|
+
abortReason: 'provider-error',
|
|
779
|
+
});
|
|
780
|
+
activeSession.messages = finalized.messages;
|
|
781
|
+
if (finalized.responsePreserved) recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
782
|
+
else releasePendingMessages(sessionId, _turnPendingEntries);
|
|
783
|
+
activeSession.providerState = undefined;
|
|
784
|
+
activeSession.updatedAt = Date.now();
|
|
785
|
+
activeSession.lastUsedAt = Date.now();
|
|
786
|
+
try {
|
|
787
|
+
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
788
|
+
if (finalized.responsePreserved) {
|
|
789
|
+
await finalizePendingMessageDelivery(
|
|
790
|
+
activeSession, _turnPendingEntries, durableSave,
|
|
791
|
+
() => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
|
|
792
|
+
);
|
|
793
|
+
} else {
|
|
794
|
+
await durableSave;
|
|
795
|
+
}
|
|
796
|
+
} catch { /* provider-failure history persistence is best-effort */ }
|
|
797
|
+
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
798
|
+
if (currentRuntime) currentRuntime.session = activeSession;
|
|
799
|
+
} else {
|
|
800
|
+
const promptPersisted = await persistCompactedOutgoingAfterAskFailure({
|
|
801
|
+
sessionId,
|
|
802
|
+
activeSession,
|
|
803
|
+
askGeneration,
|
|
804
|
+
turnOutgoing: _turnOutgoing,
|
|
805
|
+
error: err,
|
|
806
|
+
});
|
|
807
|
+
if (promptPersisted) {
|
|
808
|
+
recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
809
|
+
try {
|
|
810
|
+
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
811
|
+
await finalizePendingMessageDelivery(
|
|
812
|
+
activeSession, _turnPendingEntries, durableSave,
|
|
813
|
+
() => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
|
|
814
|
+
);
|
|
815
|
+
} catch {}
|
|
816
|
+
} else {
|
|
817
|
+
releasePendingMessages(sessionId, _turnPendingEntries);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
707
820
|
markSessionError(sessionId, err && err.message ? err.message : String(err));
|
|
708
821
|
throw err;
|
|
709
822
|
}
|
|
@@ -724,7 +837,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
724
837
|
// drain enqueues for the next loop pass and is merged there.
|
|
725
838
|
const _mergedTail = _mergePendingMessageEntries(_drained);
|
|
726
839
|
if (_mergedTail?.content) {
|
|
727
|
-
_pendingTail.push(_mergedTail.content);
|
|
840
|
+
_pendingTail.push({ content: _mergedTail.content, entries: _drained });
|
|
728
841
|
// Carry the just-committed in-memory session into the follow-up
|
|
729
842
|
// turn so the queued tail sees the preceding assistant/tool
|
|
730
843
|
// context. loadSession() would return this same live snapshot
|
|
@@ -736,6 +849,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
736
849
|
}
|
|
737
850
|
}
|
|
738
851
|
_unlinkParentAbortListener(_getRuntimeEntry(sessionId));
|
|
852
|
+
// Pick up cross-process sends that landed after takeover hydration.
|
|
853
|
+
// This never delays completion; a non-empty sweep starts an empty
|
|
854
|
+
// follow-up ask after the current session lock is released.
|
|
855
|
+
setImmediate(() => {
|
|
856
|
+
hydratePendingMessages(sessionId).then((count) => {
|
|
857
|
+
if (count <= 0) return;
|
|
858
|
+
askSession(sessionId, '', null, onToolCall, cwdOverride, null, askOpts).catch(() => {});
|
|
859
|
+
}).catch(() => {});
|
|
860
|
+
});
|
|
739
861
|
return _result;
|
|
740
862
|
}
|
|
741
863
|
} finally {
|
|
@@ -55,6 +55,20 @@ function sessionMessagesAdvancedBeyondCompactedOutgoing(currentSanitized, compac
|
|
|
55
55
|
return isCompactedOutgoingFinalAssistantMessage(currentSanitized[currentSanitized.length - 1]);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function messageSnapshotPresent(messages, wanted) {
|
|
59
|
+
if (!wanted || !Array.isArray(messages)) return false;
|
|
60
|
+
return messages.some((message) => {
|
|
61
|
+
if (message === wanted) return true;
|
|
62
|
+
try { return JSON.stringify(message) === JSON.stringify(wanted); } catch { return false; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function compactedOutgoingPromptRetained(activeMessages, outgoingMessages) {
|
|
67
|
+
const outgoing = filterModelVisibleSessionMessages(outgoingMessages);
|
|
68
|
+
const prompt = [...outgoing].reverse().find((message) => message?.role === 'user');
|
|
69
|
+
return Boolean(prompt) && messageSnapshotPresent(filterModelVisibleSessionMessages(activeMessages), prompt);
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
export const _sessionMessagesAdvancedBeyondCompactedOutgoing = sessionMessagesAdvancedBeyondCompactedOutgoing;
|
|
59
73
|
|
|
60
74
|
function applyCompactFailurePersistToSession(activeSession, {
|
|
@@ -100,24 +114,26 @@ export async function persistCompactedOutgoingAfterAskFailure({
|
|
|
100
114
|
turnOutgoing,
|
|
101
115
|
error = null,
|
|
102
116
|
}) {
|
|
103
|
-
if (!activeSession || activeSession.closed === true) return;
|
|
104
|
-
if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
|
|
117
|
+
if (!activeSession || activeSession.closed === true) return false;
|
|
118
|
+
if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return false;
|
|
105
119
|
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
106
|
-
if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
|
|
120
|
+
if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return false;
|
|
107
121
|
const sanitized = filterModelVisibleSessionMessages(turnOutgoing);
|
|
108
122
|
const priorSanitized = filterModelVisibleSessionMessages(
|
|
109
123
|
Array.isArray(activeSession.messages) ? activeSession.messages : [],
|
|
110
124
|
);
|
|
111
125
|
const messagesAdvanced = sessionMessagesAdvancedBeyondCompactedOutgoing(priorSanitized, sanitized);
|
|
126
|
+
const promptRetainedBefore = compactedOutgoingPromptRetained(priorSanitized, sanitized);
|
|
112
127
|
const applied = applyCompactFailurePersistToSession(activeSession, {
|
|
113
128
|
priorSanitized,
|
|
114
129
|
sanitized,
|
|
115
130
|
messagesAdvanced,
|
|
116
131
|
error,
|
|
117
132
|
});
|
|
118
|
-
if (!applied) return;
|
|
133
|
+
if (!applied) return promptRetainedBefore;
|
|
119
134
|
try {
|
|
120
135
|
await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
121
136
|
} catch { /* best-effort: preserve in-memory compaction even if disk is slow */ }
|
|
122
137
|
if (currentRuntime) currentRuntime.session = activeSession;
|
|
138
|
+
return compactedOutgoingPromptRetained(activeSession.messages, sanitized);
|
|
123
139
|
}
|