@north-light/crouter 0.3.304 → 0.3.306

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 (43) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +87 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +31 -26
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +33 -26
  4. package/dist/clients/attach/viewer.js +581 -581
  5. package/dist/commands/node/create.js +7 -1
  6. package/dist/commands/node/wait.js +1 -1
  7. package/dist/commands/node-worktree.js +5 -1
  8. package/dist/core/__tests__/fixtures/fake-engine.js +14 -15
  9. package/dist/core/__tests__/integration/git-optional-locks.test.d.ts +1 -0
  10. package/dist/core/__tests__/integration/git-optional-locks.test.js +81 -0
  11. package/dist/core/__tests__/integration/tmux-surface.test.js +53 -0
  12. package/dist/core/__tests__/kickoff.test.js +40 -0
  13. package/dist/core/__tests__/seam/broker-provider-retry.test.js +25 -0
  14. package/dist/core/__tests__/seam/deadline-wake-delivery.test.d.ts +1 -0
  15. package/dist/core/__tests__/seam/deadline-wake-delivery.test.js +138 -0
  16. package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.d.ts +1 -0
  17. package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.js +60 -0
  18. package/dist/core/fault-classifier.d.ts +6 -0
  19. package/dist/core/fault-classifier.js +9 -4
  20. package/dist/core/git.js +16 -2
  21. package/dist/core/provider-failure.d.ts +30 -0
  22. package/dist/core/provider-failure.js +86 -0
  23. package/dist/core/runtime/bearings-render.js +13 -4
  24. package/dist/core/runtime/broker/fault-retry.js +8 -0
  25. package/dist/core/runtime/fault.d.ts +2 -1
  26. package/dist/core/runtime/fault.js +38 -0
  27. package/dist/core/runtime/kickoff.d.ts +3 -0
  28. package/dist/core/runtime/kickoff.js +24 -4
  29. package/dist/core/runtime/reopen.d.ts +2 -2
  30. package/dist/core/runtime/reopen.js +6 -4
  31. package/dist/core/runtime/revive.js +3 -3
  32. package/dist/core/runtime/tmux-bindings.js +14 -3
  33. package/dist/core/termrender/version.d.ts +1 -1
  34. package/dist/core/termrender/version.js +1 -1
  35. package/dist/core/worktree-close.js +6 -3
  36. package/dist/core/worktree-sweep.js +7 -3
  37. package/dist/core/worktree.js +6 -3
  38. package/dist/daemon/api/__tests__/reopen-delivery.test.js +31 -0
  39. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +36 -4
  40. package/dist/daemon/api/handlers/messages.js +26 -9
  41. package/dist/daemon/reconcilers/broker-supervision.js +10 -1
  42. package/package.json +1 -1
  43. package/runtime.lock.json +5 -5
@@ -1251,6 +1251,93 @@ test("surfaces the underlying transient error without reporting a rate limit whe
1251
1251
  }
1252
1252
  });
1253
1253
 
1254
+ test("carries an observed 503 into the terminal provider error after bounded recovery", async () => {
1255
+ await resetRotationState();
1256
+ writeLadderConfig();
1257
+ await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1258
+
1259
+ const { ctx, providers, events, pi } = makeRotationCtx(undefined);
1260
+ rotationModule.__setSleepForTest(async () => {});
1261
+ rotationModule.__setStreamForProviderForTest((model, _context, options) => (async function* () {
1262
+ await options?.onResponse?.({ status: 503, headers: {} }, model);
1263
+ yield { type: "error", error: new Error("Request failed", { cause: Object.assign(new Error("socket reset"), { code: "ECONNRESET" }) }) };
1264
+ })() as any);
1265
+
1266
+ try {
1267
+ await providerRotationExtension(pi);
1268
+ await events.session_start?.({}, ctx);
1269
+ const emitted = [] as any[];
1270
+ for await (const event of providers["openai-codex"].streamSimple(ctx.model, ctx)) emitted.push(event);
1271
+
1272
+ const terminal = emitted.find((event) => event.type === "error");
1273
+ assert.ok(terminal, "the bounded attempts settle a terminal provider error");
1274
+ assert.equal(terminal.error.provider, "openai-codex");
1275
+ assert.equal(terminal.error.model, "gpt-5.6-sol");
1276
+ const details = terminal.error.diagnostics?.find((diagnostic: any) => diagnostic.type === "crtr:provider-failure")?.details;
1277
+ assert.equal(details?.status, 503, "the response status survives the stream error boundary");
1278
+ assert.equal(details?.causes?.[0]?.code, "ECONNRESET", "the cause chain survives the stream error boundary");
1279
+ } finally {
1280
+ rotationModule.__setStreamForProviderForTest(undefined);
1281
+ rotationModule.__setSleepForTest(undefined);
1282
+ }
1283
+ });
1284
+
1285
+ test("does not replay a partially exposed response, while retaining its 503 facts", async () => {
1286
+ await resetRotationState();
1287
+ writeLadderConfig();
1288
+ await helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
1289
+
1290
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1291
+ let attempts = 0;
1292
+ rotationModule.__setStreamForProviderForTest((model, _context, options) => (async function* () {
1293
+ attempts += 1;
1294
+ yield { type: "text_delta", text: "already exposed" };
1295
+ await options?.onResponse?.({ status: 503, headers: {} }, model);
1296
+ yield { type: "error", error: new Error("Request failed") };
1297
+ })() as any);
1298
+
1299
+ try {
1300
+ await providerRotationExtension(pi);
1301
+ await events.session_start?.({}, ctx);
1302
+ const emitted = [] as any[];
1303
+ for await (const event of providers["anthropic"].streamSimple(ctx.model, ctx)) emitted.push(event);
1304
+
1305
+ assert.equal(attempts, 1, "content commits the attempt before its failure");
1306
+ assert.ok(setModelCalls.every((selected) => selected.provider === "anthropic"), "a partial response never rotates to another route");
1307
+ const terminal = emitted.find((event) => event.type === "error");
1308
+ assert.equal(terminal.error.diagnostics?.find((diagnostic: any) => diagnostic.type === "crtr:provider-failure")?.details?.status, 503);
1309
+ } finally {
1310
+ rotationModule.__setStreamForProviderForTest(undefined);
1311
+ }
1312
+ });
1313
+
1314
+ test("keeps a known authentication rejection fatal", async () => {
1315
+ await resetRotationState();
1316
+ writeLadderConfig();
1317
+ await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1318
+
1319
+ const { ctx, providers, events, pi } = makeRotationCtx(undefined);
1320
+ let attempts = 0;
1321
+ rotationModule.__setStreamForProviderForTest((model, _context, options) => (async function* () {
1322
+ attempts += 1;
1323
+ await options?.onResponse?.({ status: 401, headers: {} }, model);
1324
+ yield { type: "error", error: new Error("Request failed") };
1325
+ })() as any);
1326
+
1327
+ try {
1328
+ await providerRotationExtension(pi);
1329
+ await events.session_start?.({}, ctx);
1330
+ const emitted = [] as any[];
1331
+ for await (const event of providers["openai-codex"].streamSimple(ctx.model, ctx)) emitted.push(event);
1332
+
1333
+ assert.equal(attempts, 1, "a protocol/auth failure never enters transient retries");
1334
+ const terminal = emitted.find((event) => event.type === "error");
1335
+ assert.equal(terminal.error.diagnostics?.find((diagnostic: any) => diagnostic.type === "crtr:provider-failure")?.details?.status, 401);
1336
+ } finally {
1337
+ rotationModule.__setStreamForProviderForTest(undefined);
1338
+ }
1339
+ });
1340
+
1254
1341
  // Regression: a transport-poisoned process (a corrupted TLS upload leaves undici's H2
1255
1342
  // session destroyed, so EVERY request fails with no HTTP response while fresh processes
1256
1343
  // succeed) must self-terminate after consecutive transport-only exhausted passes so the
@@ -8,6 +8,7 @@ import { envModelExact, envNodeId } from '../../../shared/env.js';
8
8
  import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
9
9
  import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
10
10
  import { recognizeProviderError } from '../../../core/provider-error-recognition.js';
11
+ import { normalizeProviderFailure, providerFailureDiagnostic } from '../../../core/provider-failure.js';
11
12
  // A response without Retry-After proves only that THIS attempt was throttled; it
12
13
  // does not authorize crouter to invent a five-minute provider-wide outage. Keep
13
14
  // a short turn-local probe backoff to avoid an immediate retry loop, then let a
@@ -59,30 +60,31 @@ function classifyProviderSignal(signal) {
59
60
  }
60
61
  return { kind: 'fatal', status: signal.status, reason: 'unclassified', hadRetryAfterHeader };
61
62
  }
62
- function classifyProviderError(error, observed) {
63
- const value = error;
64
- const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : '']
63
+ function classifyProviderFailure(failure, retryAfterMs) {
64
+ const nested = failure.causes ?? [];
65
+ const code = failure.code ?? nested.find((cause) => cause.code !== undefined)?.code;
66
+ const errno = failure.errno ?? nested.find((cause) => cause.errno !== undefined)?.errno;
67
+ const message = [failure.message, failure.body, ...nested.flatMap((cause) => [cause.message, cause.body])]
65
68
  .filter((part) => typeof part === 'string' && part.length > 0)
66
69
  .join(' ');
67
70
  return classifyProviderSignal(recognizeProviderError({
68
- status: observed.status ?? value?.status,
69
- retryAfterMs: observed.retryAfterMs,
71
+ status: failure.status,
72
+ retryAfterMs,
73
+ code,
74
+ errno,
70
75
  message,
71
76
  }));
72
77
  }
78
+ function classifyProviderError(error, observed) {
79
+ return classifyProviderFailure(normalizeProviderFailure(error, { status: observed.status }), observed.retryAfterMs);
80
+ }
73
81
  /** Test seam for the policy mapping over the shared recognition signal. */
74
82
  export function __classifyProviderErrorForTest(error, observed = {}) {
75
83
  return classifyProviderError(error, observed);
76
84
  }
77
- function providerErrorMessage(error) {
78
- const value = error;
79
- const message = typeof value?.errorMessage === "string" && value.errorMessage.trim()
80
- ? value.errorMessage.trim()
81
- : typeof value?.message === "string" && value.message.trim()
82
- ? value.message.trim()
83
- : error instanceof Error && error.message.trim()
84
- ? error.message.trim()
85
- : undefined;
85
+ function providerFailureAssistantMessage(model, failure) {
86
+ const message = toAssistantError(model, failure.message);
87
+ message.diagnostics = [providerFailureDiagnostic(failure)];
86
88
  return message;
87
89
  }
88
90
  async function defaultSleep(ms) {
@@ -435,6 +437,7 @@ async function streamProviderAttempt(state, providerId, model, context, options,
435
437
  let sawContent = false;
436
438
  let committed = false;
437
439
  let classification;
440
+ let failure;
438
441
  let failureMessage;
439
442
  // Events are buffered ONLY until this attempt commits to the provider -- i.e. until the
440
443
  // first real content event (thinking/text/toolcall) arrives, after which rotating away is
@@ -462,12 +465,13 @@ async function streamProviderAttempt(state, providerId, model, context, options,
462
465
  flush();
463
466
  return { result: "fatal", attemptAt };
464
467
  }
465
- failureMessage = providerErrorMessage(event.error);
466
- classification = classifyProviderError(event.error, { status: observedStatus, retryAfterMs: requestedRetryAfterMs });
468
+ failure = normalizeProviderFailure(event.error, { status: observedStatus });
469
+ failureMessage = failure.message;
470
+ classification = classifyProviderFailure(failure, requestedRetryAfterMs);
467
471
  if (classification.kind === "fatal" || sawContent) {
468
- emit(event);
472
+ emit({ ...event, error: providerFailureAssistantMessage(model, failure) });
469
473
  flush();
470
- return { result: "fatal", attemptAt, classification };
474
+ return { result: "fatal", attemptAt, classification, failure };
471
475
  }
472
476
  break;
473
477
  }
@@ -490,16 +494,17 @@ async function streamProviderAttempt(state, providerId, model, context, options,
490
494
  flush();
491
495
  return { result: "fatal", attemptAt };
492
496
  }
493
- failureMessage = providerErrorMessage(error);
494
- classification = classifyProviderError(error, { status: observedStatus, retryAfterMs: requestedRetryAfterMs });
497
+ failure = normalizeProviderFailure(error, { status: observedStatus });
498
+ failureMessage = failure.message;
499
+ classification = classifyProviderFailure(failure, requestedRetryAfterMs);
495
500
  if (classification.kind === "fatal" || sawContent) {
496
501
  emit({
497
502
  type: "error",
498
503
  reason: "error",
499
- error: toAssistantError(model, error instanceof Error ? error.message : String(error)),
504
+ error: providerFailureAssistantMessage(model, failure),
500
505
  });
501
506
  flush();
502
- return { result: "fatal", attemptAt, classification };
507
+ return { result: "fatal", attemptAt, classification, failure };
503
508
  }
504
509
  }
505
510
  if (!classification && requestedRetryAfterMs !== undefined && !sawContent) {
@@ -523,14 +528,14 @@ async function streamProviderAttempt(state, providerId, model, context, options,
523
528
  // to another credential / fallback provider. Nothing is emitted into the stream here:
524
529
  // the buffered (pre-content) events are discarded exactly like the rate-limit path, so
525
530
  // the rotation target streams a clean turn.
526
- return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failureMessage };
531
+ return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failure, failureMessage };
527
532
  }
528
533
  logRotation(`${credential ? `${getProviderLabel(providerId)} subscription "${credential.label}"` : providerId} hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same route in ${backoffMs}ms (attempt ${attempt + 1})`);
529
534
  await activeSleep(backoffMs);
530
535
  }
531
536
  }
532
- function emitTerminalError(stream, model, message) {
533
- stream.push({ type: "error", reason: "error", error: toAssistantError(model, message) });
537
+ function emitTerminalError(stream, model, message, failure) {
538
+ stream.push({ type: "error", reason: "error", error: failure === undefined ? toAssistantError(model, message) : providerFailureAssistantMessage(model, failure) });
534
539
  stream.end();
535
540
  }
536
541
  function enqueueFreshRouteCandidates(candidates, queuedRouteIds, knownRoutes, seenReadyLabels, cooldownAttempts, localCoolingDeadlines) {
@@ -710,7 +715,7 @@ async function runManagedProvider(state, model, context, options, stream) {
710
715
  return;
711
716
  }
712
717
  const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
713
- emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`);
718
+ emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`, lastTransientFailure.failure);
714
719
  return;
715
720
  }
716
721
  state.transportOnlyExhaustedPasses = 0;
@@ -44,6 +44,7 @@ import { envModelExact, envNodeId } from '../../../shared/env.js';
44
44
  import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
45
45
  import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
46
46
  import { recognizeProviderError, type ProviderErrorSignal } from '../../../core/provider-error-recognition.js';
47
+ import { normalizeProviderFailure, providerFailureDiagnostic, type NormalizedProviderFailure } from '../../../core/provider-failure.js';
47
48
 
48
49
  // A response without Retry-After proves only that THIS attempt was throttled; it
49
50
  // does not authorize crouter to invent a five-minute provider-wide outage. Keep
@@ -111,6 +112,7 @@ type AttemptResult = {
111
112
  attemptAt: number;
112
113
  attempts?: number;
113
114
  classification?: ProviderErrorClassification;
115
+ failure?: NormalizedProviderFailure;
114
116
  failureMessage?: string;
115
117
  };
116
118
 
@@ -153,32 +155,34 @@ function classifyProviderSignal(signal: ProviderErrorSignal): ProviderErrorClass
153
155
  return { kind: 'fatal', status: signal.status, reason: 'unclassified', hadRetryAfterHeader };
154
156
  }
155
157
 
156
- function classifyProviderError(error: unknown, observed: { status?: number; retryAfterMs?: number }): ProviderErrorClassification {
157
- const value = error as { status?: unknown; errorMessage?: unknown; message?: unknown } | undefined;
158
- const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : '']
158
+ function classifyProviderFailure(failure: NormalizedProviderFailure, retryAfterMs: number | undefined): ProviderErrorClassification {
159
+ const nested = failure.causes ?? [];
160
+ const code = failure.code ?? nested.find((cause) => cause.code !== undefined)?.code;
161
+ const errno = failure.errno ?? nested.find((cause) => cause.errno !== undefined)?.errno;
162
+ const message = [failure.message, failure.body, ...nested.flatMap((cause) => [cause.message, cause.body])]
159
163
  .filter((part): part is string => typeof part === 'string' && part.length > 0)
160
164
  .join(' ');
161
165
  return classifyProviderSignal(recognizeProviderError({
162
- status: observed.status ?? value?.status,
163
- retryAfterMs: observed.retryAfterMs,
166
+ status: failure.status,
167
+ retryAfterMs,
168
+ code,
169
+ errno,
164
170
  message,
165
171
  }));
166
172
  }
167
173
 
174
+ function classifyProviderError(error: unknown, observed: { status?: number; retryAfterMs?: number }): ProviderErrorClassification {
175
+ return classifyProviderFailure(normalizeProviderFailure(error, { status: observed.status }), observed.retryAfterMs);
176
+ }
177
+
168
178
  /** Test seam for the policy mapping over the shared recognition signal. */
169
179
  export function __classifyProviderErrorForTest(error: unknown, observed: { status?: number; retryAfterMs?: number } = {}): ProviderErrorClassification {
170
180
  return classifyProviderError(error, observed);
171
181
  }
172
182
 
173
- function providerErrorMessage(error: unknown): string | undefined {
174
- const value = error as { errorMessage?: unknown; message?: unknown } | undefined;
175
- const message = typeof value?.errorMessage === "string" && value.errorMessage.trim()
176
- ? value.errorMessage.trim()
177
- : typeof value?.message === "string" && value.message.trim()
178
- ? value.message.trim()
179
- : error instanceof Error && error.message.trim()
180
- ? error.message.trim()
181
- : undefined;
183
+ function providerFailureAssistantMessage(model: Model<any>, failure: NormalizedProviderFailure): AssistantMessage {
184
+ const message = toAssistantError(model, failure.message);
185
+ message.diagnostics = [providerFailureDiagnostic(failure)];
182
186
  return message;
183
187
  }
184
188
 
@@ -599,6 +603,7 @@ async function streamProviderAttempt(
599
603
  let sawContent = false;
600
604
  let committed = false;
601
605
  let classification: ProviderErrorClassification | undefined;
606
+ let failure: NormalizedProviderFailure | undefined;
602
607
  let failureMessage: string | undefined;
603
608
 
604
609
  // Events are buffered ONLY until this attempt commits to the provider -- i.e. until the
@@ -625,12 +630,13 @@ async function streamProviderAttempt(
625
630
  flush();
626
631
  return { result: "fatal", attemptAt };
627
632
  }
628
- failureMessage = providerErrorMessage(event.error);
629
- classification = classifyProviderError(event.error, { status: observedStatus, retryAfterMs: requestedRetryAfterMs });
633
+ failure = normalizeProviderFailure(event.error, { status: observedStatus });
634
+ failureMessage = failure.message;
635
+ classification = classifyProviderFailure(failure, requestedRetryAfterMs);
630
636
  if (classification.kind === "fatal" || sawContent) {
631
- emit(event);
637
+ emit({ ...event, error: providerFailureAssistantMessage(model, failure) });
632
638
  flush();
633
- return { result: "fatal", attemptAt, classification };
639
+ return { result: "fatal", attemptAt, classification, failure };
634
640
  }
635
641
  break;
636
642
  }
@@ -650,16 +656,17 @@ async function streamProviderAttempt(
650
656
  flush();
651
657
  return { result: "fatal", attemptAt };
652
658
  }
653
- failureMessage = providerErrorMessage(error);
654
- classification = classifyProviderError(error, { status: observedStatus, retryAfterMs: requestedRetryAfterMs });
659
+ failure = normalizeProviderFailure(error, { status: observedStatus });
660
+ failureMessage = failure.message;
661
+ classification = classifyProviderFailure(failure, requestedRetryAfterMs);
655
662
  if (classification.kind === "fatal" || sawContent) {
656
663
  emit({
657
664
  type: "error",
658
665
  reason: "error",
659
- error: toAssistantError(model, error instanceof Error ? error.message : String(error)),
666
+ error: providerFailureAssistantMessage(model, failure),
660
667
  });
661
668
  flush();
662
- return { result: "fatal", attemptAt, classification };
669
+ return { result: "fatal", attemptAt, classification, failure };
663
670
  }
664
671
  }
665
672
 
@@ -687,7 +694,7 @@ async function streamProviderAttempt(
687
694
  // to another credential / fallback provider. Nothing is emitted into the stream here:
688
695
  // the buffered (pre-content) events are discarded exactly like the rate-limit path, so
689
696
  // the rotation target streams a clean turn.
690
- return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failureMessage };
697
+ return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failure, failureMessage };
691
698
  }
692
699
  logRotation(
693
700
  `${credential ? `${getProviderLabel(providerId as ManagedProviderId)} subscription "${credential.label}"` : providerId} hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same route in ${backoffMs}ms (attempt ${attempt + 1})`,
@@ -696,8 +703,8 @@ async function streamProviderAttempt(
696
703
  }
697
704
  }
698
705
 
699
- function emitTerminalError(stream: AssistantMessageEventStream, model: Model<any>, message: string): void {
700
- stream.push({ type: "error", reason: "error", error: toAssistantError(model, message) });
706
+ function emitTerminalError(stream: AssistantMessageEventStream, model: Model<any>, message: string, failure?: NormalizedProviderFailure): void {
707
+ stream.push({ type: "error", reason: "error", error: failure === undefined ? toAssistantError(model, message) : providerFailureAssistantMessage(model, failure) });
701
708
  stream.end();
702
709
  }
703
710
 
@@ -895,7 +902,7 @@ async function runManagedProvider(
895
902
  return;
896
903
  }
897
904
  const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
898
- emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`);
905
+ emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`, lastTransientFailure.failure);
899
906
  return;
900
907
  }
901
908
  state.transportOnlyExhaustedPasses = 0;