pi-goal-list-loop-audit 0.38.91 → 0.38.95
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 +19 -0
- package/docs/INDEX.md +1 -1
- package/extensions/goal-continuation.ts +21 -1
- package/extensions/goal-heartbeat.ts +10 -1
- package/extensions/goal-loop-core.ts +29 -2
- package/extensions/goal-recovery.ts +19 -2
- package/extensions/loops/goal-activation.ts +18 -6
- package/extensions/loops/goal-session.ts +13 -5
- package/extensions/main-model-recovery.ts +4 -0
- package/extensions/payload-guard.ts +28 -12
- package/extensions/quota-retry.ts +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.38.95 — Stop the pause cycles: image-count clamp, ledger dedupe (2026-09-23)
|
|
4
|
+
|
|
5
|
+
- The payload guard now clamps image blocks per request (default 4, the observed provider limit) as well as bytes — small screenshots fit the byte budget but 400'd on count (field 2026-09-21: `Image count 12 exceeds limit 4`). Oldest-first, keep-recent floor wins on conflict; eviction entries carry the remaining count (audit/PAUSES-2026-09-22.md).
|
|
6
|
+
- Identical `payload_guard_eviction` repeats are deduped per generation (719 identical entries/day in the field) and zombie stand-down logging is latched per silent episode like the abort key (219/day) — first occurrence lands, repeats are silent, changed values re-arm.
|
|
7
|
+
- NOT in this release: a "quiet wait" for single-model chains was implemented then reverted — the cycle-reset probe IS the retry mechanism, and parking it wedged recovery into a never-retry loop (blocked-pause-autoclear caught it). Single-model retries are correct as-is.
|
|
8
|
+
|
|
9
|
+
## 0.38.94 — Fix continuation spam: delta-only actually fires (2026-09-22)
|
|
10
|
+
|
|
11
|
+
- Field: 909 identical 28KB continuation prompts in one 8h session (57MB transcript). `firstSend` was computed from the in-flight dispatch latch, which is zero before every fresh send — so every send was "first" and the v0.38.5 marker path never fired. A dedicated per-goal latch now flips only when a full brief dispatches and resets on session rebind: one 28KB brief, then 45-char markers (audit/CONTINUATION-SPAM-2026-09-22.md).
|
|
12
|
+
|
|
13
|
+
## 0.38.93 — Recovery monitoring: attempts, failing-since, cause (2026-09-22)
|
|
14
|
+
|
|
15
|
+
- The shared recovery status block (widget, `/goal status`) now carries trajectory facts: `Attempts: N · failing Xh` plus a `Cause: deterministic client error` line when classified — a held 400 with a bare countdown read like a transient that would clear. Transients stay unlabeled; the classifier moved to quota-retry.ts so display shares it without an import cycle.
|
|
16
|
+
|
|
17
|
+
## 0.38.92 — Deterministic 400s hold; dethroned sessions named (2026-09-22)
|
|
18
|
+
|
|
19
|
+
- Main-model recovery no longer blind-retries deterministic provider refusals (HTTP 400 — e.g. 12 images against a 4-image model cap, which burned 7.5h of "retrying automatically"). Identical retries cannot succeed, so it holds for manual resume with fix directions (switch model or trim the request) in every mode; model switches retire the old episode's diagnostic so failover still probes (audit/DETERMINISTIC-400-AND-DETHRONED-2026-09-22.md).
|
|
20
|
+
- A session that loses the state-root race is now told exactly that ("owned by another session … not a subagent refusal") instead of being misdiagnosed as a subagent session.
|
|
21
|
+
|
|
3
22
|
## 0.38.91 — Tool descriptions enumerate plan/audit/verify/add (2026-09-21)
|
|
4
23
|
|
|
5
24
|
- `/goal plan`, `/goal audit`, `/goal verify`, `/list plan`, and `/list add` existed but were undiscoverable from the tool descriptions. Both descriptions now enumerate them with one-line roles; a pin keeps the enumerations (audit/POST-090-IMPROVEMENT-SWEEP-2026-09-21.md).
|
package/docs/INDEX.md
CHANGED
|
@@ -16,7 +16,7 @@ Policy contracts and recent changes live in the `audit/` directory of the
|
|
|
16
16
|
failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
|
|
17
17
|
handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
|
|
18
18
|
shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
|
|
19
|
-
v0.35.14–v0.38.
|
|
19
|
+
v0.35.14–v0.38.95 continue through the supervisor freeze (`/glla pause`),
|
|
20
20
|
load hold, auditor picker parity, Windows launch fix, zombie-watchdog
|
|
21
21
|
subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
|
|
22
22
|
durable state-root selection, blank-until-resume auditor context, frozen
|
|
@@ -260,6 +260,23 @@ let continuationScheduledFor: string | null = null;
|
|
|
260
260
|
|
|
261
261
|
let lastContinuationSentAt = 0;
|
|
262
262
|
|
|
263
|
+
/** v0.38.94 (field 2026-09-22: 909 full 28KB sends in one session):
|
|
264
|
+
* goal id whose initial full brief already landed this session, or null.
|
|
265
|
+
* Previously `firstSend` was computed as `lastContinuationSentAt === 0`,
|
|
266
|
+
* but that latch tracks the IN-FLIGHT dispatch — it is zeroed on every
|
|
267
|
+
* ack/failure/timeout, i.e. before every fresh send — so EVERY send was
|
|
268
|
+
* "first" and the v0.38.5 delta-only path never fired. This latch flips
|
|
269
|
+
* only when a full payload actually dispatches, resets on session rebind
|
|
270
|
+
* (fresh context needs the brief again), and keys on goal id so a new
|
|
271
|
+
* goal re-teaches once. */
|
|
272
|
+
let continuationInitialFullSentFor: string | null = null;
|
|
273
|
+
|
|
274
|
+
/** Session-start rebind calls this: a fresh host context never saw the
|
|
275
|
+
* brief, so the next send for the restored goal goes full again. */
|
|
276
|
+
export function resetContinuationInitialSend(): void {
|
|
277
|
+
continuationInitialFullSentFor = null;
|
|
278
|
+
}
|
|
279
|
+
|
|
263
280
|
// Audit 2026-09-07 (MEDIUM): wall-clock of the last genuine user-role
|
|
264
281
|
// message observed via message_start. A user message arriving after a
|
|
265
282
|
// dispatch was sent means the next agent_start/turn_start belongs to the
|
|
@@ -1376,7 +1393,7 @@ export function sendContinuation(goalId: string): void {
|
|
|
1376
1393
|
// start-proof matching keeps working; fallback agent_start/turn_start
|
|
1377
1394
|
// needs no prompt at all.
|
|
1378
1395
|
const goalForSend = state.goal!;
|
|
1379
|
-
const { content, kind } = buildContinuationContent(goalForSend, { resync, firstSend:
|
|
1396
|
+
const { content, kind } = buildContinuationContent(goalForSend, { resync, firstSend: continuationInitialFullSentFor !== goalId });
|
|
1380
1397
|
flags.extensionApi.sendMessage({
|
|
1381
1398
|
customType: GOAL_EVENT_ENTRY,
|
|
1382
1399
|
content,
|
|
@@ -1384,6 +1401,9 @@ export function sendContinuation(goalId: string): void {
|
|
|
1384
1401
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1385
1402
|
lastContinuationSentPayload = { content, display: false }; // v0.34.88: verbatim retry payload
|
|
1386
1403
|
if (!dispatchAccepted(ctx, attempt)) return;
|
|
1404
|
+
// v0.38.94: flip the initial-send latch only once a full payload has
|
|
1405
|
+
// actually dispatched — a failed send must not burn the one full brief.
|
|
1406
|
+
if (kind === "full" || kind === "full+resync") continuationInitialFullSentFor = goalId;
|
|
1387
1407
|
continuationRearmStreak = 0; continuationRearmSince = 0; // v0.28.5 (E3): an accepted dispatch clears the storm
|
|
1388
1408
|
appendLedger(ctx.cwd, "goal_continuation_sent", { goalId, attemptId: attempt.id, generation: attempt.generation, kind, payloadChars: content.length, ...(busyBypass ? { busyBypass: true } : {}) });
|
|
1389
1409
|
// v0.35.37 (audit finding): the welcome-back recovery notice must fire
|
|
@@ -178,6 +178,10 @@ let lastWedgeAlertAt = 0;
|
|
|
178
178
|
// host turn exactly once. The key fences repeated heartbeat ticks and lets a
|
|
179
179
|
// later explicit resume start a fresh, independently bounded attempt.
|
|
180
180
|
let lastZombieAbortKey = "";
|
|
181
|
+
// Field 2026-09-22: the stand-down branch logged on EVERY heartbeat tick
|
|
182
|
+
// (219 zombie_run_stood_down_user_input in a day) while a wait was pending.
|
|
183
|
+
// Latch it per silent episode exactly like the abort key.
|
|
184
|
+
let lastZombieStandDownKey = "";
|
|
181
185
|
let zombieRunSilentMsOverride: number | null = null;
|
|
182
186
|
let zombieRunAbortGraceMsOverride: number | null = null;
|
|
183
187
|
|
|
@@ -328,6 +332,7 @@ export function __testOnlySetZombieRunWindows(silentMs: number | null, abortGrac
|
|
|
328
332
|
zombieRunAbortGraceMsOverride = abortGraceMs;
|
|
329
333
|
lastZombieAlertAt = 0;
|
|
330
334
|
lastZombieAbortKey = "";
|
|
335
|
+
lastZombieStandDownKey = "";
|
|
331
336
|
}
|
|
332
337
|
|
|
333
338
|
/** Test-only reset for the process-global watchdog state. */
|
|
@@ -336,6 +341,7 @@ export function __testOnlyResetZombieRunWatchdog(): void {
|
|
|
336
341
|
zombieRunAbortGraceMsOverride = null;
|
|
337
342
|
lastZombieAlertAt = 0;
|
|
338
343
|
lastZombieAbortKey = "";
|
|
344
|
+
lastZombieStandDownKey = "";
|
|
339
345
|
}
|
|
340
346
|
|
|
341
347
|
/** v0.35.17: release the one-shot abort latch when the bounded automatic
|
|
@@ -345,6 +351,7 @@ export function __testOnlyResetZombieRunWatchdog(): void {
|
|
|
345
351
|
* NEVER re-abort — a permanently BUSY-silent session. */
|
|
346
352
|
export function releaseZombieAbortKey(): void {
|
|
347
353
|
lastZombieAbortKey = "";
|
|
354
|
+
lastZombieStandDownKey = "";
|
|
348
355
|
}
|
|
349
356
|
|
|
350
357
|
let lastUnansweredAlertAt = 0;
|
|
@@ -1392,7 +1399,9 @@ function heartbeatTick(): void {
|
|
|
1392
1399
|
isUserInputWaitCall,
|
|
1393
1400
|
);
|
|
1394
1401
|
if (subagentWaitInFlight || userInputWaitInFlight) {
|
|
1395
|
-
|
|
1402
|
+
const standDownKey = `${flags.sessionGeneration}:${state.goal?.id ?? "loop"}:${flags.lastStreamActivityAt}`;
|
|
1403
|
+
if (streamSilentMs >= zombieAbortMs && !flags.abortedStandDown && standDownKey !== lastZombieStandDownKey) {
|
|
1404
|
+
lastZombieStandDownKey = standDownKey;
|
|
1396
1405
|
appendLedger(
|
|
1397
1406
|
ctx.cwd,
|
|
1398
1407
|
subagentWaitInFlight
|
|
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { execSync } from "node:child_process";
|
|
14
|
-
import { normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, quotaSignal, sanitizeProviderAuditReport, sanitizeProviderDisplayText, type QuotaSignal } from "./quota-retry.js";
|
|
14
|
+
import { isDeterministicProviderError, normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, quotaSignal, sanitizeProviderAuditReport, sanitizeProviderDisplayText, type QuotaSignal } from "./quota-retry.js";
|
|
15
15
|
import { MAX_AUDITOR_CANDIDATE_REFS, MAX_MAIN_MODEL_FALLBACKS, normalizeBoundedModelRefs } from "./main-model-recovery.js";
|
|
16
16
|
import { resolveGllaStateDir, stateRootPending } from "./glla-state-root.js";
|
|
17
17
|
export { normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, sanitizeProviderAuditReport, sanitizeProviderDisplayText } from "./quota-retry.js";
|
|
@@ -1195,7 +1195,18 @@ export interface MainModelRecovery {
|
|
|
1195
1195
|
}
|
|
1196
1196
|
|
|
1197
1197
|
/** Compact, truthful status shared by /goal status and /list show. */
|
|
1198
|
-
|
|
1198
|
+
/** Compact elapsed for recovery trajectory lines (core has no display
|
|
1199
|
+
* import — display's fmtElapsed stays the renderer). */
|
|
1200
|
+
function fmtRecoveryElapsed(ms: number): string {
|
|
1201
|
+
if (!Number.isFinite(ms) || ms < 0) return "?";
|
|
1202
|
+
const mins = Math.floor(ms / 60_000);
|
|
1203
|
+
if (mins < 60) return `${mins}m`;
|
|
1204
|
+
const hours = Math.floor(mins / 60);
|
|
1205
|
+
if (hours < 48) return `${hours}h${mins % 60 ? ` ${mins % 60}m` : ""}`;
|
|
1206
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
export function formatMainModelRecoveryStatus(recovery: MainModelRecovery | undefined, configuredBackups: string[] = [], nowMs: number = Date.now()): string[] {
|
|
1199
1210
|
if (!recovery) return [];
|
|
1200
1211
|
const safeBackups = configuredBackups.filter((ref) => typeof ref === "string" && ref.trim()).slice(0, MAX_MAIN_MODEL_FALLBACKS);
|
|
1201
1212
|
const chain = [recovery.primary, ...safeBackups];
|
|
@@ -1205,6 +1216,20 @@ export function formatMainModelRecoveryStatus(recovery: MainModelRecovery | unde
|
|
|
1205
1216
|
const skipped = recovery.skipped?.length
|
|
1206
1217
|
? recovery.skipped.map((entry) => `${entry.ref} (${entry.reason})`).join(", ")
|
|
1207
1218
|
: "none";
|
|
1219
|
+
// v0.38.93: trajectory facts — attempts + failing-since answer "is it
|
|
1220
|
+
// getting better or worse?", which the retry countdown alone cannot.
|
|
1221
|
+
// Durable counters only, never a provider-side guess.
|
|
1222
|
+
const attempts = typeof recovery.attempts === "number" && recovery.attempts > 0 ? recovery.attempts : 0;
|
|
1223
|
+
const firstMs = typeof recovery.firstFailureAt === "string" ? Date.parse(recovery.firstFailureAt) : Number.NaN;
|
|
1224
|
+
const trajectory = attempts > 0 || Number.isFinite(firstMs)
|
|
1225
|
+
? [` Attempts: ${attempts}${Number.isFinite(firstMs) ? ` · failing ${fmtRecoveryElapsed(nowMs - firstMs)}` : ""}`]
|
|
1226
|
+
: [];
|
|
1227
|
+
// v0.38.93: name the deterministic class when classified — a held 400
|
|
1228
|
+
// with a countdown reads like a transient that will clear; it won't.
|
|
1229
|
+
// Transients stay unlabeled (cards never guess provider reasons).
|
|
1230
|
+
const cause = isDeterministicProviderError(recovery.providerErrorDiagnostic ?? recovery.reason)
|
|
1231
|
+
? [" Cause: deterministic client error — identical retries cannot succeed"]
|
|
1232
|
+
: [];
|
|
1208
1233
|
const lines = [
|
|
1209
1234
|
`Main-model recovery: ${currentIndex >= 0 ? `${currentIndex === 0 ? "primary" : `backup ${currentIndex}/${safeBackups.length}`} selected` : "active model selected"}`,
|
|
1210
1235
|
` Order: ${chain.join(" → ")}`,
|
|
@@ -1212,6 +1237,8 @@ export function formatMainModelRecoveryStatus(recovery: MainModelRecovery | unde
|
|
|
1212
1237
|
...(recovery.pendingModelSwitch ? [` Pending switch: ${recovery.pendingModelSwitch}`] : []),
|
|
1213
1238
|
` Attempted: ${attempted}`,
|
|
1214
1239
|
` Skipped: ${skipped}`,
|
|
1240
|
+
...trajectory,
|
|
1241
|
+
...cause,
|
|
1215
1242
|
];
|
|
1216
1243
|
if (recovery.retryAt) lines.push(` Retry at: ${recovery.retryAt}`);
|
|
1217
1244
|
if (recovery.primaryProbeAt) lines.push(` Preferred-primary probe at: ${recovery.primaryProbeAt}`);
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
mainModelFailureDelayMs,
|
|
32
32
|
mainModelPrimaryProbeDelayMs,
|
|
33
33
|
mainModelRetryDelayMs,
|
|
34
|
+
isDeterministicProviderError,
|
|
34
35
|
isQuotaHorizonExempt,
|
|
35
36
|
quotaResetSleepMs,
|
|
36
37
|
isCompactionInFlightSince,
|
|
@@ -800,6 +801,15 @@ export function setMainModelRecoveryPause(ctx: ExtensionContext, recovery: MainM
|
|
|
800
801
|
};
|
|
801
802
|
const now = Date.now();
|
|
802
803
|
const deadlineMs = normalized.autoRetryUntil ? Date.parse(normalized.autoRetryUntil) : Number.NaN;
|
|
804
|
+
// v0.38.92: deterministic 400s never schedule — an identical retry cannot
|
|
805
|
+
// succeed, so the ladder would burn (forever under aggressiveMode) while
|
|
806
|
+
// promising "retrying automatically". Hold for manual resume with the fix
|
|
807
|
+
// directions; the user's resume re-probes a CHANGED request. Checked
|
|
808
|
+
// before the horizon/quota logic so it wins in every mode.
|
|
809
|
+
if (isDeterministicProviderError(normalized.providerErrorDiagnostic ?? normalized.reason)) {
|
|
810
|
+
holdMainModelRecovery(ctx, normalized, "the provider refused the request itself (deterministic client error — e.g. too many images for this model). Switch model or trim the request, then resume");
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
803
813
|
const requestedDelayMs = Math.max(1_000, delayMs);
|
|
804
814
|
// v0.38.69 (Antigravity port): quota waits never park at the horizon —
|
|
805
815
|
// a rate-limit/plan-quota wall is transient, so the hold below is
|
|
@@ -1438,7 +1448,10 @@ async function probeMainModelRecoveryImpl(ctx: ExtensionContext): Promise<void>
|
|
|
1438
1448
|
}
|
|
1439
1449
|
// The ordered chain has been visited for this recovery cycle. Start a
|
|
1440
1450
|
// deliberate new cycle by retrying the currently selected model; the
|
|
1441
|
-
// next failure can then walk primary → backup 1 → … again.
|
|
1451
|
+
// next failure can then walk primary → backup 1 → … again. (2026-09-23:
|
|
1452
|
+
// a single-model chain still needs this probe — it IS the retry. An
|
|
1453
|
+
// attempted "quiet wait" here wedged recovery into a park loop that
|
|
1454
|
+
// never retried; blocked-pause-autoclear pins the probe contract.)
|
|
1442
1455
|
// Audit 2026-09-07 (HIGH, scoped): each new cycle consumes backoff
|
|
1443
1456
|
// budget (attempts+1, so the next failure's envelope delay grows) and
|
|
1444
1457
|
// honors the 24h horizon (past the wall the reset holds for manual
|
|
@@ -1627,7 +1640,11 @@ export async function recoverMainModelFromSendStorm(ctx: ExtensionContext, kind:
|
|
|
1627
1640
|
if (!current) return;
|
|
1628
1641
|
const recovery = state.mainModelRecovery;
|
|
1629
1642
|
if (!recovery) return;
|
|
1630
|
-
|
|
1643
|
+
// v0.38.92: the switch retires the old episode's diagnostic — it
|
|
1644
|
+
// describes the previous model's failure, and a stale deterministic
|
|
1645
|
+
// marker must not hold the fresh model's probe. Reason restarts from
|
|
1646
|
+
// the storm failure that triggered the switch.
|
|
1647
|
+
if (setMainModelRecoveryPause(ctx, { ...recovery, kind: kind === "loop" ? "loop" : "goal", active: current, resumeCurrent: true, reason: mainModelRecoveryReason(failure), providerErrorDiagnostic: undefined }, 1_000)) {
|
|
1631
1648
|
flags.mainModelAbortForRecovery = true;
|
|
1632
1649
|
try { ctx.abort(); } catch { /* best effort; recovery guard prevents re-send storms */ }
|
|
1633
1650
|
scheduleMainModelRecoveryTimer(ctx, 1_000);
|
|
@@ -173,6 +173,7 @@ import {
|
|
|
173
173
|
setContinuationRearmStreak,
|
|
174
174
|
setContinuationRearmSince,
|
|
175
175
|
resetContinuationDispatchState,
|
|
176
|
+
resetContinuationInitialSend,
|
|
176
177
|
noteUserMessageForDispatch,
|
|
177
178
|
sendTerminalCompletionNotice,
|
|
178
179
|
type ContinuationFlags,
|
|
@@ -493,6 +494,11 @@ function clearInBandProviderFailure(): void {
|
|
|
493
494
|
// session_start, and manual parks — never on starved stops (the wedge
|
|
494
495
|
// persists) so a hot context cannot lap the budget forever.
|
|
495
496
|
let lengthExhaustionEpisodes = 0;
|
|
497
|
+
// Field 2026-09-22: every send re-projects the same bloated history, which
|
|
498
|
+
// logged 719 identical payload_guard_eviction entries in a day. Dedupe by
|
|
499
|
+
// fingerprint — first occurrence lands, identical repeats are silent, and
|
|
500
|
+
// any value change (or generation bump) re-arms.
|
|
501
|
+
let lastPayloadEvictionFingerprint = "";
|
|
496
502
|
/** A manual resume starts a fresh relentless cycle: a user pause between
|
|
497
503
|
an episode-1 wedge and the next one must not make that wedge park one
|
|
498
504
|
cycle early. */
|
|
@@ -1426,6 +1432,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
|
|
|
1426
1432
|
clearToolActivityState();
|
|
1427
1433
|
bindSubagentRpcHost(pi.events, sessionGeneration);
|
|
1428
1434
|
clearDraftingState();
|
|
1435
|
+
resetContinuationInitialSend(); // v0.38.94: fresh host context never saw the brief
|
|
1429
1436
|
// An auditor belonging to the disposed generation cannot block the fresh
|
|
1430
1437
|
// session's recovery gate; its finally block is generation-guarded too.
|
|
1431
1438
|
completionAuditInFlight = false;
|
|
@@ -3108,12 +3115,17 @@ async function handleHotLengthExhaustion(
|
|
|
3108
3115
|
});
|
|
3109
3116
|
}
|
|
3110
3117
|
if (projection.evicted.length > 0) {
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3118
|
+
const evictionFingerprint = `${sessionGeneration}:${projection.evicted.length}:${projection.totalImageBytes - projection.remainingImageBytes}:${projection.remainingImageBytes}:${projection.remainingImageCount}`;
|
|
3119
|
+
if (evictionFingerprint !== lastPayloadEvictionFingerprint) {
|
|
3120
|
+
lastPayloadEvictionFingerprint = evictionFingerprint;
|
|
3121
|
+
appendLedger(ctx.cwd, "payload_guard_eviction", {
|
|
3122
|
+
evicted: projection.evicted.length,
|
|
3123
|
+
bytesFreed: projection.totalImageBytes - projection.remainingImageBytes,
|
|
3124
|
+
remainingImageBytes: projection.remainingImageBytes,
|
|
3125
|
+
remainingImageCount: projection.remainingImageCount,
|
|
3126
|
+
generation: sessionGeneration,
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3117
3129
|
}
|
|
3118
3130
|
if (checkpointProjection.removedPayloads > 0) {
|
|
3119
3131
|
appendLedger(ctx.cwd, "context_checkpoint_projection", {
|
|
@@ -1884,8 +1884,15 @@ function isHostLifecycleSessionStart(event: unknown): boolean {
|
|
|
1884
1884
|
|
|
1885
1885
|
const FOREIGN_SESSION_TOOL_MESSAGE =
|
|
1886
1886
|
"This tool changes goal/loop/list state, which only the MAIN session owns — you are running in a subagent session. Report back to the main agent; it owns the goal and can call this tool.";
|
|
1887
|
-
|
|
1888
|
-
|
|
1887
|
+
/** Refusal when the caller lost the workingDir state-root race (field
|
|
1888
|
+
* 2026-09-21: a dethroned MAIN session got the bare subagent message,
|
|
1889
|
+
* which misdiagnoses — it is not a worker, it lost the root to another
|
|
1890
|
+
* live session). Keeps the v0.35.72 pinned contract (MAIN-owner clause,
|
|
1891
|
+
* "state-root read-only", sessionDir cure) but replaces the false
|
|
1892
|
+
* "subagent session" diagnosis with the dethroned one. Replaces
|
|
1893
|
+
* FOREIGN_SESSION_TOOL_MESSAGE_WITH_STATE_ROOT_HINT. */
|
|
1894
|
+
const DETHRONED_SESSION_TOOL_MESSAGE =
|
|
1895
|
+
"This tool changes goal/loop/list state, which only the MAIN session owns — but this is not a subagent refusal: another live session holds this folder's state root, so this session is state-root read-only. Close the other session, start a fresh session here to take the root back (/glla owner inspects the holder), or switch to sessionDir via /glla settings → State root.";
|
|
1889
1896
|
|
|
1890
1897
|
/** Refusal message when a state-mutating tool is called from a subagent session, else null. */
|
|
1891
1898
|
function foreignToolGuard(execCtx: unknown): string | null {
|
|
@@ -1901,9 +1908,10 @@ function foreignToolGuard(execCtx: unknown): string | null {
|
|
|
1901
1908
|
if (tryAbsorbHostSuccessor(c, "tool-call")) return null;
|
|
1902
1909
|
if (isForeignCtx(c)) {
|
|
1903
1910
|
// v0.35.72: when the cwd is also denied by the workingDir state-root
|
|
1904
|
-
// owner, the two warnings co-occur.
|
|
1905
|
-
// subagent
|
|
1906
|
-
|
|
1911
|
+
// owner, the two warnings co-occur. v0.38.92: name the dethroned
|
|
1912
|
+
// diagnosis, not the subagent one — the caller is usually a former
|
|
1913
|
+
// main session that lost the root race (field 2026-09-21).
|
|
1914
|
+
if (c && processOwnerDeniedCwd === c.cwd) return DETHRONED_SESSION_TOOL_MESSAGE;
|
|
1907
1915
|
return FOREIGN_SESSION_TOOL_MESSAGE;
|
|
1908
1916
|
}
|
|
1909
1917
|
// Post-park the owner is nulled; the dead-owner record means only the
|
|
@@ -323,6 +323,10 @@ export function isQuotaHorizonExempt(raw: string | undefined): boolean {
|
|
|
323
323
|
return signal === "rate-limit" || signal === "plan-quota";
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
// v0.38.92 classifier lives in quota-retry.ts (shared with the display
|
|
327
|
+
// layer); re-exported here so existing importers keep working.
|
|
328
|
+
export { isDeterministicProviderError } from "./quota-retry.js";
|
|
329
|
+
|
|
326
330
|
/** v0.38.69 (Antigravity port): sleep-until-reset for quota-class failures
|
|
327
331
|
* with an explicit upstream reset hint. Returns undefined (keep the blind
|
|
328
332
|
* ladder) unless ALL hold: a rate-limit/plan-quota signal, an upstream
|
|
@@ -31,6 +31,11 @@ export const DEFAULT_IMAGE_BUDGET_BYTES = 16 * 1024 * 1024;
|
|
|
31
31
|
* current turn almost always needs the newest visual state. */
|
|
32
32
|
export const DEFAULT_KEEP_RECENT_IMAGES = 2;
|
|
33
33
|
|
|
34
|
+
/** Max inline image blocks in one outgoing request. Field 2026-09-21: a
|
|
35
|
+
* provider 400'd `Image count 12 exceeds limit 4 per request` on small
|
|
36
|
+
* screenshots that fit the byte budget — count is a separate wall. */
|
|
37
|
+
export const DEFAULT_MAX_IMAGES_PER_REQUEST = 4;
|
|
38
|
+
|
|
34
39
|
export interface ImageBlockLocation {
|
|
35
40
|
messageIndex: number;
|
|
36
41
|
blockIndex: number;
|
|
@@ -52,11 +57,16 @@ export interface EvictionResult {
|
|
|
52
57
|
totalImageBytes: number;
|
|
53
58
|
/** Cumulative image bytes AFTER eviction. */
|
|
54
59
|
remainingImageBytes: number;
|
|
60
|
+
/** Image blocks BEFORE eviction. */
|
|
61
|
+
totalImageCount: number;
|
|
62
|
+
/** Image blocks AFTER eviction. */
|
|
63
|
+
remainingImageCount: number;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
export interface PayloadGuardOptions {
|
|
58
67
|
imageBudgetBytes?: number;
|
|
59
68
|
keepRecentImages?: number;
|
|
69
|
+
maxImagesPerRequest?: number;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
interface ImageBlockLike {
|
|
@@ -101,10 +111,12 @@ function evictionPlaceholder(bytes: number): { type: string; text: string } {
|
|
|
101
111
|
|
|
102
112
|
/**
|
|
103
113
|
* Project the outgoing message list with cumulative inline-image bytes
|
|
104
|
-
* bounded to `imageBudgetBytes
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
114
|
+
* bounded to `imageBudgetBytes` AND image blocks bounded to
|
|
115
|
+
* `maxImagesPerRequest`. Oldest images are evicted first; the most recent
|
|
116
|
+
* `keepRecentImages` images are never evicted (best effort when neither
|
|
117
|
+
* bound can be met without them — the floor always wins). Returns the input
|
|
118
|
+
* array identity when nothing needs eviction — callers can cheaply detect
|
|
119
|
+
* the no-op.
|
|
108
120
|
*/
|
|
109
121
|
export function evictStaleImages(
|
|
110
122
|
messages: readonly unknown[],
|
|
@@ -116,27 +128,31 @@ export function evictStaleImages(
|
|
|
116
128
|
const keepRecent = typeof opts.keepRecentImages === "number" && opts.keepRecentImages >= 0
|
|
117
129
|
? opts.keepRecentImages
|
|
118
130
|
: DEFAULT_KEEP_RECENT_IMAGES;
|
|
131
|
+
const maxImages = typeof opts.maxImagesPerRequest === "number" && opts.maxImagesPerRequest > 0
|
|
132
|
+
? Math.floor(opts.maxImagesPerRequest)
|
|
133
|
+
: DEFAULT_MAX_IMAGES_PER_REQUEST;
|
|
119
134
|
|
|
120
135
|
const blocks = collectImageBlocks(messages);
|
|
121
136
|
const totalImageBytes = blocks.reduce((sum, b) => sum + b.bytes, 0);
|
|
122
|
-
if (totalImageBytes <= budget) {
|
|
123
|
-
return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes };
|
|
137
|
+
if (totalImageBytes <= budget && blocks.length <= maxImages) {
|
|
138
|
+
return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes, totalImageCount: blocks.length, remainingImageCount: blocks.length };
|
|
124
139
|
}
|
|
125
140
|
|
|
126
|
-
// Evict oldest-first; the newest `keepRecent` are
|
|
127
|
-
// exempt
|
|
128
|
-
// never a destructive sweep of the current visual
|
|
141
|
+
// Evict oldest-first until BOTH bounds hold; the newest `keepRecent` are
|
|
142
|
+
// exempt. When even the exempt set breaks a bound the eviction stops
|
|
143
|
+
// there — best effort, never a destructive sweep of the current visual
|
|
144
|
+
// state.
|
|
129
145
|
const evict = new Set<ImageBlockLocation>();
|
|
130
146
|
let remaining = totalImageBytes;
|
|
131
147
|
const exempt = blocks.slice(Math.max(0, blocks.length - keepRecent));
|
|
132
148
|
for (const block of blocks) {
|
|
133
|
-
if (remaining <= budget) break;
|
|
149
|
+
if (remaining <= budget && blocks.length - evict.size <= maxImages) break;
|
|
134
150
|
if (exempt.includes(block)) continue;
|
|
135
151
|
evict.add(block);
|
|
136
152
|
remaining -= block.bytes;
|
|
137
153
|
}
|
|
138
154
|
if (evict.size === 0) {
|
|
139
|
-
return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes };
|
|
155
|
+
return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes, totalImageCount: blocks.length, remainingImageCount: blocks.length };
|
|
140
156
|
}
|
|
141
157
|
|
|
142
158
|
// Group evictions per message so each affected message is copied once.
|
|
@@ -160,7 +176,7 @@ export function evictStaleImages(
|
|
|
160
176
|
return { ...(message as object), content: newContent };
|
|
161
177
|
});
|
|
162
178
|
|
|
163
|
-
return { messages: projected, evicted: [...byMessage.values()].flat(), totalImageBytes, remainingImageBytes: remaining };
|
|
179
|
+
return { messages: projected, evicted: [...byMessage.values()].flat(), totalImageBytes, remainingImageBytes: remaining, totalImageCount: blocks.length, remainingImageCount: blocks.length - evict.size };
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
/**
|
|
@@ -317,6 +317,20 @@ export function isBillingError(error: string | undefined): boolean {
|
|
|
317
317
|
return quotaSignal(error) === "billing";
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// v0.38.92 (field 2026-09-21: 12 images vs a 4-image model cap retried
|
|
321
|
+
// 7.5h): a deterministic HTTP 400 client error can never succeed on an
|
|
322
|
+
// identical retry — only a changed request (fewer images, different
|
|
323
|
+
// model, fixed params) can. Match the error-type markers, never a bare
|
|
324
|
+
// "400" (token counts and limits quote 400s constantly). Moved here from
|
|
325
|
+
// main-model-recovery.ts in v0.38.93 so the display layer can share the
|
|
326
|
+
// classifier without an import cycle (it is re-exported there).
|
|
327
|
+
const DETERMINISTIC_CLIENT_ERROR = /badrequesterror|invalid_request_error|"code"\s*:\s*"400"/i;
|
|
328
|
+
|
|
329
|
+
export function isDeterministicProviderError(raw: string | undefined): boolean {
|
|
330
|
+
if (typeof raw !== "string" || !raw.trim()) return false;
|
|
331
|
+
return DETERMINISTIC_CLIENT_ERROR.test(raw);
|
|
332
|
+
}
|
|
333
|
+
|
|
320
334
|
/** Match recoverable provider rate/plan walls and explicit billing walls.
|
|
321
335
|
* Ambiguous `temporarily unavailable`, ordinary 403s, and generic network
|
|
322
336
|
* failures intentionally return false. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.95",
|
|
4
4
|
"description": "Mission control for long running pi work: interview drafted goals, an audited task queue, and metric or audit loops that run for hours. Every completion is rechecked by a detached auditor with raw evidence, while Confirm drafts, decision pauses, and consent gates keep you in charge.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "dracon",
|