mixdog 0.9.11 → 0.9.13

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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/lib/rules-builder.cjs +15 -11
  5. package/src/mixdog-session-runtime.mjs +48 -0
  6. package/src/rules/agent/00-common.md +5 -17
  7. package/src/rules/agent/00-core.md +21 -0
  8. package/src/rules/lead/lead-brief.md +7 -0
  9. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  10. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  12. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  19. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  22. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  23. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  24. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  25. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  26. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  27. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  28. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  29. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  30. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  32. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  33. package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
  34. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  35. package/src/runtime/channels/index.mjs +28 -1
  36. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  37. package/src/runtime/memory/index.mjs +11 -11
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
  39. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
  40. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  41. package/src/session-runtime/tool-catalog.mjs +1 -0
  42. package/src/session-runtime/tool-defs.mjs +28 -0
  43. package/src/standalone/agent-tool.mjs +89 -7
  44. package/src/tui/dist/index.mjs +35 -12
  45. package/src/tui/engine.mjs +55 -12
  46. package/src/workflows/default/WORKFLOW.md +2 -0
  47. package/src/workflows/sequential/WORKFLOW.md +2 -0
  48. 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
- assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
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
- assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
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
- if (emittedText) throw markErrorLiveTextEmitted(err);
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) markErrorLiveTextEmitted(err);
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) markErrorLiveTextEmitted(err);
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
- const nextPreviousResponseId = streamed.stopReason === 'length' ? null : response.id;
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
- const nextPreviousResponseId = result.stopReason === 'length' ? null : responseId;
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
- for (let attempt = 1; attempt <= HANDSHAKE_MAX_ATTEMPTS; attempt++) {
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 >= HANDSHAKE_MAX_ATTEMPTS) {
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}/${HANDSHAKE_MAX_ATTEMPTS} attempts: ${err?.message || err}\n`,
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}/${HANDSHAKE_MAX_ATTEMPTS} (transient: ${classifier}, backoff ${backoff}ms)\n`,
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: HANDSHAKE_MAX_ATTEMPTS,
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
- if (classifier && attemptIndex < retryLimit) {
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
- const bumpSemanticIdle = () => { resetSemanticIdle(); };
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 no extra bookkeeping here.
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 deliberately excluded: rate-limit/quota windows are deterministic
32
- // for the current call and must surface immediately instead of sleeping until
33
- // an outer watchdog reports a misleading timeout.
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) throw caught
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