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.
- package/CHANGELOG.md +7666 -0
- package/INSTALL.md +377 -0
- package/README.md +26 -13
- package/docs/DESIGN.md +31 -4
- package/docs/INDEX.md +32 -7
- package/examples/example-objective.md +119 -0
- package/extensions/faulty-objective-recovery.ts +13 -2
- package/extensions/goal-commands.ts +50 -6
- package/extensions/goal-heartbeat.ts +25 -21
- package/extensions/goal-loop-auditor-process.ts +184 -3
- package/extensions/goal-loop-core.ts +108 -15
- package/extensions/goal-loop-display.ts +82 -0
- package/extensions/goal-loop-shield.ts +55 -0
- package/extensions/goal-recovery.ts +317 -7
- package/extensions/goal-settings.ts +31 -1
- package/extensions/loops/goal-activation.ts +74 -7
- package/extensions/loops/goal-auditor-hooks.ts +30 -55
- package/extensions/loops/goal-list-queue.ts +18 -5
- package/extensions/loops/goal-orchestrator.ts +5 -0
- package/extensions/loops/goal-runtime-globals.ts +2 -0
- package/extensions/loops/goal-session.ts +101 -21
- package/extensions/loops/goal-settings-ui.ts +122 -63
- package/extensions/loops/goal-tools.ts +110 -36
- package/extensions/loops/goal-ui.ts +59 -2
- package/extensions/main-model-recovery.ts +24 -1
- package/extensions/model-picker.ts +3 -1
- package/extensions/model-selector.ts +2 -1
- package/extensions/multi-model-picker.ts +64 -7
- package/extensions/settings-menu.ts +16 -0
- package/package.json +4 -1
- package/prompts/goal-loop-continuation.md +11 -8
- package/prompts/goal-loop-draft.md +5 -5
- package/schemas/goal.schema.json +17 -0
|
@@ -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
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
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
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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(
|
|
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
|
-
//
|
|
461
|
-
// registry
|
|
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
|
-
|
|
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
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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:
|
|
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
|
-
|
|
754
|
-
|
|
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;
|