agent-nuvira 3.5.0 → 3.5.1

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 (42) hide show
  1. package/README.md +13 -13
  2. package/dist/agent-sdk/src/scaffold.d.ts +1 -1
  3. package/dist/agent-sdk/src/scaffold.js +1 -1
  4. package/dist/cli/chat.d.ts +117 -6
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +344 -20
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/cli/duties.d.ts +11 -0
  9. package/dist/cli/duties.d.ts.map +1 -1
  10. package/dist/cli/duties.js +31 -0
  11. package/dist/cli/duties.js.map +1 -1
  12. package/dist/cli/models.d.ts.map +1 -1
  13. package/dist/cli/models.js +55 -0
  14. package/dist/cli/models.js.map +1 -1
  15. package/dist/learning/auto-router.d.ts +28 -0
  16. package/dist/learning/auto-router.d.ts.map +1 -1
  17. package/dist/learning/auto-router.js +84 -5
  18. package/dist/learning/auto-router.js.map +1 -1
  19. package/dist/learning/duty-lease.d.ts +163 -0
  20. package/dist/learning/duty-lease.d.ts.map +1 -0
  21. package/dist/learning/duty-lease.js +301 -0
  22. package/dist/learning/duty-lease.js.map +1 -0
  23. package/dist/learning/model-first-router.d.ts +14 -0
  24. package/dist/learning/model-first-router.d.ts.map +1 -1
  25. package/dist/learning/model-first-router.js +54 -17
  26. package/dist/learning/model-first-router.js.map +1 -1
  27. package/dist/learning/model-warmup.d.ts +92 -0
  28. package/dist/learning/model-warmup.d.ts.map +1 -1
  29. package/dist/learning/model-warmup.js +137 -2
  30. package/dist/learning/model-warmup.js.map +1 -1
  31. package/dist/learning/retry-patience.d.ts +139 -0
  32. package/dist/learning/retry-patience.d.ts.map +1 -0
  33. package/dist/learning/retry-patience.js +169 -0
  34. package/dist/learning/retry-patience.js.map +1 -0
  35. package/dist/tools/tool-loop.d.ts +41 -3
  36. package/dist/tools/tool-loop.d.ts.map +1 -1
  37. package/dist/tools/tool-loop.js +260 -4
  38. package/dist/tools/tool-loop.js.map +1 -1
  39. package/package.json +4 -1
  40. package/src/web-dashboard/public/assets/{index-yky0dw5e.js → index-Bik91KJh.js} +2 -2
  41. package/src/web-dashboard/public/assets/{index-yky0dw5e.js.map → index-Bik91KJh.js.map} +1 -1
  42. package/src/web-dashboard/public/index.html +1 -1
package/dist/cli/chat.js CHANGED
@@ -62,8 +62,9 @@ import { assertAgenticRoute, setWeakModelConsent, resolveWeakModelPolicy, weakRo
62
62
  import { resolvePromptBudget, measurePromptBudget, formatPromptBudgetBreakdown } from '../learning/prompt-budget.js';
63
63
  import { buildTurnReport, formatTurnReport } from '../learning/turn-report.js';
64
64
  import { turnOutcomeObservation } from '../learning/outcome-observation.js';
65
- import { resolveAdapterDefault, hasCredentials, preferredModelsFor } from '../learning/model-selection.js';
65
+ import { resolveAdapterDefault, hasCredentials, preferredModelsFor, rankAvailableProviders } from '../learning/model-selection.js';
66
66
  import { sameClassFallback, capabilityClassOf } from '../learning/model-capability.js';
67
+ import { planBlipPatience, EMPTY_PATIENCE_MS, TRANSIENT_BLIP_DELAYS_MS, } from '../learning/retry-patience.js';
67
68
  import { buildLoopProjectContext } from '../tools/loop-project-context.js';
68
69
  import { sweepTransientFailures, collectionRevivalStore } from '../learning/provider-revival.js';
69
70
  import { analyzeComplexity } from '../learning/hybrid-router.js';
@@ -261,12 +262,208 @@ export async function runDeveloperMode(goal, configManager, options) {
261
262
  * caller, never to skip the loop).
262
263
  */
263
264
  /**
264
- * Backoff schedule for a SAME-provider retry on a transient failure. Two extra
265
- * attempts, deliberately short: a capacity spike at a shared endpoint clears in
266
- * seconds, and the user is waiting in the foreground. Long/looping retries belong
267
- * to the background runners, not the interactive turn.
265
+ * Backoff schedule for a SAME-provider retry on a TRANSIENT failure
266
+ * (server/network/timeout): dense early, sparse late — attempts at ≈1s, 3s, 7s,
267
+ * 15s, 30s, 60s.
268
+ *
269
+ * WHY THE SHAPE CHANGED (it was `[5s, 10s, 20s, 30s]`, front-loaded). A fixed
270
+ * front-loaded schedule is wrong in BOTH directions at once:
271
+ *
272
+ * - A TWO-SECOND BLINK WAS NOT CAUGHT. A cloud endpoint routinely returns 200,
273
+ * then a couple of empty/5xx answers for a handful of seconds, then serves
274
+ * normally again (measured repeatedly on `deepseek`). The first retry sat at
275
+ * t=5s, so the wait outlived the blink AND, because exhausting this schedule
276
+ * is what starts the failover walk, a healthy strong model was declared dead
277
+ * and the turn folded onto a weaker one. A retry at t≈1s catches the blink
278
+ * outright.
279
+ * - THE TOTAL STILL HAS TO RIDE OUT A REAL SPIKE. A capacity spike, an
280
+ * upstream batch job or a rolling restart lasts tens of seconds, so the late
281
+ * delays stay sparse and the total stays ~60s. Nothing that used to be
282
+ * ridden out is given up on; the wait is just spent where the evidence is.
283
+ *
284
+ * THIS IS THE NO-EVIDENCE DEFAULT, NOT THE WHOLE POLICY. The schedule used per
285
+ * call comes from {@link planBlipPatience} — which also decides whether walking
286
+ * away is a remedy at all, from the pair's alternatives, its track record, and
287
+ * whether it already failed an earlier step of this turn. This constant is what
288
+ * every caller with no evidence gets, and it is what the `models`/parity
289
+ * harnesses see.
290
+ *
291
+ * Every sleep is CANCELLABLE (see `abortableSleep`), so an Esc during a 30s wait
292
+ * stops the turn immediately instead of after it.
293
+ */
294
+ export const TRANSIENT_RETRY_DELAYS_MS = TRANSIENT_BLIP_DELAYS_MS;
295
+ /**
296
+ * Resolve the backoff schedule actually used (env override → default).
297
+ *
298
+ * `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` takes a comma-separated list of ms
299
+ * (`"5000,10000"`), so how long a foreground turn waits on a flapping provider
300
+ * is a CONFIGURATION decision rather than a property of the build — the same
301
+ * rule the warmup sweep's bounds follow. A headless gateway, a CI contract
302
+ * test or a user on a metered connection can dial it down (or to `0`) without a
303
+ * source edit and a rebuild; nonsense is ignored and the default stands.
304
+ *
305
+ * Read PER CALL, not captured at import, so a test observes the value in force
306
+ * at that moment.
307
+ */
308
+ export function transientRetryDelaysMs(env = process.env) {
309
+ const raw = env.NUVIRA_TRANSIENT_RETRY_DELAYS_MS;
310
+ if (raw === undefined || raw.trim() === '')
311
+ return TRANSIENT_RETRY_DELAYS_MS;
312
+ const parsed = raw
313
+ .split(',')
314
+ .map((part) => Number(part.trim()))
315
+ .filter((n) => Number.isFinite(n) && n >= 0);
316
+ return parsed.length > 0 ? parsed : TRANSIENT_RETRY_DELAYS_MS;
317
+ }
318
+ /**
319
+ * Is the transient schedule overridden by the environment right now?
320
+ *
321
+ * The dial is authoritative when set (it is an explicit configuration decision,
322
+ * and it is what makes attempt COUNTS deterministic in the parity/E2E
323
+ * contracts), so callers ask this BEFORE substituting an evidence-derived
324
+ * schedule — otherwise a test would silently get the learned schedule and its
325
+ * exact call-count assertions would be about a different policy than the one it
326
+ * dialed.
327
+ *
328
+ * A set-but-nonsense value does NOT count as an override: `transientRetryDelaysMs`
329
+ * ignores it and falls back to the shipped schedule, so the evidence-derived
330
+ * schedule would be overridden by nothing.
268
331
  */
269
- export const TRANSIENT_RETRY_DELAYS_MS = [1_000, 3_000];
332
+ export function transientRetryScheduleOverridden(env = process.env) {
333
+ const raw = env.NUVIRA_TRANSIENT_RETRY_DELAYS_MS;
334
+ if (raw === undefined || raw.trim() === '')
335
+ return false;
336
+ return raw.split(',').some((part) => {
337
+ const n = Number(part.trim());
338
+ return Number.isFinite(n) && n >= 0;
339
+ });
340
+ }
341
+ /**
342
+ * Bounded extra same-model patience for an `empty-response` — the "final verse"
343
+ * case, granted ONLY to a pair that has already delivered real work this turn.
344
+ *
345
+ * THE TWO CASES LOOK IDENTICAL TO THE CLASSIFIER AND MUST NOT BE TREATED ALIKE:
346
+ *
347
+ * - a BROKEN pair answers nothing, forever. The codebase already measured the
348
+ * cost of re-asking it: "5 empty responses on one model" in one turn, no
349
+ * failover, which is why `empty-response` was removed from
350
+ * `TRANSIENT_RETRY_TYPES` and why the handoff/walk is what remedies it.
351
+ * - a pair that just finished four GOOD steps and then returns nothing is
352
+ * having a blip at the END of real work — the expensive case, because the
353
+ * turn already paid for the context, the tool results and the edits, and a
354
+ * single empty reply would otherwise discard all of it.
355
+ *
356
+ * The discriminator is EVIDENCE, not a heuristic on the reply. Two halves, both
357
+ * required to be absent before we give up on a pair: a completed tool call in
358
+ * THIS turn (`threadHasDeliveredWork`), and the registry's own record for the
359
+ * pair — verified by TELEMETRY or a spot-check, with a low error rate
360
+ * (`hasProvenTrackRecord` in `retry-patience.ts`, which is where the rule now
361
+ * lives so it cannot drift). So a pair that answers nothing from the start with
362
+ * no track record still fails over immediately, a proven pair's FIRST empty
363
+ * reply gets these two short waits, and a proven pair whose only alternatives
364
+ * would DE-ESCALATE gets the longer
365
+ * `EMPTY_PATIENCE_WAIT_BEATS_FOLDING_MS` schedule rather than folding down a
366
+ * capability band. Bounded by design, and always finite.
367
+ *
368
+ * The value is re-exported from `retry-patience.ts`: ONE table for the whole
369
+ * blip policy, so the CLI and any other caller cannot drift apart.
370
+ */
371
+ export const EMPTY_RESPONSE_PATIENCE_MS = EMPTY_PATIENCE_MS;
372
+ /**
373
+ * Sleep that a CANCELLED turn interrupts.
374
+ *
375
+ * The old retry slept with a bare `setTimeout`, so an Esc during a backoff was
376
+ * only honored when the timer happened to fire — with a 30s step in the
377
+ * schedule that is a 30s delay before the turn admits it was cancelled. This
378
+ * resolves the moment the signal aborts, so the caller's abort checks see the
379
+ * cancellation immediately.
380
+ */
381
+ function abortableSleep(ms, signal) {
382
+ if (!signal)
383
+ return new Promise((resolve) => setTimeout(resolve, ms));
384
+ if (signal.aborted)
385
+ return Promise.resolve();
386
+ return new Promise((resolve) => {
387
+ const finish = () => {
388
+ clearTimeout(timer);
389
+ signal.removeEventListener('abort', finish);
390
+ resolve();
391
+ };
392
+ const timer = setTimeout(finish, ms);
393
+ signal.addEventListener('abort', finish, { once: true });
394
+ });
395
+ }
396
+ /**
397
+ * Has this turn already produced real, OBSERVED work on this thread?
398
+ *
399
+ * Structural, not a counter: an assistant step that ASKED for a tool and the
400
+ * tool result that came back for it. That is the cheapest honest evidence that
401
+ * the pair under us has already served this turn — a pair that has not is given
402
+ * no `empty-response` patience at all, so the "ask a broken model five times"
403
+ * loop stays impossible.
404
+ */
405
+ export function threadHasDeliveredWork(thread) {
406
+ const asked = thread.some((m) => m.role === 'assistant' && (m.toolCalls?.length ?? 0) > 0);
407
+ const answered = thread.some((m) => m.role === 'tool');
408
+ return asked && answered;
409
+ }
410
+ /**
411
+ * The pairs this surface could actually switch to right now, best-first, for
412
+ * {@link planBlipPatience}.
413
+ *
414
+ * DELIBERATELY NARROW. Only providers with at least one registry-VERIFIED model
415
+ * count, and the model offered is the one that provider would actually lead
416
+ * with (`preferredModelsFor`'s health order). A provider we cannot name a model
417
+ * for is not evidence of a same-band alternative — treating a cold-start or
418
+ * unverified provider as one would SHORTEN patience on the strength of a guess,
419
+ * and the whole point of the policy is that waiting is better than an
420
+ * unproven downgrade. Unknown therefore reads as "no known alternative", the
421
+ * conservative direction.
422
+ *
423
+ * Pure read, best-effort: any failure to rank means the same thing (no known
424
+ * alternative), never a thrown turn.
425
+ */
426
+ function failoverAlternatives(configManager, excludeProvider) {
427
+ const out = [];
428
+ try {
429
+ for (const ranked of rankAvailableProviders(configManager)) {
430
+ if (ranked.provider === excludeProvider)
431
+ continue;
432
+ const model = ranked.verifiedModels[0];
433
+ if (!model)
434
+ continue;
435
+ out.push({ provider: ranked.provider, model });
436
+ }
437
+ }
438
+ catch {
439
+ // Best-effort — see the note above: unknown is the conservative reading.
440
+ }
441
+ return out;
442
+ }
443
+ /**
444
+ * Registry evidence for one pair, shaped for {@link planBlipPatience}.
445
+ * `undefined` when nothing is recorded — an untracked pair earns no patience.
446
+ *
447
+ * The `default` sentinel is not a model id, so it has no evidence by definition
448
+ * (the same reasoning that keeps it out of `preferredModelsFor`).
449
+ */
450
+ function registryPatienceEvidence(provider, model) {
451
+ if (!model || model === 'default')
452
+ return undefined;
453
+ try {
454
+ const entry = getModelRegistry().getEntry(provider, model);
455
+ if (!entry)
456
+ return undefined;
457
+ return {
458
+ verified: entry.status === 'verified',
459
+ source: entry.source,
460
+ errorRate: entry.errorRate,
461
+ };
462
+ }
463
+ catch {
464
+ return undefined;
465
+ }
466
+ }
270
467
  /**
271
468
  * How long a PINNED + STRICT pair is given to answer before the user is asked
272
469
  * what to do next (ms).
@@ -333,18 +530,48 @@ const PINNED_DEFINITIVE_ERROR_RE = /(?:does not exist|not found|no such model|in
333
530
  *
334
531
  * Never retries: non-transient classes (auth, rate-limit, model/harness faults),
335
532
  * a cancelled turn, or once the schedule is exhausted.
533
+ *
534
+ * `emptyResponseDelaysMs` is the ONE narrowly-scoped exception, and it exists in
535
+ * this helper rather than in `TRANSIENT_RETRY_TYPES` because retryability here
536
+ * depends on the TURN, not only on the error class: pass it only when the pair
537
+ * has a track record this turn (see {@link threadHasDeliveredWork}). Omitted →
538
+ * an empty response is not retried at all, byte for byte the old behaviour.
539
+ *
540
+ * `transientDelaysMs` is the caller's EVIDENCE-DERIVED schedule
541
+ * ({@link planBlipPatience}) for server/network/timeout failures. Omitted → the
542
+ * shipped default / env dial (`transientRetryDelaysMs`), which is what a caller
543
+ * with no evidence (a test, a headless path) gets.
336
544
  */
337
- export async function generateWithTransientRetry(attempt, signal, onRetry) {
338
- for (let i = 0;; i += 1) {
545
+ export async function generateWithTransientRetry(attempt, signal, onRetry, opts = {}) {
546
+ let transientRetries = 0;
547
+ let emptyRetries = 0;
548
+ const emptySchedule = opts.emptyResponseDelaysMs ?? [];
549
+ const transientSchedule = opts.transientDelaysMs ?? transientRetryDelaysMs();
550
+ for (;;) {
339
551
  try {
340
552
  return await attempt();
341
553
  }
342
554
  catch (err) {
343
- const canRetry = i < TRANSIENT_RETRY_DELAYS_MS.length && isTransientForRetry(err);
344
- if (!canRetry || signal?.aborted)
555
+ if (signal?.aborted)
556
+ throw err;
557
+ // The attempt number a human sees: 1-based, counting every retry so far.
558
+ const attemptNumber = transientRetries + emptyRetries + 1;
559
+ let delay;
560
+ if (isTransientForRetry(err)) {
561
+ delay = transientRetries < transientSchedule.length
562
+ ? transientSchedule[transientRetries++]
563
+ : undefined;
564
+ }
565
+ else if (classifyFallbackError(err) === 'empty-response' && emptyRetries < emptySchedule.length) {
566
+ delay = emptySchedule[emptyRetries++];
567
+ }
568
+ if (delay === undefined)
569
+ throw err;
570
+ onRetry?.(attemptNumber, err);
571
+ await abortableSleep(delay, signal);
572
+ // A cancelled turn must not pay for another attempt.
573
+ if (signal?.aborted)
345
574
  throw err;
346
- onRetry?.(i + 1, err);
347
- await new Promise((resolve) => setTimeout(resolve, TRANSIENT_RETRY_DELAYS_MS[i]));
348
575
  }
349
576
  }
350
577
  }
@@ -3398,13 +3625,54 @@ export class ChatCommand extends BaseCommand {
3398
3625
  // R2 — this IS the fallback transport, so say so: the same fact the
3399
3626
  // subagent child announces, and what makes a chat turn comparable with it.
3400
3627
  return answered({ content: text, toolCalls: calls, transport: 'json' });
3628
+ }; // ── HOW PATIENT TO BE, AND WHY ────────────────────────────────────────
3629
+ // A fixed schedule cannot tell a 2-second blink from a dead endpoint, so
3630
+ // the schedule is chosen per pair from evidence the caller ALREADY HAS:
3631
+ // is a same-or-better-band candidate reachable, does this pair have a
3632
+ // track record, and has it already failed an EARLIER step of this turn.
3633
+ // Both ways of getting this wrong are expensive — fail fast and a
3634
+ // blinking strong model is silently replaced by a weaker one nobody
3635
+ // chose; wait blindly and every user of a dead provider pays the full
3636
+ // backoff. The plan's `reason` goes to the debug log, because "why was
3637
+ // that turn slow / why did it change model" is asked after the fact.
3638
+ const patiencePair = {
3639
+ provider: session.type,
3640
+ model: this.lastAttempt?.model ?? session.model ?? 'default',
3401
3641
  };
3642
+ const patience = planBlipPatience({
3643
+ current: patiencePair,
3644
+ alternatives: failoverAlternatives(this.configManager, patiencePair.provider),
3645
+ evidence: registryPatienceEvidence(patiencePair.provider, patiencePair.model),
3646
+ // An entry here means an EARLIER step of this same turn already failed
3647
+ // on this pair — the same pair failing twice is a pattern, not a blip,
3648
+ // and patience collapses there whatever the alternatives look like.
3649
+ failedEarlierStep: this.turnAttempts.some((a) => a.provider === patiencePair.provider && a.model === patiencePair.model),
3650
+ deliveredWorkThisTurn: threadHasDeliveredWork(messages),
3651
+ });
3652
+ try {
3653
+ logger.debug(`[patience] ${patience.reason}`);
3654
+ }
3655
+ catch {
3656
+ // Logging must never break a turn.
3657
+ }
3402
3658
  try {
3403
- // Same-provider transient retry FIRST (see the helper's contract): a
3404
- // 503 spike at a shared endpoint must not become a dead run. Failover
3405
- // only helps if a DIFFERENT provider exists — and it also hides the real
3406
- // failure from the user while parking a healthy provider for 120s.
3407
- return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) => logger.warn(` ⏳ ${session.provider.name} transient failure (attempt ${attempt}) — retrying shortly: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`));
3659
+ return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) => logger.warn(` ⏳ ${session.provider.name} transient failure (attempt ${attempt}) — retrying shortly: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`),
3660
+ // Empty-response patience from the plan: granted for a proven pair
3661
+ // (registry track record or real work this turn), LONGER when every
3662
+ // alternative would de-escalate a capability band. A pair with neither
3663
+ // gets none — the measured "5 empty responses on one model" loop stays
3664
+ // impossible — while a pair that just finished real steps never has
3665
+ // them thrown away by a single empty reply.
3666
+ {
3667
+ // The env dial stays authoritative when set (tests, headless
3668
+ // gateways, metered connections) — it is an explicit configuration
3669
+ // decision, and the attempt COUNTS in the parity/E2E contracts are
3670
+ // derived from it.
3671
+ transientDelaysMs: transientRetryScheduleOverridden()
3672
+ ? undefined
3673
+ : patience.transientDelaysMs,
3674
+ emptyResponseDelaysMs: patience.emptyResponseDelaysMs,
3675
+ });
3408
3676
  }
3409
3677
  catch (err) {
3410
3678
  // P4 — the model this turn STARTED on failed. Recorded with a human
@@ -3419,13 +3687,29 @@ export class ChatCommand extends BaseCommand {
3419
3687
  kind: primaryKind,
3420
3688
  reason: describeFailureKind(primaryKind),
3421
3689
  });
3690
+ // ── LEARN the failure, don't just survive it ───────────────────────────
3691
+ // A failed attempt was recorded in an in-memory per-turn list only, so
3692
+ // the registry's health score, the quota timeline and the shared
3693
+ // breaker all stayed untouched: the model kept its `verified` badge and
3694
+ // was re-picked on the next turn, and a degraded turn left no on-disk
3695
+ // evidence of WHICH candidate failed or WHY. `recordAutoProviderFailure`
3696
+ // runs the full shared composition (model-scoped session exclusion,
3697
+ // registry write-through, quota-timeline event, shared breaker) exactly
3698
+ // as the empty-response handoff already does.
3699
+ try {
3700
+ this.recordAutoProviderFailure(this.lastAttempt?.provider ?? session.type, err, this.lastAttempt?.model ?? session.model);
3701
+ }
3702
+ catch {
3703
+ // Best-effort — bookkeeping must never break the turn.
3704
+ }
3422
3705
  // Auto mode: fail over across the ranked candidates (never stuck).
3423
3706
  if (mode.auto) {
3424
3707
  const firstType = session.type;
3425
3708
  // Observable telemetry, identical to the shared single-shot runner
3426
3709
  // (the E2E repair-failover contract): one warn per walk, one
3427
- // success log per landed candidate.
3428
- logger.warn(` ⚠️ ${session.provider.name} failed — trying the next auto candidate...`);
3710
+ // success log per landed candidate — and now the failure KIND in the
3711
+ // line, so the console says what went wrong instead of only that it did.
3712
+ logger.warn(` ⚠️ ${session.provider.name} failed (${describeFailureKind(primaryKind)}) — trying the next auto candidate...`);
3429
3713
  const failed = new Set([session.type]);
3430
3714
  // Intent-aware escalation for the FAILOVER walk (the live junk path):
3431
3715
  // a mid-turn failure used to re-route on the bare message alone, so a
@@ -3503,6 +3787,21 @@ export class ChatCommand extends BaseCommand {
3503
3787
  kind: nextKind,
3504
3788
  reason: describeFailureKind(nextKind),
3505
3789
  });
3790
+ // ── DIAGNOSABILITY: every failed candidate gets a reason LINE and
3791
+ // a REGISTRY write, not only an in-memory entry. A live turn
3792
+ // walked gemini → openrouter → groq → local and left NO on-disk
3793
+ // record of why the first three died — the walk was undebuggable
3794
+ // and those candidates never lost health, so the router kept
3795
+ // offering them. `recordAutoProviderFailure` writes the pair's
3796
+ // own row (and, for a rate-limit, parks it for the real reset
3797
+ // window) while leaving a healthy provider's siblings routable.
3798
+ try {
3799
+ this.recordAutoProviderFailure(next.type, nextErr, next.model);
3800
+ }
3801
+ catch {
3802
+ // Best-effort — bookkeeping must never break the turn.
3803
+ }
3804
+ logger.warn(` ⚠️ ${next.type}/${next.model ?? 'default'} failed (${describeFailureKind(nextKind)}) — trying the next auto candidate...`);
3506
3805
  // Next candidate.
3507
3806
  }
3508
3807
  }
@@ -3608,6 +3907,10 @@ export class ChatCommand extends BaseCommand {
3608
3907
  // gate `loop-executor`'s pinned pool applies.
3609
3908
  if (!hasCredentials(this.configManager, fbType))
3610
3909
  continue;
3910
+ // The model THIS candidate actually used — hoisted so the failure
3911
+ // handler below can name the pair without relying on block-scoped
3912
+ // bindings from the `try` body.
3913
+ let fbAttemptedModel;
3611
3914
  try {
3612
3915
  const resolved = resolveProvider(this.configManager, fbType);
3613
3916
  // The fallback provider gets ITS OWN model, not the primary's
@@ -3639,6 +3942,7 @@ export class ChatCommand extends BaseCommand {
3639
3942
  `${pinnedType}/${pinnedModel ?? 'default'}.`);
3640
3943
  }
3641
3944
  const fallbackModel = pick?.model ?? baseModel;
3945
+ fbAttemptedModel = fallbackModel;
3642
3946
  const resp = await tryGenerate(resolved.provider, resolved.type, fallbackModel);
3643
3947
  // ── The pair that ANSWERED is the pair the turn ran on ───────
3644
3948
  // This walk used to serve the fallback and leave `session` still
@@ -3673,7 +3977,27 @@ export class ChatCommand extends BaseCommand {
3673
3977
  logger.warn(` 🔀 ${pinnedType} was unavailable — answered from ${resolved.provider.name} (${fallbackModel ?? 'default'})`);
3674
3978
  return resp;
3675
3979
  }
3676
- catch {
3980
+ catch (fbErr) {
3981
+ // DIAGNOSABILITY (pinned walk): name and LEARN each failed
3982
+ // fallback candidate instead of swallowing it. The silent catch
3983
+ // meant a pinned turn that stepped through three dead providers
3984
+ // reported only the primary error, and none of those providers
3985
+ // lost health — the same undebuggable-walk defect the auto walk
3986
+ // had.
3987
+ const fbKind = classifyFallbackError(fbErr);
3988
+ this.recordTurnAttempt({
3989
+ provider: fbType,
3990
+ model: fbAttemptedModel ?? 'default',
3991
+ kind: fbKind,
3992
+ reason: describeFailureKind(fbKind),
3993
+ });
3994
+ try {
3995
+ this.recordAutoProviderFailure(fbType, fbErr, fbAttemptedModel);
3996
+ }
3997
+ catch {
3998
+ // Best-effort — bookkeeping must never break the turn.
3999
+ }
4000
+ logger.warn(` ⚠️ ${fbType}/${fbAttemptedModel ?? 'default'} failed (${describeFailureKind(fbKind)}) — trying the next fallback...`);
3677
4001
  // Next fallback candidate.
3678
4002
  }
3679
4003
  }