pi-goal-list-loop-audit 0.38.92 → 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 +14 -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 +4 -1
- package/extensions/loops/goal-activation.ts +18 -6
- package/extensions/main-model-recovery.ts +3 -14
- 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,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.38.92 — Deterministic 400s hold; dethroned sessions named (2026-09-22)
|
|
4
18
|
|
|
5
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).
|
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}`);
|
|
@@ -1448,7 +1448,10 @@ async function probeMainModelRecoveryImpl(ctx: ExtensionContext): Promise<void>
|
|
|
1448
1448
|
}
|
|
1449
1449
|
// The ordered chain has been visited for this recovery cycle. Start a
|
|
1450
1450
|
// deliberate new cycle by retrying the currently selected model; the
|
|
1451
|
-
// 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.)
|
|
1452
1455
|
// Audit 2026-09-07 (HIGH, scoped): each new cycle consumes backoff
|
|
1453
1456
|
// budget (attempts+1, so the next failure's envelope delay grows) and
|
|
1454
1457
|
// honors the 24h horizon (past the wall the reset holds for manual
|
|
@@ -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", {
|
|
@@ -323,20 +323,9 @@ export function isQuotaHorizonExempt(raw: string | undefined): boolean {
|
|
|
323
323
|
return signal === "rate-limit" || signal === "plan-quota";
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
// v0.38.92
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
// params) can. Retrying it on the ladder burns the horizon (or loops
|
|
330
|
-
// forever under aggressiveMode) while telling the user "retrying
|
|
331
|
-
// automatically". Match the error-type markers, never a bare "400" (token
|
|
332
|
-
// counts and limits quote 400s constantly). Callers hold for manual resume
|
|
333
|
-
// with the fix directions instead of scheduling.
|
|
334
|
-
const DETERMINISTIC_CLIENT_ERROR = /badrequesterror|invalid_request_error|"code"\s*:\s*"400"/i;
|
|
335
|
-
|
|
336
|
-
export function isDeterministicProviderError(raw: string | undefined): boolean {
|
|
337
|
-
if (typeof raw !== "string" || !raw.trim()) return false;
|
|
338
|
-
return DETERMINISTIC_CLIENT_ERROR.test(raw);
|
|
339
|
-
}
|
|
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";
|
|
340
329
|
|
|
341
330
|
/** v0.38.69 (Antigravity port): sleep-until-reset for quota-class failures
|
|
342
331
|
* with an explicit upstream reset hint. Returns undefined (keep the blind
|
|
@@ -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",
|