pi-goal-list-loop-audit 0.35.4 → 0.35.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.
@@ -104,6 +104,7 @@ import {
104
104
  writeGoalMd,
105
105
  writeQueueItemFile,
106
106
  readQueueFromDisk,
107
+ clearQueueItemFiles,
107
108
  deleteQueueItemFile,
108
109
  missingGllaTools,
109
110
  runPersistStep,
@@ -392,6 +393,11 @@ let extensionApiStale = false;
392
393
  // path must still ledger the stale handle, stop stale work, and preserve the
393
394
  // interrupt marker so a later fresh lifecycle can restore it.
394
395
  let staleTerminalDone = false;
396
+ // v0.35.x: a silent host-handle death is a coalesced recovery boundary. Keep
397
+ // the durable interrupt marker for an orphan with no successor, but remember
398
+ // that the first genuinely fresh lifecycle/turn event may re-arm the goal
399
+ // immediately instead of leaving it parked behind that marker.
400
+ let staleContinuationRearmPending = false;
395
401
  // v0.34.19: delayed session-owned callbacks capture this generation. A
396
402
  // clearTimeout can race a callback already queued by Node; without a
397
403
  // generation check, an old compaction/refire callback can run after /reload
@@ -588,6 +594,10 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
588
594
  // "invalidated without delivering a replacement" case the user keeps
589
595
  // hitting. The field distribution in the ledger now separates proper
590
596
  // session cycles from genuine host losses.
597
+ const invalidationReason = classifySessionHandleInvalidation({
598
+ sessionHandoffPending,
599
+ mainModelRecoveryActive: mainModelRecoveryActive(),
600
+ });
591
601
  appendLedger(ctx.cwd, "session_handle_invalidated", {
592
602
  where,
593
603
  kind: isLoopActive() ? "loop" : "goal",
@@ -596,6 +606,20 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
596
606
  mainModelRecoveryActive: mainModelRecoveryActive(),
597
607
  }),
598
608
  });
609
+ // A silent death is the coalescing boundary: retain the durable interrupt
610
+ // marker for an orphan with no successor, but let the first fresh host
611
+ // lifecycle/turn event consume it and re-arm the continuation. Provider
612
+ // disconnects and announced shutdowns keep their existing recovery policy.
613
+ staleContinuationRearmPending = invalidationReason === "silent_handle_death"
614
+ && state.goal?.status === "active"
615
+ && state.goal.autoContinue !== false;
616
+ if (staleContinuationRearmPending) {
617
+ appendLedger(ctx.cwd, "stale_continuation_rearm_armed", {
618
+ goalId: state.goal?.id,
619
+ where,
620
+ reason: invalidationReason,
621
+ });
622
+ }
599
623
  const guidance = "pi invalidated this session's extension handle without delivering a replacement session. glla stopped stale sends and kept the work safe in .pi-glla/. Use /new to create a fresh context; its session_start will resume the work. If /new does not create one, restart pi normally and glla will restore the saved work.";
600
624
  // v0.35.x: an orphaned detached completion audit is not allowed to leave
601
625
  // the durable goal in AUDITING. Release the MAIN-side wait immediately and
@@ -628,6 +652,28 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
628
652
  try { notifyExternal(ctx, `glla: extension api stale — waiting for a fresh session_start; restart pi normally only if no replacement arrives. (${where})`); } catch { /* stale notifier is best effort */ }
629
653
  }
630
654
 
655
+ /** Consume the one-shot coalesced recovery armed by goStaleTerminal. The
656
+ * caller must already have a context that belongs to the fresh host and must
657
+ * schedule the appropriate continuation after this returns true. */
658
+ function consumeStaleContinuationRearm(ctx: ExtensionContext, via: string): boolean {
659
+ if (!staleContinuationRearmPending) return false;
660
+ staleContinuationRearmPending = false;
661
+ const goal = state.goal;
662
+ if (!goal || goal.status !== "active" || goal.autoContinue === false) {
663
+ appendLedger(ctx.cwd, "stale_continuation_rearm_skipped", { via, reason: "goal-not-actionable" });
664
+ return false;
665
+ }
666
+ const wasInterrupted = Boolean(goal.interruptedAt);
667
+ if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
668
+ appendLedger(ctx.cwd, "stale_continuation_rearmed", {
669
+ goalId: goal.id,
670
+ via,
671
+ clearedInterrupt: wasInterrupted,
672
+ });
673
+ postRestoreGraceTurns = 2;
674
+ return true;
675
+ }
676
+
631
677
  /** v0.34.16: lifecycle handoff replaces terminal keystroke injection. A
632
678
  * stale extension cannot call pi, so recovery must cross the lifecycle
633
679
  * boundary: session_shutdown records durable resume debt, clears every timer
@@ -948,6 +994,7 @@ function emitIdInvalidation(ctx: ExtensionContext, oldId: string | null, newId:
948
994
  * between stale scenarios. Never called by production code. */
949
995
  export function __testOnlyResetStaleFlag(): void {
950
996
  extensionApiStale = false;
997
+ staleContinuationRearmPending = false;
951
998
  }
952
999
 
953
1000
  /** TEST-ONLY hook (GitHub #4): the last draft dialog rendered through the
@@ -967,6 +1014,7 @@ export function __testOnlyLastConfirmDialog(): { title: string; body: string; op
967
1014
  * when an earlier file latched them. Never called by production code. */
968
1015
  export function __testOnlyResetTerminalFlags(): void {
969
1016
  staleTerminalDone = false;
1017
+ staleContinuationRearmPending = false;
970
1018
  zombieStoodDown = false;
971
1019
  sessionHandoffPending = false;
972
1020
  }
@@ -1004,6 +1052,16 @@ export async function __testOnlyRunFanOutListAuditFindings(cwd: string): Promise
1004
1052
  await fanOutListAuditFindings(cwd, sessionGeneration);
1005
1053
  }
1006
1054
 
1055
+ function isCtxAlive(ctx: ExtensionContext | null | undefined): boolean {
1056
+ if (!ctx) return false;
1057
+ try {
1058
+ ctx.isIdle();
1059
+ return true;
1060
+ } catch {
1061
+ return false;
1062
+ }
1063
+ }
1064
+
1007
1065
  /** v0.28.1 (S3): side-effect-free staleness probe — getSessionName()
1008
1066
  * routes through pi's assertActive() and throws the stale signature iff
1009
1067
  * pi invalidated this factory handle (session replacement). A positive
@@ -1014,6 +1072,11 @@ export async function __testOnlyRunFanOutListAuditFindings(cwd: string): Promise
1014
1072
  * (field: hegemon 2026-08-06 — one probe failure parked a live session for
1015
1073
  * 5 hours). */
1016
1074
  function probeExtensionApiStaleRaw(): boolean {
1075
+ // The ExtensionAPI and ExtensionContext can disagree briefly: a live
1076
+ // context does NOT prove that the captured `pi` can still send. In
1077
+ // particular, a stale send can latch the API while ctx.isIdle() continues
1078
+ // to succeed. Always probe the API itself here or the heartbeat will
1079
+ // repeatedly "recover" and immediately fail the next continuation send.
1017
1080
  if (!extensionApi) return false;
1018
1081
  try {
1019
1082
  extensionApi.getSessionName();
@@ -1024,6 +1087,9 @@ function probeExtensionApiStaleRaw(): boolean {
1024
1087
  }
1025
1088
 
1026
1089
  function probeExtensionApiStale(): boolean {
1090
+ // A stale ExtensionAPI is terminal for this factory instance. A healthy
1091
+ // context probe must not clear this latch: the next send would still use
1092
+ // the same captured API and throw again.
1027
1093
  if (extensionApiStale) return true;
1028
1094
  if (probeExtensionApiStaleRaw()) extensionApiStale = true;
1029
1095
  return extensionApiStale;
@@ -1182,10 +1248,17 @@ function resolveCarryover(ctx: ExtensionContext, trigger: "goal" | "loop" | "lis
1182
1248
  } else if (snap.pausedGoal) {
1183
1249
  waiting.push(`paused goal "${displaySlice(snap.pausedGoal, 60)}" (${workCommand(snap.pausedGoalPolicy, "resume")})`);
1184
1250
  }
1185
- if (snap.listCount > 0) {
1251
+ if (snap.listCount > 0 || policy === "clear") {
1186
1252
  if (policy === "clear") {
1253
+ // Carryover clear must remove the durable union, not only RAM. The
1254
+ // stale-handle /list reader intentionally resurrects queue sidecars.
1255
+ const clearedSidecars = clearQueueItemFiles(ctx.cwd);
1187
1256
  replaceState({ ...state, list: [] });
1188
- done.push(`dropped ${snap.listCount} waiting list item(s)`);
1257
+ if (snap.listCount > 0) done.push(`dropped ${snap.listCount} waiting list item(s)`);
1258
+ if (clearedSidecars.removed > 0) done.push(`removed ${clearedSidecars.removed} durable queue sidecar(s)`);
1259
+ if (clearedSidecars.failed.length > 0) {
1260
+ waiting.push(`${clearedSidecars.failed.length} queue sidecar(s) could not be removed — clear is incomplete`);
1261
+ }
1189
1262
  } else {
1190
1263
  waiting.push(`${snap.listCount} waiting list item(s) (/list next)`);
1191
1264
  }
@@ -1363,6 +1436,10 @@ function tryAbsorbHostSuccessor(ctx: ExtensionContext, via: string): boolean {
1363
1436
  ctx.ui.notify("glla: pi replaced this session without delivering session_start — absorbed the live replacement as the goal-plane owner (in-memory subagent sessions stay refused).", "info");
1364
1437
  startHeartbeat();
1365
1438
  heartbeatStaleStreak = 0;
1439
+ const staleRearmed = consumeStaleContinuationRearm(ctx, via);
1440
+ if (staleRearmed) {
1441
+ ctx.ui.notify(`glla: fresh host event re-armed ${state.goal?.policy === "list" ? "the list item" : "the goal"} continuation after the stale-handle boundary.`, "info");
1442
+ }
1366
1443
  if (state.goal && state.goal.status === "active") {
1367
1444
  const wasInterrupted = Boolean(state.goal.interruptedAt);
1368
1445
  if (wasInterrupted) {
@@ -1407,9 +1484,11 @@ function selfHealStaleSameSession(ctx: ExtensionContext): boolean {
1407
1484
  if (owner && owner.pid === process.pid && typeof owner.instanceId === "string" && owner.instanceId !== instanceId) {
1408
1485
  return false; // a successor module instance owns this cwd — never re-claim
1409
1486
  }
1410
- // The handle must actually be healthy NOW — the fresh probe, not the
1411
- // latched cache. A null api means we cannot send anything anyway.
1412
- if (!extensionApi || probeExtensionApiStaleRaw()) return false;
1487
+ // Both handles must actually be healthy NOW. A live ExtensionContext does
1488
+ // not prove that the captured ExtensionAPI can send; Pi can leave ctx
1489
+ // probes answering while the API has already latched stale. Reclaim only
1490
+ // when the exact API used by continuation sends also passes its probe.
1491
+ if (!isCtxAlive(ctx) || !extensionApi || probeExtensionApiStaleRaw()) return false;
1413
1492
  const was = extensionApiStale ? "extension_api_stale" : sessionHandoffPending ? "session_handoff_pending" : "stale_terminal_done";
1414
1493
  extensionApiStale = false;
1415
1494
  sessionHandoffPending = false;
@@ -1425,6 +1504,13 @@ function selfHealStaleSameSession(ctx: ExtensionContext): boolean {
1425
1504
  appendLedger(ctx.cwd, "stale_self_healed", { was, via: "same-session command", generation: sessionGeneration });
1426
1505
  startHeartbeat();
1427
1506
  startUITicker();
1507
+ const staleRearmed = consumeStaleContinuationRearm(ctx, "same-session command");
1508
+ if (staleRearmed) {
1509
+ ctx.ui.notify(`glla: the healthy handle re-armed ${state.goal?.policy === "list" ? "the list item" : "the goal"} continuation after the stale-handle boundary.`, "info");
1510
+ if (state.goal?.status === "active" && !continuationTimerPending() && !pendingContinuationDispatchRef()) {
1511
+ scheduleContinuation(ctx, true);
1512
+ }
1513
+ }
1428
1514
  const auditRetryStarted = state.goal?.status === "paused"
1429
1515
  && state.goal.pendingCompletion?.phase === "recovery-pending"
1430
1516
  && typeof maybeAutoRetryParkedCompletionAudit === "function"
@@ -1432,23 +1518,16 @@ function selfHealStaleSameSession(ctx: ExtensionContext): boolean {
1432
1518
  if (auditRetryStarted) {
1433
1519
  ctx.ui.notify("glla: the stale handle recovered in place — retrying the stored no-verdict completion audit once.", "info");
1434
1520
  } else if (state.goal && state.goal.status === "active" && state.goal.interruptedAt) {
1435
- // Mirror the session-load restore gate: autoResume=on (unattended rigs)
1436
- // resumes; the hold-everything default keeps the interrupt marker and
1437
- // asks for an explicit resume.
1438
- const autoResumeSetting = resolveEffectiveAggressiveSettings(loadGlobalSettings()).autoResume;
1439
- if (autoResumeSetting) {
1440
- updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
1441
- ctx.ui.notify(
1442
- `Recovered from the stale-handle park — the handle is healthy again. Resuming ${state.goal.policy === "list" ? "list item" : "goal"}: ${displaySlice(state.goal.objective, 70)}`,
1443
- "info",
1444
- );
1445
- postRestoreGraceTurns = 2;
1521
+ // A live context from the same session proves the session was not lost.
1522
+ // Clear the spurious interrupt marker so the UI does not show 'host session lost'.
1523
+ updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
1524
+ ctx.ui.notify(
1525
+ `Recovered from the stale-handle park — the handle is healthy again. Resuming ${state.goal.policy === "list" ? "list item" : "goal"}: ${displaySlice(state.goal.objective, 70)}`,
1526
+ "info",
1527
+ );
1528
+ postRestoreGraceTurns = 2;
1529
+ if (!staleRearmed && !continuationTimerPending() && !pendingContinuationDispatchRef() && state.goal.autoContinue !== false) {
1446
1530
  scheduleContinuation(ctx, true);
1447
- } else {
1448
- ctx.ui.notify(
1449
- `Recovered from the stale-handle park — the handle is healthy again. ${activeGoalSurfaceCommand("resume")} continues the interrupted ${state.goal.policy === "list" ? "list item" : "goal"}.`,
1450
- "info",
1451
- );
1452
1531
  }
1453
1532
  } else {
1454
1533
  ctx.ui.notify("glla: recovered from the stale-handle park — the handle is healthy again; the goal plane is live.", "info");
@@ -1585,6 +1664,7 @@ defineGoalRuntimeGlobal("writeOwnerFile", { get: () => writeOwnerFile });
1585
1664
  defineGoalRuntimeGlobal("readOwnerFile", { get: () => readOwnerFile });
1586
1665
  defineGoalRuntimeGlobal("absorbStaleIfSuperseded", { get: () => absorbStaleIfSuperseded });
1587
1666
  defineGoalRuntimeGlobal("goStaleTerminal", { get: () => goStaleTerminal });
1667
+ defineGoalRuntimeGlobal("consumeStaleContinuationRearm", { get: () => consumeStaleContinuationRearm });
1588
1668
  defineGoalRuntimeGlobal("SESSION_HANDOFF_FILE", { get: () => SESSION_HANDOFF_FILE });
1589
1669
  defineGoalRuntimeGlobal("SESSION_HANDOFF_VERSION", { get: () => SESSION_HANDOFF_VERSION });
1590
1670
  defineGoalRuntimeGlobal("SESSION_HANDOFF_FRESH_MS", { get: () => SESSION_HANDOFF_FRESH_MS });
@@ -173,19 +173,10 @@ import {
173
173
  tickLengthContinue,
174
174
  } from "../length-continue.js";
175
175
  import {
176
- classifyMainModelFailure,
177
- mainModelAutoRetryUntil,
178
- mainModelFailureDelayMs,
179
- mainModelRetryDelayMs,
180
- MAIN_MODEL_AUTO_RETRY_HORIZON_MS,
181
176
  MAX_MAIN_MODEL_FALLBACKS,
182
177
  modelRef,
183
- nextUntriedModelRef,
184
178
  normalizeModelRefs,
185
179
  normalizeMainModelFallbackRefs,
186
- sendStormEscalateMs,
187
- splitModelRef,
188
- type MainModelFailure,
189
180
  } from "../main-model-recovery.js";
190
181
  import {
191
182
  globalSettingsPath,
@@ -196,6 +187,7 @@ import {
196
187
  settingsProvenance,
197
188
  type Settings,
198
189
  } from "../goal-settings.js";
190
+ import { ModelSelector } from "../model-selector.js";
199
191
  import {
200
192
  curateAuditReviewSources,
201
193
  normalizeObjective,
@@ -372,7 +364,7 @@ import {
372
364
  } from "../goal-loop.js";
373
365
  import { defineGoalRuntimeGlobal } from "./goal-runtime-globals.js";
374
366
 
375
- type AuditorModelCandidate = any;
367
+ type AuditorModelCandidate = { ref?: string; model: any; via: string };
376
368
 
377
369
  function auditorThinkingLevels(model: any): string[] {
378
370
  const ALL = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
@@ -449,77 +441,110 @@ const THINKING_DESCR: Record<string, string> = {
449
441
  max: "maximum reasoning",
450
442
  };
451
443
 
452
- function resolveAuditorModel(ctx: ExtensionContext, ref?: string, fallbackRef?: string, sameSessionSwap = true): { model: any; error?: string; via?: string; fallbackModels?: AuditorModelCandidate[] } {
444
+ export function resolveAuditorModel(
445
+ ctx: ExtensionContext,
446
+ ref?: string,
447
+ fallbackRef?: string,
448
+ sameSessionSwap = true,
449
+ ): { model: any; error?: string; via?: string; fallbackModels?: AuditorModelCandidate[] } {
453
450
  const sessionModel = ctx.model as any;
451
+ const currentRef = modelRef(sessionModel);
454
452
  const tryRef = (trimmed: string): { model?: any; reason?: string } => {
455
453
  const slash = trimmed.indexOf("/");
456
454
  if (slash > 0) {
457
455
  const provider = trimmed.slice(0, slash);
458
456
  const model = ctx.modelRegistry.find(provider, trimmed.slice(slash + 1));
459
457
  if (!model) return { reason: "model not found" };
460
- // v0.29.17: an unkeyed provider counts as unavailable. This is a
461
- // registry/auth distinction only; recovery itself remains generic.
458
+ // An unkeyed provider is unavailable to the detached worker even when
459
+ // the registry knows the model. Keep this check at the resolver edge;
460
+ // runtime failures are handled by the shared retry walker.
462
461
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) return { reason: `no configured auth for ${provider}` };
463
462
  return { model };
464
463
  }
465
464
  const matches = ctx.modelRegistry.getAvailable().filter((m: any) => m.id === trimmed || m.name === trimmed);
466
- return matches[0] ? { model: matches[0] } : { reason: "no available model matching" };
467
- };
468
- const isSession = (m: any) => sessionModel && m.provider === sessionModel.provider && m.id === sessionModel.id;
469
- const modelKey = (m: any): string => {
470
- if (!m || typeof m !== "object") return String(m ?? "(unset)");
471
- return `${m.provider ?? ""}/${m.id ?? ""}`;
465
+ const model = matches.find((candidate: any) => ctx.modelRegistry.hasConfiguredAuth(candidate));
466
+ return model ? { model } : { reason: "no available model matching" };
472
467
  };
468
+ const configuredRefs = normalizeMainModelFallbackRefs([
469
+ ...(ref?.trim() ? [ref.trim()] : []),
470
+ ...(fallbackRef?.trim() ? [fallbackRef.trim()] : []),
471
+ ]);
472
+ const settings = loadSettings(ctx.cwd);
473
+ const forbidden = (candidate: string): boolean => isForbiddenModel(candidate, settings.forbiddenModels);
474
+ const selector = new ModelSelector({
475
+ getChain: () => configuredRefs,
476
+ resolve: (candidate) => tryRef(candidate).model,
477
+ isForbidden: forbidden,
478
+ record: (event) => {
479
+ if (event.reason === "forbidden" && event.toRef) {
480
+ // Forbidden is an explicit user gate, not an unavailable-model
481
+ // warning. Keep the forensic ledger entry but do not nudge the UI.
482
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: event.toRef, reason: "forbidden" });
483
+ return;
484
+ }
485
+ if (event.reason !== "unregistered" || !event.toRef) return;
486
+ const reason = tryRef(event.toRef).reason ?? "model unavailable";
487
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: event.toRef, reason });
488
+ const display = providerErrorPresentation(reason, "completion").display;
489
+ ctx.ui.notify(`Auditor model "${event.toRef}" is unavailable (${display}) — trying the next fallback pin. Fix via /glla → Auditor model.`, "warning");
490
+ },
491
+ });
473
492
  const candidates: AuditorModelCandidate[] = [];
474
- const seen = new Set<string>();
475
- const addCandidate = (model: any, via: string): void => {
476
- const key = modelKey(model);
477
- if (seen.has(key)) return;
478
- seen.add(key);
479
- candidates.push({ model, via });
493
+ const attempted: string[] = [];
494
+ const seenModels = new Set<string>();
495
+ const addCandidate = (candidateRef: string, model: any, via: string): void => {
496
+ const key = modelRef(model)?.toLowerCase() ?? candidateRef.toLowerCase();
497
+ if (seenModels.has(key)) return;
498
+ seenModels.add(key);
499
+ candidates.push({ ref: candidateRef, model, via });
480
500
  };
481
- // v0.32.0: per-pin source labels — when the primary is unset, pins[0] IS
482
- // the fallback and the old i===0→"setting" map mislabeled it.
483
- const pins: Array<{ pin: string; src: "setting" | "fallback-pin" }> = [];
484
- if (ref?.trim()) pins.push({ pin: ref.trim(), src: "setting" });
485
- if (fallbackRef?.trim()) pins.push({ pin: fallbackRef.trim(), src: "fallback-pin" });
486
- for (let i = 0; i < pins.length; i++) {
487
- const { pin } = pins[i]!;
488
- const r = tryRef(pin);
489
- if (!r.model) {
490
- // Unavailable pin → cascade: next pin, then the session model (LOUD).
491
- appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pin, reason: r.reason });
492
- // v0.32.0: the last pin no longer pre-announces the session fallback —
493
- // the post-loop block does that (it notified twice before).
494
- const modelFailureCopy = providerErrorPresentation(r.reason, "completion");
495
- ctx.ui.notify(`Auditor model "${pin}" is unavailable (${modelFailureCopy.display})${i + 1 < pins.length ? " — trying the fallback pin" : ""}. Fix via /glla → Auditor model.`, "warning");
496
- continue;
497
- }
498
- if (sameSessionSwap && isSession(r.model) && i + 1 < pins.length) {
499
- // The pin IS the session model — the verifier would be the executor's
500
- // own model; auto-swap down the chain (the user's move).
501
- appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: pins[i + 1]!.pin });
502
- ctx.ui.notify(`Session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — auditor auto-swapped to ${pins[i + 1]!.pin} so the verifier differs.`, "info");
503
- continue;
501
+ const primaryRef = ref?.trim() && ref.trim().toLowerCase() !== "unset" ? configuredRefs[0] : undefined;
502
+ // Unlike main recovery, the auditor may deliberately use the session model
503
+ // when same-model swapping is disabled. Seed that configured primary before
504
+ // asking the selector to walk the remaining chain.
505
+ if (
506
+ !sameSessionSwap
507
+ && primaryRef
508
+ && currentRef
509
+ && primaryRef.toLowerCase() === currentRef.toLowerCase()
510
+ && !forbidden(primaryRef)
511
+ && sessionModel
512
+ ) {
513
+ addCandidate(primaryRef, sessionModel, "setting");
514
+ attempted.push(primaryRef);
515
+ }
516
+ for (;;) {
517
+ const selected = selector.selectNextValid(
518
+ { kind: "auditor" },
519
+ sameSessionSwap ? currentRef : undefined,
520
+ attempted,
521
+ );
522
+ for (const visited of selector.lastVisitedRefs) {
523
+ if (!attempted.some((entry) => entry.toLowerCase() === visited.toLowerCase())) attempted.push(visited);
504
524
  }
505
- // v0.32.0: the nudge must fire when the LAST pin stands on the session
506
- // model — the old `!fallbackRef` guard went SILENT when the fallback pin
507
- // itself resolved to the session model (verifier == executor, and hop 0's
508
- // notify had just claimed "auto-swapped so the verifier differs" — false).
509
- if (sameSessionSwap && isSession(r.model) && i + 1 >= pins.length) {
510
- // Last resort reached and it IS the session model, with no fallback
511
- // ever pinned — the model stands (the session IS the last resort);
512
- // one loud nudge so the user can wire the swap.
513
- appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: null });
514
- ctx.ui.notify(`The session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — pin a different /glla → Auditor fallback agent so the verifier can differ.`, "warning");
525
+ if (!("model" in selected) || typeof selected.ref !== "string") break;
526
+ const via = primaryRef && selected.ref.toLowerCase() === primaryRef.toLowerCase() ? "setting" : "fallback-pin";
527
+ addCandidate(selected.ref, selected.model, via);
528
+ if (!attempted.some((entry) => entry.toLowerCase() === selected.ref.toLowerCase())) attempted.push(selected.ref);
529
+ }
530
+ if (sessionModel && currentRef) addCandidate(currentRef, sessionModel, candidates.length > 0 ? "session-fallback" : "session");
531
+
532
+ const currentPinned = sameSessionSwap && currentRef
533
+ ? configuredRefs.find((candidate) => candidate.toLowerCase() === currentRef.toLowerCase())
534
+ : undefined;
535
+ if (currentPinned && currentRef) {
536
+ const replacement = candidates.find((candidate) => candidate.via === "fallback-pin" && candidate.ref?.toLowerCase() !== currentRef.toLowerCase());
537
+ appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: currentRef, fallback: replacement?.ref ?? null });
538
+ if (replacement?.ref) {
539
+ ctx.ui.notify(`Session model IS the pinned auditor (${currentRef}) — auditor auto-swapped to ${replacement.ref} so the verifier differs.`, "info");
540
+ } else {
541
+ ctx.ui.notify(`The session model IS the pinned auditor (${currentRef}) — pin a different /glla → Auditor fallback agent so the verifier can differ.`, "warning");
515
542
  }
516
- addCandidate(r.model, pins[i]!.src);
517
543
  }
518
- if (sessionModel) addCandidate(sessionModel, pins.length > 0 ? "session-fallback" : "session");
519
544
  if (candidates.length > 0) {
520
545
  const first = candidates[0]!;
521
546
  if (first.via === "session-fallback") {
522
- appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pins.map((p) => p.pin).join(" → ") || "(none)", reason: "all pins exhausted" });
547
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: configuredRefs.join(" → ") || "(none)", reason: "all pins exhausted" });
523
548
  ctx.ui.notify("All pinned auditor models are unavailable — falling back to the session model. Fix via /glla → Auditor model.", "warning");
524
549
  }
525
550
  return { model: first.model, via: first.via, fallbackModels: candidates.slice(1) };
@@ -750,8 +775,12 @@ async function promptModelRefs(
750
775
  // an invoked factory returning undefined is a genuine user cancel.
751
776
  if (pick === undefined && !factoryInvoked) return inputFallback();
752
777
  if (pick === undefined) return undefined;
753
- const normalizedPick = normalizeSelection(pick);
754
- const omitted = pick.filter((ref) => !normalizedPick.some((kept) => kept.toLowerCase() === ref.toLowerCase()));
778
+ // The picker keeps its legacy string[] result by default, but its optional
779
+ // inherit row returns a structured selection so inheritance is never
780
+ // mistaken for a provider/model ref at this boundary.
781
+ const pickedRefs = Array.isArray(pick) ? pick : pick.refs;
782
+ const normalizedPick = normalizeSelection(pickedRefs);
783
+ const omitted = pickedRefs.filter((ref) => !normalizedPick.some((kept) => kept.toLowerCase() === ref.toLowerCase()));
755
784
  const omittedCurrent: string[] = opts.currentRef
756
785
  ? omitted.filter((ref) => ref.toLowerCase() === opts.currentRef!.toLowerCase())
757
786
  : [];
@@ -908,6 +937,36 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
908
937
  }
909
938
  return;
910
939
  }
940
+ case "mainModelFailback": {
941
+ const v = await ctx.ui.select("Primary failback — what happens after a fallback turn succeeds", [
942
+ "auto — periodically probe the preferred primary and fail back when it works (default)",
943
+ "sticky — stay on the fallback until a manual model selection",
944
+ ]);
945
+ if (v) {
946
+ const sticky = v.startsWith("sticky");
947
+ saveSettings("global", ctx.cwd, { mainModelFailback: sticky ? "sticky" : undefined });
948
+ if (sticky && state.mainModelRecovery && (state.mainModelRecovery.primaryProbeAt || state.mainModelRecovery.primaryProbeInFlight)) {
949
+ clearMainModelRecoveryTimer();
950
+ state.mainModelRecovery = undefined;
951
+ persistState(ctx);
952
+ } else if (!sticky && state.mainModelRecovery?.primaryProbeAt) {
953
+ scheduleMainModelRecoveryTimer(ctx, Math.max(0, Date.parse(state.mainModelRecovery.primaryProbeAt) - Date.now()));
954
+ }
955
+ ctx.ui.notify(sticky ? "Primary failback STICKY — the current fallback stays selected until you switch manually." : "Primary failback AUTO — a healthy fallback will periodically test the preferred primary.", "info");
956
+ }
957
+ return;
958
+ }
959
+ case "mainModelPrimaryProbeMinutes": {
960
+ const v = await ctx.ui.input("Preferred-primary failback probe cadence", "positive integer minutes; empty = default 15");
961
+ if (v !== undefined) {
962
+ const raw = v.trim();
963
+ const n = Number.parseInt(raw, 10);
964
+ if (Number.isInteger(n) && n > 0) saveSettings("global", ctx.cwd, { mainModelPrimaryProbeMinutes: n });
965
+ else if (!raw) saveSettings("global", ctx.cwd, { mainModelPrimaryProbeMinutes: undefined });
966
+ else ctx.ui.notify(`primary probe minutes must be a positive integer, got: ${v}`, "warning");
967
+ }
968
+ return;
969
+ }
911
970
  case "drafterModel": {
912
971
  const pick = await promptModelRef(ctx, "Drafter agent — temporary agent for drafting only", "provider/model-id — empty keeps the current session model");
913
972
  if (pick === undefined) return;