mixdog 0.9.12 → 0.9.14
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/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +11 -7
- package/src/defaults/cycle3-review-prompt.md +14 -0
- package/src/defaults/memory-promote-prompt.md +9 -9
- package/src/lib/rules-builder.cjs +15 -11
- package/src/mixdog-session-runtime.mjs +10 -0
- package/src/rules/agent/00-common.md +5 -17
- package/src/rules/agent/00-core.md +21 -0
- package/src/rules/lead/lead-brief.md +7 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
- package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
- package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
- package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
- package/src/runtime/agent/orchestrator/session/manager.mjs +30 -15
- package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
- package/src/runtime/channels/index.mjs +28 -1
- package/src/runtime/channels/lib/memory-client.mjs +200 -15
- package/src/runtime/memory/index.mjs +18 -13
- package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
- package/src/runtime/memory/lib/cycle-scheduler.mjs +52 -18
- package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +37 -25
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
- package/src/runtime/memory/lib/memory-embed.mjs +35 -1
- package/src/runtime/memory/lib/memory.mjs +7 -1
- package/src/runtime/memory/lib/query-handlers.mjs +1 -0
- package/src/standalone/agent-tool.mjs +89 -7
- package/src/tui/dist/index.mjs +203 -10
- package/src/tui/engine/tui-steering-persist.mjs +175 -0
- package/src/tui/engine.mjs +46 -2
- package/src/ui/statusline.mjs +2 -4
- package/src/workflows/default/WORKFLOW.md +2 -0
- package/src/workflows/sequential/WORKFLOW.md +2 -0
- package/src/workflows/solo/WORKFLOW.md +2 -0
|
@@ -39,6 +39,30 @@ export function geminiTruncatedStreamError(message) {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// CC-rule safety stamp for Gemini stream failures (provider-stall audit):
|
|
43
|
+
// once text has been relayed to the live gateway or a leaked tool call was
|
|
44
|
+
// dispatched, replaying the request would double-render/double-execute — the
|
|
45
|
+
// outer withRetry() in gemini.mjs wraps the WHOLE stream, and a bare
|
|
46
|
+
// EGEMINITIMEOUT classifies transient, so without these markers a mid-stream
|
|
47
|
+
// stall after visible output was silently retried. Visible-text stalls also
|
|
48
|
+
// gain streamStalled + partialContent so the loop's partial-final path can
|
|
49
|
+
// keep the streamed output instead of dropping the turn.
|
|
50
|
+
export function stampGeminiStreamFailure(err, { relayedText = '', textLeakGuard = null } = {}) {
|
|
51
|
+
if (!err || typeof err !== 'object') return err;
|
|
52
|
+
const leaked = (textLeakGuard?.getLeakedToolCalls?.() || []).length > 0;
|
|
53
|
+
const visible = relayedText.length > 0;
|
|
54
|
+
try {
|
|
55
|
+
if (visible) { err.liveTextEmitted = true; err.unsafeToRetry = true; }
|
|
56
|
+
if (leaked) { err.emittedToolCall = true; err.unsafeToRetry = true; }
|
|
57
|
+
if (visible && !leaked && err.code === 'EGEMINITIMEOUT') {
|
|
58
|
+
err.streamStalled = true;
|
|
59
|
+
if (typeof err.partialContent !== 'string') err.partialContent = relayedText;
|
|
60
|
+
if (err.pendingToolUse === undefined) err.pendingToolUse = false;
|
|
61
|
+
}
|
|
62
|
+
} catch { /* best-effort */ }
|
|
63
|
+
return err;
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
/**
|
|
43
67
|
* Aggregate streamed GenerateContentResponse chunks into one response object
|
|
44
68
|
* (same shape as a non-streaming generateContent JSON body).
|
|
@@ -220,6 +244,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
220
244
|
let idleTimedOut = false;
|
|
221
245
|
let idleTimer = null;
|
|
222
246
|
let idleReject = null;
|
|
247
|
+
let relayedText = '';
|
|
223
248
|
|
|
224
249
|
let firstByteTimer = setTimeout(() => {
|
|
225
250
|
try { reader.cancel('first byte timeout'); } catch {}
|
|
@@ -309,6 +334,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
309
334
|
try { onStreamDelta?.(); } catch {}
|
|
310
335
|
if (onTextDelta || textLeakGuard) {
|
|
311
336
|
const t = geminiChunkText(parsed);
|
|
337
|
+
if (t) relayedText += t;
|
|
312
338
|
relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
|
|
313
339
|
}
|
|
314
340
|
}
|
|
@@ -328,12 +354,15 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
328
354
|
try { onStreamDelta?.(); } catch {}
|
|
329
355
|
if (onTextDelta || textLeakGuard) {
|
|
330
356
|
const t = geminiChunkText(parsed);
|
|
357
|
+
if (t) relayedText += t;
|
|
331
358
|
relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
|
|
332
359
|
}
|
|
333
360
|
} catch { /* skip malformed tail */ }
|
|
334
361
|
}
|
|
335
362
|
}
|
|
336
363
|
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
337
366
|
} finally {
|
|
338
367
|
clearFirstByteTimer();
|
|
339
368
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -344,7 +373,15 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
344
373
|
|
|
345
374
|
const aggregated = aggregateGeminiStreamChunks(allChunks);
|
|
346
375
|
const finishReason = aggregated.candidates?.[0]?.finishReason || null;
|
|
347
|
-
|
|
376
|
+
// Truncation (no finishReason) after visible output must carry the same
|
|
377
|
+
// safety stamps as an in-loop failure — assert throws OUTSIDE the catch
|
|
378
|
+
// above, so stamp here too (review High: transient TRUNCATED_STREAM would
|
|
379
|
+
// otherwise replay/double-render live text via withRetry).
|
|
380
|
+
try {
|
|
381
|
+
assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
|
|
382
|
+
} catch (err) {
|
|
383
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
384
|
+
}
|
|
348
385
|
return aggregated;
|
|
349
386
|
}
|
|
350
387
|
|
|
@@ -355,6 +392,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
355
392
|
let firstByteReject = null;
|
|
356
393
|
let firstByteTimer = null;
|
|
357
394
|
let inFlightReject = null;
|
|
395
|
+
let relayedText = '';
|
|
358
396
|
|
|
359
397
|
const armFirstByteTimer = () => {
|
|
360
398
|
if (firstByteTimer) clearTimeout(firstByteTimer);
|
|
@@ -468,6 +506,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
468
506
|
try { onStreamDelta?.(); } catch {}
|
|
469
507
|
if (onTextDelta || textLeakGuard) {
|
|
470
508
|
const t = geminiChunkText(step.value);
|
|
509
|
+
if (t) relayedText += t;
|
|
471
510
|
relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
|
|
472
511
|
}
|
|
473
512
|
}
|
|
@@ -480,7 +519,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
480
519
|
const reason = signal.reason;
|
|
481
520
|
throw reason instanceof Error ? reason : new Error(`${label} aborted`);
|
|
482
521
|
}
|
|
483
|
-
throw err;
|
|
522
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
484
523
|
} finally {
|
|
485
524
|
clearFirstByteTimer();
|
|
486
525
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -515,6 +554,12 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
515
554
|
raw = response?.candidates ? response : (response?.response || response);
|
|
516
555
|
}
|
|
517
556
|
const finishReason = raw?.candidates?.[0]?.finishReason || null;
|
|
518
|
-
|
|
557
|
+
// Same stamping as the REST consumer: truncation after visible output
|
|
558
|
+
// must not classify transient (review High).
|
|
559
|
+
try {
|
|
560
|
+
assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
|
|
561
|
+
} catch (err) {
|
|
562
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
563
|
+
}
|
|
519
564
|
return raw;
|
|
520
565
|
}
|
|
@@ -402,8 +402,19 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
402
402
|
// trailing text is never lost.
|
|
403
403
|
if (leakGuard.enabled) flushLeak();
|
|
404
404
|
} catch (err) {
|
|
405
|
-
// Any mid-stream failure after live text was relayed is non-retryable
|
|
406
|
-
|
|
405
|
+
// Any mid-stream failure after live text was relayed is non-retryable —
|
|
406
|
+
// but the streamed partial must still ride on the error (CC rule: once
|
|
407
|
+
// output is visible, keep it and finalize with a notice instead of
|
|
408
|
+
// discarding the turn). The loop's partial-final path consumes these.
|
|
409
|
+
if (emittedText) {
|
|
410
|
+
markErrorLiveTextEmitted(err);
|
|
411
|
+
try {
|
|
412
|
+
err.partialContent = content;
|
|
413
|
+
err.pendingToolUse = toolAcc.size > 0 || leakedCalls.length > 0;
|
|
414
|
+
err.partialModel = model || undefined;
|
|
415
|
+
} catch { /* best-effort */ }
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
407
418
|
// Partial-final recovery: on a mid-stream stall, attach the
|
|
408
419
|
// streamed partial state so the loop can accept a wedged FINAL no-tool
|
|
409
420
|
// summary as partial-final success. pendingToolUse gates out any
|
|
@@ -425,7 +436,19 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
|
|
|
425
436
|
}
|
|
426
437
|
if (!stopReason) {
|
|
427
438
|
const err = truncatedCompatStreamError(label, 'no finish_reason');
|
|
428
|
-
if (emittedText)
|
|
439
|
+
if (emittedText) {
|
|
440
|
+
// Truncation after visible output: preserve the partial (CC-style)
|
|
441
|
+
// instead of surfacing a bare terminal error. streamStalled lets
|
|
442
|
+
// the loop's partial-final acceptance path pick it up; liveText
|
|
443
|
+
// marking still blocks any retry/replay.
|
|
444
|
+
markErrorLiveTextEmitted(err);
|
|
445
|
+
try {
|
|
446
|
+
err.streamStalled = true;
|
|
447
|
+
err.partialContent = content;
|
|
448
|
+
err.pendingToolUse = toolAcc.size > 0 || leakedCalls.length > 0;
|
|
449
|
+
err.partialModel = model || undefined;
|
|
450
|
+
} catch { /* best-effort */ }
|
|
451
|
+
}
|
|
429
452
|
throw err;
|
|
430
453
|
}
|
|
431
454
|
const message = {
|
|
@@ -796,7 +819,22 @@ export async function consumeCompatResponsesStream(stream, {
|
|
|
796
819
|
}
|
|
797
820
|
if (!state.completed) {
|
|
798
821
|
const err = truncatedCompatStreamError(label, 'no response.completed');
|
|
799
|
-
if (state.emittedText)
|
|
822
|
+
if (state.emittedText) {
|
|
823
|
+
// Truncation after visible output: keep the streamed partial
|
|
824
|
+
// (CC rule) so the loop can finalize it as partial-final instead
|
|
825
|
+
// of dropping the turn. liveText marking still blocks replay.
|
|
826
|
+
markErrorLiveTextEmitted(err);
|
|
827
|
+
try {
|
|
828
|
+
err.streamStalled = true;
|
|
829
|
+
err.partialContent = state.content || '';
|
|
830
|
+
err.pendingToolUse = state.emittedToolCall === true
|
|
831
|
+
|| leakedCalls.length > 0
|
|
832
|
+
|| (state.pendingCalls && state.pendingCalls.size > 0)
|
|
833
|
+
|| (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
|
|
834
|
+
|| state.toolInFlight === true;
|
|
835
|
+
err.partialModel = state.model || undefined;
|
|
836
|
+
} catch { /* best-effort */ }
|
|
837
|
+
}
|
|
800
838
|
throw err;
|
|
801
839
|
}
|
|
802
840
|
const unresolved = state.toolCalls.find(t => t._pendingItemId);
|
|
@@ -149,7 +149,7 @@ export function useXaiResponsesWebSocket(opts, config) {
|
|
|
149
149
|
return !['0', 'false', 'off', 'http', 'https', 'responses-http', 'sdk'].includes(transport);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function _envFlag(name, fallback = true) {
|
|
152
|
+
export function _envFlag(name, fallback = true) {
|
|
153
153
|
const raw = process.env[name];
|
|
154
154
|
if (raw == null || raw === '') return fallback;
|
|
155
155
|
return !['0', 'false', 'off', 'no'].includes(String(raw).toLowerCase());
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from './openai-compat-stream.mjs';
|
|
10
10
|
import { enrichModels, getModelMetadataSync } from './model-catalog.mjs';
|
|
11
11
|
import { sanitizeModelList } from './model-list-sanitize.mjs';
|
|
12
|
-
import { appendAgentTrace, traceAgentUsage } from '../agent-trace.mjs';
|
|
12
|
+
import { appendAgentTrace, grokCacheChainTraceFields, traceAgentUsage } from '../agent-trace.mjs';
|
|
13
13
|
import {
|
|
14
14
|
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
15
15
|
PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
useXaiResponsesWebSocket,
|
|
47
47
|
useXaiResponsesWebSocketWarmup,
|
|
48
48
|
_shouldFallbackXaiWsToHttp,
|
|
49
|
+
_envFlag,
|
|
49
50
|
withXaiResponsesCacheLane,
|
|
50
51
|
writeCompatCacheTrace,
|
|
51
52
|
traceXaiResponsesCacheContext,
|
|
@@ -543,6 +544,11 @@ export class OpenAICompatProvider {
|
|
|
543
544
|
if (response.usage) {
|
|
544
545
|
const inputTokens = Number(response.usage.input_tokens ?? response.usage.prompt_tokens ?? 0);
|
|
545
546
|
const cachedTokens = extractCompatCachedTokens(response.usage);
|
|
547
|
+
const cacheChain = grokCacheChainTraceFields(
|
|
548
|
+
opts.providerState,
|
|
549
|
+
previousResponseId,
|
|
550
|
+
continuationResetReason,
|
|
551
|
+
);
|
|
546
552
|
traceAgentUsage({
|
|
547
553
|
sessionId: opts.sessionId || opts.session?.id || null,
|
|
548
554
|
iteration: Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null,
|
|
@@ -556,9 +562,17 @@ export class OpenAICompatProvider {
|
|
|
556
562
|
responseId: response.id || null,
|
|
557
563
|
rawUsage: response.usage,
|
|
558
564
|
provider: 'xai',
|
|
565
|
+
requestPrevResponseId: cacheChain.requestPrevResponseId,
|
|
566
|
+
chainContinuous: cacheChain.chainContinuous,
|
|
567
|
+
continuationResetReason: cacheChain.continuationResetReason,
|
|
559
568
|
});
|
|
560
569
|
}
|
|
561
|
-
|
|
570
|
+
// Keep the response chain across `length` stops: a max-output cutoff
|
|
571
|
+
// is still a committed response server-side, so chaining from its id
|
|
572
|
+
// preserves prefix cache. (Previously reset to null defensively; no
|
|
573
|
+
// provider requirement found — chain_continuous trace will surface
|
|
574
|
+
// any rejection as a provider-side drop.)
|
|
575
|
+
const nextPreviousResponseId = response.id;
|
|
562
576
|
const searchSources = collectCompatResponseSearchSources(response);
|
|
563
577
|
return {
|
|
564
578
|
content: streamed.content,
|
|
@@ -671,12 +685,19 @@ export class OpenAICompatProvider {
|
|
|
671
685
|
traceProvider: 'xai',
|
|
672
686
|
logSuppressedReasoningDeltas: false,
|
|
673
687
|
warmupBody,
|
|
688
|
+
// Mirror openai-oauth fast fallback: when the HTTP fallback is
|
|
689
|
+
// enabled (outer catch → _shouldFallbackXaiWsToHttp), a first
|
|
690
|
+
// acquire/first-byte failure should skip remaining WS
|
|
691
|
+
// handshake retries instead of burning the retry budget
|
|
692
|
+
// before HTTP starts.
|
|
693
|
+
fastFallback: _envFlag('MIXDOG_XAI_WS_HTTP_FALLBACK', true),
|
|
674
694
|
});
|
|
675
695
|
});
|
|
676
696
|
const result = scheduled.value;
|
|
677
697
|
cacheLane = cacheLane || scheduled.laneMeta;
|
|
678
698
|
const responseId = result.responseId || previousResponseId || null;
|
|
679
|
-
|
|
699
|
+
// Same rationale as the HTTP path above: `length` stop keeps the chain.
|
|
700
|
+
const nextPreviousResponseId = responseId;
|
|
680
701
|
const rawUsage = result.usage?.raw || result.usage || null;
|
|
681
702
|
const traceParams = result.__warmup?.requestBody || params;
|
|
682
703
|
writeXaiResponsesCacheTrace({
|
|
@@ -636,6 +636,12 @@ export async function sendViaHttpSse({
|
|
|
636
636
|
};
|
|
637
637
|
|
|
638
638
|
try {
|
|
639
|
+
// Arm the idle watchdog BEFORE the first read: a 200 response with an
|
|
640
|
+
// open-but-silent body (no SSE events at all) previously hung on bare
|
|
641
|
+
// reader.read() until the outer agent watchdog (CC/codex/opencode all
|
|
642
|
+
// bound the first read). meaningful() re-arms it per delta thereafter;
|
|
643
|
+
// an empty-partial stall classifies stream_stalled → retry/fallback.
|
|
644
|
+
_armSemanticIdle();
|
|
639
645
|
while (true) {
|
|
640
646
|
if (totalTimeout.signal?.aborted) {
|
|
641
647
|
_clearSemanticIdle();
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
traceAgentFetch,
|
|
29
29
|
traceAgentSse,
|
|
30
30
|
traceAgentUsage,
|
|
31
|
+
grokCacheChainTraceFields,
|
|
31
32
|
appendAgentTrace,
|
|
32
33
|
} from '../agent-trace.mjs';
|
|
33
34
|
import {
|
|
@@ -421,10 +422,14 @@ export async function _acquireWithRetry({
|
|
|
421
422
|
externalSignal,
|
|
422
423
|
_acquire = acquireWebSocket,
|
|
423
424
|
_sleepFn = _defaultSleep,
|
|
425
|
+
maxAttempts = HANDSHAKE_MAX_ATTEMPTS,
|
|
424
426
|
} = {}) {
|
|
425
427
|
let lastErr = null;
|
|
426
428
|
let lastClassifier = null;
|
|
427
|
-
|
|
429
|
+
const attemptCap = Number.isFinite(maxAttempts) && maxAttempts > 0
|
|
430
|
+
? Math.min(maxAttempts, HANDSHAKE_MAX_ATTEMPTS)
|
|
431
|
+
: HANDSHAKE_MAX_ATTEMPTS;
|
|
432
|
+
for (let attempt = 1; attempt <= attemptCap; attempt++) {
|
|
428
433
|
if (externalSignal?.aborted) {
|
|
429
434
|
const reason = externalSignal.reason;
|
|
430
435
|
throw reason instanceof Error ? reason : new Error('OpenAI OAuth WS acquire aborted');
|
|
@@ -449,14 +454,14 @@ export async function _acquireWithRetry({
|
|
|
449
454
|
throw err;
|
|
450
455
|
}
|
|
451
456
|
// Transient but exhausted: surface with tagging.
|
|
452
|
-
if (attempt >=
|
|
457
|
+
if (attempt >= attemptCap) {
|
|
453
458
|
if (err && typeof err === 'object') {
|
|
454
459
|
try { err.attempts = attempt; } catch {}
|
|
455
460
|
try { err.retryClassifier = classifier; } catch {}
|
|
456
461
|
}
|
|
457
462
|
try {
|
|
458
463
|
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
|
|
459
|
-
`[openai-oauth-ws] handshake failed after ${attempt}/${
|
|
464
|
+
`[openai-oauth-ws] handshake failed after ${attempt}/${attemptCap} attempts: ${err?.message || err}\n`,
|
|
460
465
|
);
|
|
461
466
|
} catch {}
|
|
462
467
|
throw err;
|
|
@@ -465,13 +470,13 @@ export async function _acquireWithRetry({
|
|
|
465
470
|
const backoff = _backoffFor(attempt);
|
|
466
471
|
try {
|
|
467
472
|
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
|
|
468
|
-
`[openai-oauth-ws] worker retry ${attempt}/${
|
|
473
|
+
`[openai-oauth-ws] worker retry ${attempt}/${attemptCap} (transient: ${classifier}, backoff ${backoff}ms)\n`,
|
|
469
474
|
);
|
|
470
475
|
} catch {}
|
|
471
476
|
try {
|
|
472
477
|
onRetry?.({
|
|
473
478
|
attempt,
|
|
474
|
-
max:
|
|
479
|
+
max: attemptCap,
|
|
475
480
|
classifier,
|
|
476
481
|
backoffMs: backoff,
|
|
477
482
|
error: err,
|
|
@@ -530,6 +535,12 @@ export async function sendViaWebSocket({
|
|
|
530
535
|
traceProvider = 'openai-oauth',
|
|
531
536
|
logSuppressedReasoningDeltas = true,
|
|
532
537
|
warmupBody = null,
|
|
538
|
+
// Fast-fallback: when the caller has HTTP/SSE fallback enabled, cap the
|
|
539
|
+
// handshake acquire retry loop to a single attempt so a first
|
|
540
|
+
// acquire/first-byte failure surfaces immediately instead of burning the
|
|
541
|
+
// full exponential-backoff budget (measured 16.4s) before the caller can
|
|
542
|
+
// race HTTP. WS-only callers leave this false → full HANDSHAKE_MAX_ATTEMPTS.
|
|
543
|
+
fastFallback = false,
|
|
533
544
|
// Test seams (undefined in production). Let the unit test drive the
|
|
534
545
|
// retry state machine without opening real sockets or touching the
|
|
535
546
|
// handshake-retry layer.
|
|
@@ -608,6 +619,12 @@ export async function sendViaWebSocket({
|
|
|
608
619
|
// one is either torn down or in an unknown state.
|
|
609
620
|
forceFresh: forceFresh || attemptIndex > 0,
|
|
610
621
|
externalSignal,
|
|
622
|
+
// Fast-fallback caps the handshake acquire loop at 1 attempt so
|
|
623
|
+
// the caller can race HTTP immediately instead of waiting out
|
|
624
|
+
// the full backoff budget. Only the FIRST midstream attempt is
|
|
625
|
+
// capped — a midstream retry (attemptIndex>0) is already past
|
|
626
|
+
// the fallback decision and keeps the normal budget.
|
|
627
|
+
maxAttempts: (fastFallback && attemptIndex === 0) ? 1 : HANDSHAKE_MAX_ATTEMPTS,
|
|
611
628
|
onRetry: (info) => {
|
|
612
629
|
handshakeRetries += 1;
|
|
613
630
|
if (info?.classifier) handshakeRetryClassifiers.push(info.classifier);
|
|
@@ -880,7 +897,16 @@ export async function sendViaWebSocket({
|
|
|
880
897
|
_stampTool(err);
|
|
881
898
|
const classifier = _classifyMidstreamError(err, midState);
|
|
882
899
|
const retryLimit = classifier ? _midstreamRetryLimit(classifier) : 0;
|
|
883
|
-
|
|
900
|
+
// Fast-fallback: a first-byte timeout on the FIRST attempt means the
|
|
901
|
+
// socket opened but the server never ACKed — race HTTP now instead
|
|
902
|
+
// of burning a midstream retry (fresh acquire + first-byte window).
|
|
903
|
+
// Only suppresses the first_byte_timeout bucket on attemptIndex 0;
|
|
904
|
+
// ws_1006/1011 mid-stream drops still retry (a live socket that died
|
|
905
|
+
// is cheaper to re-acquire than to cold-fall-back).
|
|
906
|
+
const fastFallbackSkipRetry = fastFallback
|
|
907
|
+
&& attemptIndex === 0
|
|
908
|
+
&& (classifier === 'first_byte_timeout' || err?.firstByteTimeout === true);
|
|
909
|
+
if (classifier && attemptIndex < retryLimit && !fastFallbackSkipRetry) {
|
|
884
910
|
// Retry-eligible: stash the first-attempt error, emit progress,
|
|
885
911
|
// and loop. The subsequent acquire uses forceFresh so no socket
|
|
886
912
|
// is shared between attempts.
|
|
@@ -949,6 +975,12 @@ export async function sendViaWebSocket({
|
|
|
949
975
|
// calls: the next request is previous input + server output items
|
|
950
976
|
// + tool results, and _computeDelta strips the first two parts so the
|
|
951
977
|
// WebSocket frame only sends the true new tail.
|
|
978
|
+
// Captured BEFORE the overwrite below: chain-continuity trace must
|
|
979
|
+
// compare the request's prev_id against what the entry held when the
|
|
980
|
+
// request was BUILT, not the id we just received (review Low).
|
|
981
|
+
const priorEntryResponseId = typeof entry?.lastResponseId === 'string' && entry.lastResponseId.length > 0
|
|
982
|
+
? entry.lastResponseId
|
|
983
|
+
: null;
|
|
952
984
|
if (result.responseId && keepResponseChain) {
|
|
953
985
|
entry.lastResponseId = result.responseId;
|
|
954
986
|
entry.lastRequestSansInput = _stableStringify(_sansInput(requestBody));
|
|
@@ -981,6 +1013,24 @@ export async function sendViaWebSocket({
|
|
|
981
1013
|
|
|
982
1014
|
const requestedServiceTier = body?.service_tier || null;
|
|
983
1015
|
const responseServiceTier = result.serviceTier || result.usage?.raw?.service_tier || null;
|
|
1016
|
+
const sentPrevResponseId = typeof frame?.previous_response_id === 'string' && frame.previous_response_id.length > 0
|
|
1017
|
+
? frame.previous_response_id
|
|
1018
|
+
: (typeof body?.previous_response_id === 'string' && body.previous_response_id.length > 0
|
|
1019
|
+
? body.previous_response_id
|
|
1020
|
+
: null);
|
|
1021
|
+
// Compare against the entry's PRE-request lastResponseId (captured
|
|
1022
|
+
// above, before line ~979 overwrites it with the new response id):
|
|
1023
|
+
// the WS delta path chains from entry state, so stale providerState
|
|
1024
|
+
// OR the post-overwrite id would both mis-report continuity.
|
|
1025
|
+
const cacheChain = traceProvider === 'xai'
|
|
1026
|
+
? (priorEntryResponseId
|
|
1027
|
+
? {
|
|
1028
|
+
requestPrevResponseId: sentPrevResponseId,
|
|
1029
|
+
chainContinuous: sentPrevResponseId !== null && sentPrevResponseId === priorEntryResponseId,
|
|
1030
|
+
continuationResetReason: null,
|
|
1031
|
+
}
|
|
1032
|
+
: grokCacheChainTraceFields(sendOpts?.providerState, sentPrevResponseId, null))
|
|
1033
|
+
: null;
|
|
984
1034
|
traceAgentUsage({
|
|
985
1035
|
sessionId: poolKey,
|
|
986
1036
|
iteration,
|
|
@@ -994,6 +1044,11 @@ export async function sendViaWebSocket({
|
|
|
994
1044
|
rawUsage: result.usage?.raw || null,
|
|
995
1045
|
provider: traceProvider,
|
|
996
1046
|
serviceTier: responseServiceTier,
|
|
1047
|
+
...(cacheChain ? {
|
|
1048
|
+
requestPrevResponseId: cacheChain.requestPrevResponseId,
|
|
1049
|
+
chainContinuous: cacheChain.chainContinuous,
|
|
1050
|
+
continuationResetReason: cacheChain.continuationResetReason,
|
|
1051
|
+
} : {}),
|
|
997
1052
|
});
|
|
998
1053
|
const requestHasPreviousResponseId = typeof frame.previous_response_id === 'string' && frame.previous_response_id.length > 0;
|
|
999
1054
|
const transportCacheKeyHash = cacheKey
|
|
@@ -809,6 +809,10 @@ export class OpenAIOAuthProvider {
|
|
|
809
809
|
const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
|
|
810
810
|
const sendWs = typeof opts._sendViaWebSocketFn === 'function' ? opts._sendViaWebSocketFn : sendViaWebSocket;
|
|
811
811
|
const sendHttp = typeof opts._sendViaHttpSseFn === 'function' ? opts._sendViaHttpSseFn : sendViaHttpSse;
|
|
812
|
+
// Fast-fallback is only meaningful when HTTP/SSE fallback is actually
|
|
813
|
+
// configured for this provider; WS-only paths keep the full handshake
|
|
814
|
+
// retry budget. This mirrors _shouldUseOpenAIHttpFallback's `enabled`.
|
|
815
|
+
const httpFallbackEnabled = _envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true);
|
|
812
816
|
const _t1 = Date.now();
|
|
813
817
|
const recordLiveModel = (result) => {
|
|
814
818
|
if (result?.model && !_codexCatalogHas(result.model)) {
|
|
@@ -912,6 +916,13 @@ export class OpenAIOAuthProvider {
|
|
|
912
916
|
useModel,
|
|
913
917
|
displayModel: _displayCodexModel,
|
|
914
918
|
forceFresh,
|
|
919
|
+
// Fast-fallback: when HTTP/SSE fallback is enabled, cap the WS
|
|
920
|
+
// handshake acquire loop at ONE attempt so a first
|
|
921
|
+
// acquire/first-byte failure aborts the remaining backoff retries
|
|
922
|
+
// and lets HTTP start immediately (skip-retries, no concurrent race
|
|
923
|
+
// → no double token spend). WS-only paths (fallback disabled) keep
|
|
924
|
+
// the full retry budget.
|
|
925
|
+
fastFallback: httpFallbackEnabled,
|
|
915
926
|
// codex-parity prewarm (generate:false full frame on a fresh
|
|
916
927
|
// socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
|
|
917
928
|
// R20(on) A/B shows prewarm removes ALL early-session zero-cache
|
|
@@ -987,6 +998,26 @@ export class OpenAIOAuthProvider {
|
|
|
987
998
|
return this.send(messages, model, tools, { ...opts, _modelRetry: true });
|
|
988
999
|
}
|
|
989
1000
|
if (_shouldUseOpenAIHttpFallback(err, externalSignal)) {
|
|
1001
|
+
// Fast-path trace: the WS handshake acquire/first-byte failed on
|
|
1002
|
+
// its FIRST attempt while fast-fallback was armed, so the
|
|
1003
|
+
// remaining backoff retries were skipped and HTTP starts now.
|
|
1004
|
+
// err.attempts===1 means the capped single-attempt path fired.
|
|
1005
|
+
if (httpFallbackEnabled && Number(err?.attempts) === 1) {
|
|
1006
|
+
appendAgentTrace({
|
|
1007
|
+
sessionId: poolKey,
|
|
1008
|
+
iteration,
|
|
1009
|
+
kind: 'ws_fallback_fast',
|
|
1010
|
+
provider: 'openai-oauth',
|
|
1011
|
+
model: useModel,
|
|
1012
|
+
transport: 'websocket',
|
|
1013
|
+
elapsed_ms: Date.now() - _t1,
|
|
1014
|
+
payload: {
|
|
1015
|
+
elapsed_ms: Date.now() - _t1,
|
|
1016
|
+
classifier: err?.retryClassifier || err?.midstreamClassifier || null,
|
|
1017
|
+
code: err?.code || null,
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
990
1021
|
try {
|
|
991
1022
|
return await dispatchHttp(
|
|
992
1023
|
err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed',
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS,
|
|
26
26
|
PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
|
|
27
27
|
PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
|
|
28
|
+
PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS,
|
|
28
29
|
streamStalledError,
|
|
29
30
|
} from '../stall-policy.mjs';
|
|
30
31
|
import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
|
|
@@ -225,6 +226,13 @@ export async function _streamResponse({
|
|
|
225
226
|
const socket = entry.socket;
|
|
226
227
|
const preResponseCreatedMs = _positiveInt(_timeouts?.preResponseCreatedMs, WS_PRE_RESPONSE_CREATED_MS);
|
|
227
228
|
const interChunkMs = _positiveInt(_timeouts?.interChunkMs, WS_INTER_CHUNK_MS);
|
|
229
|
+
// First-MEANINGFUL-frame deadline. Distinct from preResponseCreatedMs (a
|
|
230
|
+
// short pre-created byte-silence window that resetIdle clears on the FIRST
|
|
231
|
+
// frame of any kind): this timer is cleared only by a meaningful response
|
|
232
|
+
// event (response.created or the first content/tool-arg delta), so a server
|
|
233
|
+
// that ACKs with keepalive/metadata-only frames — resetting inter-chunk idle
|
|
234
|
+
// forever — still trips a stall before the agent watchdog's first-byte abort.
|
|
235
|
+
const firstMeaningfulMs = _positiveInt(_timeouts?.firstMeaningfulMs, PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS);
|
|
228
236
|
const _streamingStart = Date.now();
|
|
229
237
|
let _firstDeltaEmitted = false;
|
|
230
238
|
let content = '';
|
|
@@ -444,6 +452,12 @@ export async function _streamResponse({
|
|
|
444
452
|
let semanticIdleTimer = null;
|
|
445
453
|
const semanticIdleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
|
|
446
454
|
const semanticIdleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED && semanticIdleMs > 0;
|
|
455
|
+
// First-meaningful-frame watchdog timer + one-shot latch. Armed alongside
|
|
456
|
+
// the pre-stream watchdog; cleared exactly once by the first meaningful
|
|
457
|
+
// response event (response.created / first content or tool-arg delta).
|
|
458
|
+
let firstMeaningfulTimer = null;
|
|
459
|
+
let firstMeaningfulSeen = false;
|
|
460
|
+
const firstMeaningfulEnabled = firstMeaningfulMs > 0;
|
|
447
461
|
|
|
448
462
|
return new Promise((resolve, reject) => {
|
|
449
463
|
// Pre-stream watchdog: the timer fires if the server never sends a
|
|
@@ -516,6 +530,37 @@ export async function _streamResponse({
|
|
|
516
530
|
idleTimer = null;
|
|
517
531
|
}
|
|
518
532
|
};
|
|
533
|
+
// First-meaningful-frame watchdog: fires if no meaningful response event
|
|
534
|
+
// (response.created / first content or tool-arg delta) arrives within
|
|
535
|
+
// firstMeaningfulMs, even while keepalive/metadata frames keep the
|
|
536
|
+
// inter-chunk idle timer fresh. On expiry treat the stream as stalled →
|
|
537
|
+
// named streamStalledError routes through the existing mid-stream
|
|
538
|
+
// retry/fallback path (fires before the 300s agent first-byte abort).
|
|
539
|
+
const armFirstMeaningfulWatchdog = () => {
|
|
540
|
+
if (!firstMeaningfulEnabled) return;
|
|
541
|
+
if (firstMeaningfulTimer) clearTimeout(firstMeaningfulTimer);
|
|
542
|
+
firstMeaningfulTimer = setTimeout(() => {
|
|
543
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
544
|
+
process.stderr.write(`[agent-trace] ws-timeout kind=first-meaningful afterMs=${firstMeaningfulMs}\n`);
|
|
545
|
+
}
|
|
546
|
+
traceWsTimeout('first_meaningful_timeout', firstMeaningfulMs);
|
|
547
|
+
terminalError = streamStalledError('Responses WS', firstMeaningfulMs, { emittedToolCall: !!midState?.emittedToolCall });
|
|
548
|
+
try { terminalError.wsCloseCode = 4000; } catch {}
|
|
549
|
+
try { socket.close(4000, 'first_meaningful_timeout'); } catch {}
|
|
550
|
+
finish();
|
|
551
|
+
}, firstMeaningfulMs);
|
|
552
|
+
try { firstMeaningfulTimer.unref?.(); } catch {}
|
|
553
|
+
};
|
|
554
|
+
// Cleared exactly once, only by a meaningful response event — NOT by
|
|
555
|
+
// keepalive/metadata frames (which never call this).
|
|
556
|
+
const clearFirstMeaningfulWatchdog = () => {
|
|
557
|
+
if (firstMeaningfulSeen) return;
|
|
558
|
+
firstMeaningfulSeen = true;
|
|
559
|
+
if (firstMeaningfulTimer) {
|
|
560
|
+
clearTimeout(firstMeaningfulTimer);
|
|
561
|
+
firstMeaningfulTimer = null;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
519
564
|
const resetInterChunk = () => {
|
|
520
565
|
if (interChunkTimer) clearTimeout(interChunkTimer);
|
|
521
566
|
interChunkTimer = setTimeout(() => {
|
|
@@ -568,11 +613,16 @@ export async function _streamResponse({
|
|
|
568
613
|
};
|
|
569
614
|
// Meaningful-output progress bump: called by the same delta cases that
|
|
570
615
|
// call onStreamDelta (text/reasoning/tool args). Arms the semantic idle.
|
|
571
|
-
|
|
616
|
+
// Also clears the first-meaningful-frame watchdog: the first content /
|
|
617
|
+
// tool-arg delta is the meaningful signal that satisfies it (response
|
|
618
|
+
// .created is cleared explicitly in its own case). Keepalive/metadata
|
|
619
|
+
// frames never reach here, so they cannot satisfy the watchdog.
|
|
620
|
+
const bumpSemanticIdle = () => { clearFirstMeaningfulWatchdog(); resetSemanticIdle(); };
|
|
572
621
|
const cleanup = () => {
|
|
573
622
|
if (idleTimer) clearTimeout(idleTimer);
|
|
574
623
|
if (interChunkTimer) { clearTimeout(interChunkTimer); interChunkTimer = null; }
|
|
575
624
|
if (semanticIdleTimer) { clearTimeout(semanticIdleTimer); semanticIdleTimer = null; }
|
|
625
|
+
if (firstMeaningfulTimer) { clearTimeout(firstMeaningfulTimer); firstMeaningfulTimer = null; }
|
|
576
626
|
if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
|
|
577
627
|
if (messageHandler) socket.off('message', messageHandler);
|
|
578
628
|
if (closeHandler) socket.off('close', closeHandler);
|
|
@@ -639,7 +689,11 @@ export async function _streamResponse({
|
|
|
639
689
|
if (event.response?.id) responseId = event.response.id;
|
|
640
690
|
// Server ack (first event). resetIdle() at the top of
|
|
641
691
|
// messageHandler already cleared the pre-stream watchdog and
|
|
642
|
-
// armed the single idle timer
|
|
692
|
+
// armed the single idle timer. response.created is a
|
|
693
|
+
// MEANINGFUL frame, so it also satisfies the
|
|
694
|
+
// first-meaningful watchdog (keepalive/metadata frames do
|
|
695
|
+
// NOT reach this case, so they never clear it).
|
|
696
|
+
clearFirstMeaningfulWatchdog();
|
|
643
697
|
break;
|
|
644
698
|
case 'response.output_text.delta':
|
|
645
699
|
try {
|
|
@@ -1065,6 +1119,7 @@ export async function _streamResponse({
|
|
|
1065
1119
|
socket.on('close', closeHandler);
|
|
1066
1120
|
socket.on('error', errorHandler);
|
|
1067
1121
|
armPreStreamWatchdog();
|
|
1122
|
+
armFirstMeaningfulWatchdog();
|
|
1068
1123
|
// Periodic client-side WS ping while the stream is active. The server's
|
|
1069
1124
|
// server closes with 1011 "keepalive ping timeout" when it thinks the
|
|
1070
1125
|
// peer is silent during long reasoning windows where no data frames
|
|
@@ -28,11 +28,18 @@ import {
|
|
|
28
28
|
// HTTP statuses considered transient — safe to retry with backoff.
|
|
29
29
|
// 408 — request timeout
|
|
30
30
|
// 500/502/503/504 — server errors (overload / bad gateway / timeout)
|
|
31
|
-
// 429 is
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// 429 is excluded from blanket transient retry, but withRetry() honors a
|
|
32
|
+
// SHORT Retry-After header (< SHORT_RETRY_AFTER_MAX_MS) the way Claude Code
|
|
33
|
+
// does (refs/claude-code withRetry.ts SHORT_RETRY_THRESHOLD_MS = 20s):
|
|
34
|
+
// brief throttle windows retry once the server-stated wait elapses; long or
|
|
35
|
+
// headerless 429s still surface immediately (quota windows are
|
|
36
|
+
// deterministic — sleeping into an outer watchdog just relabels the error).
|
|
34
37
|
const TRANSIENT_STATUSES = new Set([408, 500, 502, 503, 504])
|
|
35
38
|
|
|
39
|
+
// Max server-stated Retry-After we are willing to sleep on a 429 before
|
|
40
|
+
// surfacing it. Mirrors Claude Code's 20s short-retry threshold.
|
|
41
|
+
const SHORT_RETRY_AFTER_MAX_MS = 20_000
|
|
42
|
+
|
|
36
43
|
// HTTP statuses that mean "permanent: stop retrying, surface to caller".
|
|
37
44
|
// 401/403 — auth issue
|
|
38
45
|
// 404 — not found
|
|
@@ -296,6 +303,26 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
|
296
303
|
if (err?.wsSendFailed || state.wsSendFailed) {
|
|
297
304
|
return _allowMidstream('ws_send_failed', attemptIndex, policy)
|
|
298
305
|
}
|
|
306
|
+
// Stall / local-close-4000 must be classified as RETRYABLE before the
|
|
307
|
+
// pre-`response.created` deny gate below. A first-meaningful-frame timeout
|
|
308
|
+
// fires with sawResponseCreated=false + close 4000 + StreamStalledError, so
|
|
309
|
+
// without this the pre-created gate would return null (terminal) and the
|
|
310
|
+
// stall would never route through the mid-stream retry / transport fallback.
|
|
311
|
+
{
|
|
312
|
+
const name = err?.name || ''
|
|
313
|
+
const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0)
|
|
314
|
+
if (name === 'AgentStallAbortError' || state.watchdogAbort === 'AgentStallAbortError') {
|
|
315
|
+
return _allowMidstream('agent_stall', attemptIndex, policy)
|
|
316
|
+
}
|
|
317
|
+
if (name === 'StreamStalledAbortError' || name === 'StreamStalledError'
|
|
318
|
+
|| err?.code === 'ESTREAMSTALL' || err?.streamStalled === true
|
|
319
|
+
|| state.watchdogAbort === 'StreamStalledAbortError') {
|
|
320
|
+
// A stall AFTER a tool emit is unsafe to replay (double side-effect).
|
|
321
|
+
if (err?.unsafeToRetry === true) return null
|
|
322
|
+
return _allowMidstream('stream_stalled', attemptIndex, policy)
|
|
323
|
+
}
|
|
324
|
+
if (closeCode === 4000) return _allowMidstream('ws_4000', attemptIndex, policy)
|
|
325
|
+
}
|
|
299
326
|
if (!state.sawResponseCreated) {
|
|
300
327
|
const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0)
|
|
301
328
|
if (closeCode !== 1011 && closeCode !== 1012) return null
|
|
@@ -572,7 +599,16 @@ export async function withRetry(fn, opts = {}) {
|
|
|
572
599
|
|| caught?.providerQuota === true
|
|
573
600
|
|| caught?.quotaExceeded === true
|
|
574
601
|
if (unsafeToRetry) throw caught
|
|
575
|
-
if (status === 429)
|
|
602
|
+
if (status === 429) {
|
|
603
|
+
// CC-style short Retry-After: a brief server-stated throttle window
|
|
604
|
+
// is worth one sleep-and-retry (prompt cache stays warm). Long or
|
|
605
|
+
// absent Retry-After surfaces immediately as before.
|
|
606
|
+
const ra = retryAfterMsFromError(caught)
|
|
607
|
+
if (ra == null || ra > SHORT_RETRY_AFTER_MAX_MS || attempt === maxAttempts - 1) throw caught
|
|
608
|
+
nextDelayMs = Math.max(0, ra)
|
|
609
|
+
nextDelayReason = 'retry-after'
|
|
610
|
+
continue
|
|
611
|
+
}
|
|
576
612
|
if (kind !== 'transient') throw caught
|
|
577
613
|
// Last attempt failed transiently — propagate to caller.
|
|
578
614
|
if (attempt === maxAttempts - 1) throw caught
|