pi-mega-compact 0.11.2 → 0.11.4
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/dist/extensions/dashboard-server/api-contracts/endpoints.js +9 -1
- package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
- package/dist/extensions/dashboard-server/index-reader.js +22 -0
- package/dist/extensions/dashboard-server/routes-cache.js +80 -0
- package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
- package/dist/extensions/dashboard-server/routes-maintenance.js +7 -7
- package/dist/extensions/dashboard-server/routes-repo.js +24 -0
- package/dist/extensions/dashboard-server/routes.js +1 -0
- package/dist/extensions/dashboard-server/server.js +3 -1
- package/dist/extensions/mega-cache-replay.test.js +233 -24
- package/dist/extensions/mega-compact-s38.test.js +778 -20
- package/dist/extensions/mega-config.js +4 -0
- package/dist/extensions/mega-events/agent-handlers.js +44 -8
- package/dist/extensions/mega-events/context-handler.js +37 -19
- package/dist/extensions/mega-events/error-classifier.js +125 -135
- package/dist/extensions/mega-events/outage-advisor.js +53 -0
- package/dist/extensions/mega-runtime/reset-runtime.js +1 -0
- package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
- package/dist/extensions/mega-runtime/runtime.js +1 -0
- package/dist/extensions/mega-runtime/snapshot.js +1 -1
- package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
- package/dist/extensions/mega-runtime/widget.js +4 -4
- package/dist/extensions/mega-runtime/widget.test.js +103 -12
- package/dist/src/pricing.js +78 -0
- package/dist/src/store/sqlite/perf-samples.js +104 -0
- package/dist/src/store/sqlite/perf-samples.test.js +93 -1
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js → AchievementTiles-vEo2MtE2.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B8NqDt7I.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
- package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js → ConfigTab-Cm6ymbmx.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js → EventsTab-DXWl_YzI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js → GameTab-BLDudcO5.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js.map → GameTab-BLDudcO5.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js → MetricsTab-Cq4u-rZF.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js → OverviewTab-D4kGvsiQ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
- package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js → SessionsTab-DZC2-GCn.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js → TopicsTab-01G71hv1.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js.map → TopicsTab-01G71hv1.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js → TurnsTab-DLRg0LZT.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
- package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js → index-BX0bcgLu.js} +4 -4
- package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js.map → index-BX0bcgLu.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js → useSSE-BBYt5UPB.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js.map → useSSE-BBYt5UPB.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/api/client.ts +6 -0
- package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
- package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
- package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
- package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
- package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
- package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +301 -136
- package/extensions/dashboard-client/src/utils/format.ts +42 -7
- package/extensions/dashboard-server/api-contracts/endpoints.ts +31 -8
- package/extensions/dashboard-server/api-contracts/index.ts +4 -0
- package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
- package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
- package/extensions/dashboard-server/api-contracts.test.ts +3 -2
- package/extensions/dashboard-server/index-reader.ts +23 -0
- package/extensions/dashboard-server/routes-cache.test.ts +302 -0
- package/extensions/dashboard-server/routes-cache.ts +123 -0
- package/extensions/dashboard-server/routes-maintenance.ts +18 -11
- package/extensions/dashboard-server/routes-repo.ts +31 -4
- package/extensions/dashboard-server/routes.ts +1 -0
- package/extensions/dashboard-server/server.ts +2 -0
- package/extensions/mega-cache-replay.test.ts +481 -166
- package/extensions/mega-compact-s38.test.ts +785 -20
- package/extensions/mega-config.ts +14 -0
- package/extensions/mega-events/agent-handlers.ts +48 -7
- package/extensions/mega-events/context-handler.ts +59 -24
- package/extensions/mega-events/error-classifier.ts +167 -129
- package/extensions/mega-events/outage-advisor.ts +73 -0
- package/extensions/mega-runtime/helpers.ts +2 -0
- package/extensions/mega-runtime/reset-runtime.ts +1 -0
- package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
- package/extensions/mega-runtime/runtime.ts +1 -0
- package/extensions/mega-runtime/snapshot.test.ts +139 -0
- package/extensions/mega-runtime/snapshot.ts +6 -4
- package/extensions/mega-runtime/widget.test.ts +290 -105
- package/extensions/mega-runtime/widget.ts +29 -6
- package/package.json +1 -1
- package/src/pricing.ts +95 -0
- package/src/store/sqlite/perf-samples.test.ts +107 -3
- package/src/store/sqlite/perf-samples.ts +142 -6
- package/extensions/dashboard-client/dist/assets/AchievementsTab-B8NqDt7I.js +0 -2
- package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js +0 -2
- package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +0 -2
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js +0 -2
- package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js +0 -2
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
|
@@ -148,6 +148,7 @@ export function loadConfig() {
|
|
|
148
148
|
errorRetryBackoffMs: envFlag("MEGACOMPACT_ERROR_RETRY_BACKOFF_MS", 5000),
|
|
149
149
|
errorRetrySessionMax: envFlag("MEGACOMPACT_ERROR_RETRY_SESSION_MAX", 3),
|
|
150
150
|
poisonedContextRepeatThreshold: envFlag("MEGACOMPACT_POISONED_REPEAT_THRESHOLD", 3),
|
|
151
|
+
providerOutageAdviseThreshold: envFlag("MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD", 3),
|
|
151
152
|
autoPctTrigger,
|
|
152
153
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
153
154
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
@@ -165,6 +166,9 @@ export function loadConfig() {
|
|
|
165
166
|
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
166
167
|
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
167
168
|
tuiWidget: envBool("MEGACOMPACT_TUI_WIDGET", true),
|
|
169
|
+
// D.1: env-overridable recompact delta (minimum context growth % before
|
|
170
|
+
// re-compacting instead of replaying the cached live trim). Default 50.
|
|
171
|
+
recompactPctDelta: envFlag("MEGACOMPACT_RECOMPACT_PCT_DELTA", 50),
|
|
168
172
|
debug: envBool("MEGACOMPACT_DEBUG", false),
|
|
169
173
|
};
|
|
170
174
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { piCompactWouldNoop, runMemoryReview } from "../mega-pipeline.js";
|
|
2
2
|
import { memoryReviewCadence } from "../mega-config.js";
|
|
3
|
-
import { recordScore } from "../../src/store/sqlite.js";
|
|
3
|
+
import { recordScore, readLatestCacheHitPct } from "../../src/store/sqlite.js";
|
|
4
4
|
import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
|
|
5
5
|
import { ensureConversationIdFor, recordTurnWrite, } from "../mega-turn-store.js";
|
|
6
6
|
import { isMegaCache } from "../../src/game/scoring.js";
|
|
7
7
|
import { resolveRepoRoot } from "../mega-config.js";
|
|
8
|
-
import { classifyError, errorRetryBackoffMs, extractErrorSignature, } from "./error-classifier.js";
|
|
8
|
+
import { classifyError, classifyErrorDetailed, errorRetryBackoffMs, extractErrorSignature, isKnownRetryableTransient, } from "./error-classifier.js";
|
|
9
9
|
import { safeSendUserMessage } from "./send-safe.js";
|
|
10
|
+
import { maybeSendProviderOutageAdvisory } from "./outage-advisor.js";
|
|
10
11
|
import { vectorStats } from "../../src/vectorStore.js";
|
|
11
12
|
/** Register agent/turn tracking event handlers. */
|
|
12
13
|
export function registerAgentHandlers(pi, runtime, config) {
|
|
@@ -255,7 +256,9 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
255
256
|
if (runtime.getCachedGameState().game_mode_on) {
|
|
256
257
|
const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
|
|
257
258
|
const st = vectorStats(runtime.store, runtime.rt.sessionId);
|
|
258
|
-
|
|
259
|
+
// C.3: prefer provider cache hit rate, fall back to dedup hit rate
|
|
260
|
+
const providerPct = readLatestCacheHitPct(runtime.currentStateDir);
|
|
261
|
+
const cachePct = providerPct != null ? providerPct : st.dedupHitRate * 100;
|
|
259
262
|
const modelId = runtime.currentModel?.modelId ?? "unknown";
|
|
260
263
|
recordScore(runtime.currentStateDir, {
|
|
261
264
|
repo_root: repo,
|
|
@@ -334,14 +337,17 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
334
337
|
}
|
|
335
338
|
else {
|
|
336
339
|
const category = classifyError(event.message);
|
|
340
|
+
const detail = classifyErrorDetailed(event.message);
|
|
337
341
|
// R3: stateful poisoned-context signal — repeated identical error text
|
|
338
342
|
// across consecutive turns. The classifier is stateless, so this
|
|
339
343
|
// upgrade happens here. A 'transient' (or 'poisoned-context') turn
|
|
340
344
|
// whose normalized error signature matches the previous one bumps
|
|
341
345
|
// errorTextRepeatCount; once it crosses the threshold, the category
|
|
342
|
-
// is upgraded to 'poisoned-context' (the request is deterministic)
|
|
343
|
-
//
|
|
344
|
-
//
|
|
346
|
+
// is upgraded to 'poisoned-context' (the request is deterministic) —
|
|
347
|
+
// R7: unless the signature carries a known-retryable network/
|
|
348
|
+
// throughput marker (guard below). Conservative: only upgrade, never
|
|
349
|
+
// downgrade, and only for transient (a 'permanent' auth error
|
|
350
|
+
// repeating is still permanent).
|
|
345
351
|
let effectiveCategory = category;
|
|
346
352
|
const errSig = extractErrorSignature(event.message);
|
|
347
353
|
if (category === "transient" || category === "poisoned-context") {
|
|
@@ -356,7 +362,26 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
356
362
|
if (runtime.rt.errorTextRepeatCount >=
|
|
357
363
|
config.poisonedContextRepeatThreshold &&
|
|
358
364
|
category === "transient") {
|
|
359
|
-
|
|
365
|
+
// R3/R7: upgrade only when the repeating error carries NO
|
|
366
|
+
// known-retryable marker. Network/throughput failures (timeout,
|
|
367
|
+
// ECONNRESET, 5xx, 429, connection lost, …) that repeat are
|
|
368
|
+
// still retryable — the API is just flaky, and /clear cannot fix
|
|
369
|
+
// them (2026-07-30 false-alarm incident). Only upgrade
|
|
370
|
+
// transient → poisoned-context for errors that repeat identically
|
|
371
|
+
// with no known-retryable cause, like the 2026-07-28 incident
|
|
372
|
+
// where the provider returned "Request failed — please retry."
|
|
373
|
+
// deterministically. The marker set is shared with classifyError
|
|
374
|
+
// (error-classifier.ts) so the two never drift.
|
|
375
|
+
if (!isKnownRetryableTransient(errSig)) {
|
|
376
|
+
effectiveCategory = "poisoned-context";
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
runtime.logger.info("repeat-upgrade-declined", {
|
|
380
|
+
sessionId: runtime.rt.sessionId,
|
|
381
|
+
signature: (errSig || '').slice(0, 500),
|
|
382
|
+
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
360
385
|
}
|
|
361
386
|
}
|
|
362
387
|
}
|
|
@@ -372,6 +397,9 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
372
397
|
runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
|
|
373
398
|
// R4: a successful assistant turn consumes any queued nudge.
|
|
374
399
|
runtime.rt.retryNudgePending = false;
|
|
400
|
+
// R10: reset outage advisory so a recovered-then-flapping
|
|
401
|
+
// provider re-advises once per episode.
|
|
402
|
+
runtime.rt.providerOutageAdvised = false;
|
|
375
403
|
}
|
|
376
404
|
else if (effectiveCategory === "compaction-noop") {
|
|
377
405
|
// (4) pi race / manual compact catch — NOT retryable. The compaction
|
|
@@ -471,18 +499,24 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
471
499
|
runtime.rt.consecutiveErrors++; // still counts toward the circuit breaker
|
|
472
500
|
runtime.rt.poisonedCount++; // R7: dashboard counter
|
|
473
501
|
const sig = errSig || "unknown";
|
|
474
|
-
// (a) dashboard + log
|
|
502
|
+
// (a) dashboard + log — R11: include signal + rawText for forensics
|
|
475
503
|
runtime.dashboard.event("poisoned_context", {
|
|
476
504
|
signature: sig,
|
|
477
505
|
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
478
506
|
turnIndex: event.turnIndex,
|
|
479
507
|
sessionId: runtime.rt.sessionId,
|
|
508
|
+
signal: detail.signal,
|
|
509
|
+
rawText: (errSig || '').slice(0, 500),
|
|
510
|
+
...(detail.httpStatus !== undefined ? { httpStatus: detail.httpStatus } : {}),
|
|
480
511
|
});
|
|
481
512
|
runtime.logger.warn("poisoned-context", {
|
|
482
513
|
sessionId: runtime.rt.sessionId,
|
|
483
514
|
turnIndex: event.turnIndex,
|
|
484
515
|
signature: sig,
|
|
485
516
|
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
517
|
+
signal: detail.signal,
|
|
518
|
+
rawText: (errSig || '').slice(0, 500),
|
|
519
|
+
...(detail.httpStatus !== undefined ? { httpStatus: detail.httpStatus } : {}),
|
|
486
520
|
});
|
|
487
521
|
// (b) one-per-session advise message (throttled by poisonedAdviseSent)
|
|
488
522
|
if (!runtime.rt.poisonedAdviseSent) {
|
|
@@ -536,6 +570,8 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
536
570
|
}
|
|
537
571
|
// S38.6: circuit-breaker — stop retrying after too many consecutive errors.
|
|
538
572
|
runtime.rt.consecutiveErrors++;
|
|
573
|
+
// R10: send calm "provider outage" advisory once per episode.
|
|
574
|
+
await maybeSendProviderOutageAdvisory(effectiveCategory, runtime, pi, config, { signal: detail.signal, rawText: (errSig || '').slice(0, 500) });
|
|
539
575
|
if (runtime.rt.consecutiveErrors > config.maxConsecutiveErrors) {
|
|
540
576
|
runtime.dashboard.event("error_retry_circuit_open", {
|
|
541
577
|
consecutive: runtime.rt.consecutiveErrors,
|
|
@@ -170,30 +170,24 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
170
170
|
runtime.diagCtxFastGate++;
|
|
171
171
|
return;
|
|
172
172
|
}
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
runtime.debounceUntil = now + 2000;
|
|
173
|
+
// D.2: Replay MUST be exempt from debounce — replay is free (no compute,
|
|
174
|
+
// no re-write) and prevents unnecessary KV-cache invalidation. Check
|
|
175
|
+
// replay FIRST, before debounce, so two context events <2s apart both
|
|
176
|
+
// return the cached sentinel verbatim (re-stabilises the provider prefix).
|
|
177
|
+
//
|
|
180
178
|
// v0.8.6 cache-stability: replay the cached trim view when still in the
|
|
181
179
|
// same compaction epoch AND context hasn't grown enough to warrant a
|
|
182
|
-
// re-compact.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// is only valid while the transcript grows within the epoch — it is
|
|
189
|
-
// cleared on session_compact (durable truncation) + resetRuntime, so we
|
|
190
|
-
// never replay a stale cut into a truncated transcript (PREVENT-PI-001/002).
|
|
191
|
-
const RECOMPACT_PCT_DELTA = 10;
|
|
180
|
+
// re-compact. Re-compact only when context grew >=config.recompactPctDelta%
|
|
181
|
+
// of the window (percent basis) or >=50% of the effective threshold
|
|
182
|
+
// (token basis, when percent is unavailable). The cached `cut` is only
|
|
183
|
+
// valid while the transcript grows within the epoch — it is cleared on
|
|
184
|
+
// session_compact (durable truncation) + resetRuntime, so we never replay
|
|
185
|
+
// a stale cut into a truncated transcript (PREVENT-PI-001/002).
|
|
192
186
|
if (runtime.trimCache &&
|
|
193
187
|
runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
|
|
194
188
|
runtime.trimCache.cut <= messages.length) {
|
|
195
189
|
const grewEnough = pct != null && runtime.trimCache.ctxPct != null
|
|
196
|
-
? pct - runtime.trimCache.ctxPct >=
|
|
190
|
+
? pct - runtime.trimCache.ctxPct >= config.recompactPctDelta
|
|
197
191
|
: currentTokens - (runtime.trimCache.ctxTokens ?? 0) >=
|
|
198
192
|
runtime.effectiveThreshold * 0.5;
|
|
199
193
|
if (!grewEnough) {
|
|
@@ -209,6 +203,14 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
209
203
|
}
|
|
210
204
|
// else: context grew enough → fall through to re-compact (cache is stale)
|
|
211
205
|
}
|
|
206
|
+
// Debounce so we don't fire on every context event past threshold.
|
|
207
|
+
// (Replay already returned above — only fresh compacts reach this point.)
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
if (now < runtime.debounceUntil) {
|
|
210
|
+
runtime.diagCtxDebounce++;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
runtime.debounceUntil = now + 2000;
|
|
212
214
|
// Adaptive compression (Fix E): scale compression strength + keepFrom depth
|
|
213
215
|
// with how close we are to the model context limit. Null-safe: when the
|
|
214
216
|
// token-fallback path ran (pct unavailable) use the token-basis pressure
|
|
@@ -219,8 +221,22 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
219
221
|
const ran = runCompact(pi, runtime, config, ctx, messages, {
|
|
220
222
|
compressionPressure: pressure,
|
|
221
223
|
});
|
|
224
|
+
// D.3: skip paths fall back to replay instead of returning empty.
|
|
225
|
+
// If runCompact skipped and we have a valid trimCache, replay it
|
|
226
|
+
// (free stability win) — otherwise defer to the next event.
|
|
222
227
|
if (ran.skipped) {
|
|
223
228
|
runtime.diagCtxRunSkipped++;
|
|
229
|
+
if (runtime.trimCache &&
|
|
230
|
+
runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
|
|
231
|
+
runtime.trimCache.cut <= messages.length) {
|
|
232
|
+
const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002
|
|
233
|
+
runtime.diagLiveTrimFires++;
|
|
234
|
+
runtime.diagLiveTrimReplays++;
|
|
235
|
+
runtime.snapshot(ctx);
|
|
236
|
+
return {
|
|
237
|
+
messages: [{ ...runtime.trimCache.summaryAgentMsg }, ...recent],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
224
240
|
return;
|
|
225
241
|
}
|
|
226
242
|
// S27 DB-mirror: write checkpoint_epoch with deterministic nonce.
|
|
@@ -277,7 +293,9 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
277
293
|
}
|
|
278
294
|
}
|
|
279
295
|
catch (wikiErr) {
|
|
280
|
-
runtime.logger.warn("wiki_rebuild_failed", {
|
|
296
|
+
runtime.logger.warn("wiki_rebuild_failed", {
|
|
297
|
+
error: String(wikiErr),
|
|
298
|
+
});
|
|
281
299
|
}
|
|
282
300
|
// S27 Task 6: Fire-and-forget dedup pipeline.
|
|
283
301
|
// Deduplicates raw_transcript rows for the compacted range.
|
|
@@ -11,42 +11,60 @@
|
|
|
11
11
|
* compact per signature). Conservative: when unsure between transient and
|
|
12
12
|
* poisoned, choose transient (bounded by R1 dedup + R2 session cap).
|
|
13
13
|
*/
|
|
14
|
-
/**
|
|
14
|
+
/** R7: known-retryable transient markers — network/throughput failures that
|
|
15
|
+
* retry can fix and /clear cannot. SINGLE SOURCE OF TRUTH: classifyError uses
|
|
16
|
+
* this for its transient-marker stage, and the agent-handlers R3 repeat
|
|
17
|
+
* upgrade guard uses it to keep these errors out of poisoned-context. Add new
|
|
18
|
+
* transient phrasings HERE only.
|
|
15
19
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
* Non-obvious alternatives:
|
|
21
|
+
* - `time(?:d[\s-]?out|out)` covers "timeout", "timed out", "timed-out" —
|
|
22
|
+
* "timed out" does NOT match /timeout/ (the 2026-07-30 incident phrasing).
|
|
23
|
+
* - `etimedout` is Node's timeout errno lowercased — does NOT contain the
|
|
24
|
+
* substring "timeout".
|
|
25
|
+
* - `socket hang up` is Node's ECONNRESET *message*; the errno lives in
|
|
26
|
+
* error.code, which extractErrorSignature never sees.
|
|
27
|
+
* - router phrasings (all targets failed / no healthy target / too many
|
|
28
|
+
* concurrent) -- pi's status prefix is console-only (2026-07-30 incident). */
|
|
29
|
+
export const KNOWN_RETRYABLE_TRANSIENT_PATTERN = /max(imum)? output token|rate[\s.-]?limit|429|too many requests|too many concurrent|overloaded|5\d\d|internal server|bad gateway|service unavailable|all targets failed|no healthy target|fetch failed|network|time(?:d[\s-]?out|out)|etimedout|econnreset|econnrefused|epipe|eai_again|socket|closed unexpectedly|premature close|other side closed|connection (lost|refused|reset|aborted?|was closed)|stream (interrupted|closed|ended|failed)|disconnected/;
|
|
30
|
+
/** R7: true when the error text carries a known-retryable transient marker.
|
|
31
|
+
* Defensively lowercases so callers can pass raw or normalized text. */
|
|
32
|
+
export function isKnownRetryableTransient(text) {
|
|
33
|
+
return KNOWN_RETRYABLE_TRANSIENT_PATTERN.test(text.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
/** R8: best-effort extraction of HTTP status from pi AgentMessage shapes. */
|
|
36
|
+
function extractHttpStatus(m) {
|
|
37
|
+
for (const outer of [m.error, m]) {
|
|
38
|
+
if (!outer || typeof outer !== 'object')
|
|
39
|
+
continue;
|
|
40
|
+
for (const key of ['status', 'statusCode', 'code']) {
|
|
41
|
+
const v = outer[key];
|
|
42
|
+
if (typeof v === 'number' && v >= 100 && v <= 599)
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** R11: classify a turn-end error/stop signal into a retry category with a
|
|
49
|
+
* diagnostic signal tag naming the rule that fired, plus the raw httpStatus
|
|
50
|
+
* when extractHttpStatus found one. */
|
|
51
|
+
export function classifyErrorDetailed(message) {
|
|
24
52
|
let text = '';
|
|
25
|
-
// R3: usage-token signal. Only when `usage` is explicitly present with
|
|
26
|
-
// inputTokens=0 AND outputTokens=0 do we know the turn never reached the
|
|
27
|
-
// model. An ABSENT usage field means "unknown" (the event didn't carry
|
|
28
|
-
// token counts) — we do NOT treat that as the 0-token poisoned signal,
|
|
29
|
-
// because mid-response stream deaths and partial-content turns routinely
|
|
30
|
-
// omit usage while still being transient (preserves all pre-R3 tests).
|
|
31
53
|
let usagePresent = false;
|
|
32
54
|
let totalTokens = 0;
|
|
55
|
+
let httpStatus;
|
|
33
56
|
if (typeof message === 'string') {
|
|
34
57
|
text = message;
|
|
35
58
|
}
|
|
36
59
|
else if (message && typeof message === 'object') {
|
|
37
60
|
const m = message;
|
|
38
61
|
const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
|
|
39
|
-
// S28 guard: length stopReason is handled exclusively by the S28 path.
|
|
40
62
|
if (sr === 'length')
|
|
41
|
-
return null;
|
|
42
|
-
// User ESC / Ctrl-C abort — stopReason === 'aborted'. Not retryable:
|
|
43
|
-
// nudging would restart a task the user explicitly stopped.
|
|
63
|
+
return { category: null, signal: 'length-guard' };
|
|
44
64
|
if (sr === 'aborted')
|
|
45
|
-
return 'cancelled';
|
|
46
|
-
// Success / normal tool flow — not an error, nothing to retry.
|
|
65
|
+
return { category: 'cancelled', signal: 'cancelled' };
|
|
47
66
|
if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use')
|
|
48
|
-
return null;
|
|
49
|
-
// R3: extract usage tokens for the 0-token poisoned-context signal.
|
|
67
|
+
return { category: null, signal: 'success' };
|
|
50
68
|
const usage = m.usage;
|
|
51
69
|
usagePresent = usage != null && typeof usage === 'object';
|
|
52
70
|
if (usagePresent) {
|
|
@@ -55,6 +73,7 @@ export function classifyError(message) {
|
|
|
55
73
|
(typeof u.inputTokens === 'number' ? u.inputTokens : 0) +
|
|
56
74
|
(typeof u.outputTokens === 'number' ? u.outputTokens : 0);
|
|
57
75
|
}
|
|
76
|
+
httpStatus = extractHttpStatus(m);
|
|
58
77
|
const parts = [];
|
|
59
78
|
if (sr)
|
|
60
79
|
parts.push(sr);
|
|
@@ -69,136 +88,75 @@ export function classifyError(message) {
|
|
|
69
88
|
}
|
|
70
89
|
}
|
|
71
90
|
if (m.error) {
|
|
72
|
-
// Extract message from error objects for pattern matching.
|
|
73
91
|
const err = m.error;
|
|
74
|
-
if (typeof err === 'string')
|
|
92
|
+
if (typeof err === 'string')
|
|
75
93
|
parts.push(err);
|
|
76
|
-
}
|
|
77
94
|
else if (err && typeof err === 'object') {
|
|
78
95
|
const errObj = err;
|
|
79
|
-
if (typeof errObj.message === 'string')
|
|
96
|
+
if (typeof errObj.message === 'string')
|
|
80
97
|
parts.push(errObj.message);
|
|
81
|
-
|
|
82
|
-
else {
|
|
98
|
+
else
|
|
83
99
|
parts.push(JSON.stringify(err));
|
|
84
|
-
}
|
|
85
100
|
}
|
|
86
101
|
}
|
|
87
|
-
// S38: detect mid-response errors where the stream died without a
|
|
88
|
-
// proper stopReason (empty/undefined) — this catches provider failures
|
|
89
|
-
// that cut off the response mid-stream, INCLUDING the case where the
|
|
90
|
-
// provider emitted partial content before dying (a truncated response
|
|
91
|
-
// with no stop reason is a mid-stream death, not a success).
|
|
92
|
-
// A genuine success ALWAYS carries stop/tool_use/toolUse (which
|
|
93
|
-
// short-circuit to null at the top), so any message reaching here with a
|
|
94
|
-
// falsy stopReason is a stream failure → retryable.
|
|
95
102
|
if (!sr) {
|
|
96
|
-
|
|
103
|
+
const r = { category: 'transient', signal: 'stream-death-no-stopreason' };
|
|
104
|
+
if (httpStatus !== undefined)
|
|
105
|
+
r.httpStatus = httpStatus;
|
|
106
|
+
return r;
|
|
97
107
|
}
|
|
98
108
|
text = parts.join(' ');
|
|
99
109
|
}
|
|
100
110
|
if (!text)
|
|
101
|
-
return null;
|
|
111
|
+
return { category: null, signal: 'unknown' };
|
|
102
112
|
const s = text.toLowerCase();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (/already compacted/.test(s))
|
|
107
|
-
return 'compaction-noop';
|
|
108
|
-
if (/compaction failed/.test(s))
|
|
109
|
-
return 'compaction-noop';
|
|
110
|
-
if (/nothing to compact/.test(s))
|
|
111
|
-
return 'compaction-noop';
|
|
112
|
-
if (/auto[\s-]?compaction failed/.test(s))
|
|
113
|
-
return 'compaction-noop';
|
|
114
|
-
// --- context-overflow (ORDER BEFORE generic transient!) ---
|
|
115
|
-
// A 400 from the model meaning "the prompt is bigger than the context window."
|
|
116
|
-
// Catches BOTH provider phrasings so the classification does not depend on
|
|
117
|
-
// which backend the router landed on:
|
|
118
|
-
// - pi/Anthropic wrapper: "too long for this model's context window even
|
|
119
|
-
// after compaction. Reduce the conversation length..." ->
|
|
120
|
-
// too long | context window | even after compaction | reduce the conversation
|
|
121
|
-
// - OpenAI/OpenRouter provider-side (often wrapped in "All targets failed:
|
|
122
|
-
// <model>. Last error: ..."): "This model's maximum context length is N
|
|
123
|
-
// tokens. Your request requires at least M tokens. Please reduce your
|
|
124
|
-
// input or max_tokens." -> maximum context length | context length
|
|
125
|
-
// exceeded | requires at least N tokens | reduce your input
|
|
126
|
-
// All of these carry `invalid_request_error` (UNDERSCORE, not 'invalid
|
|
127
|
-
// request' space) and would otherwise fall through to the generic
|
|
128
|
-
// `s.includes('error')` transient branch below, misclassifying as
|
|
129
|
-
// 'transient' and firing up to 5 blind retry nudges that re-submit the same
|
|
130
|
-
// oversized prompt -> re-400 -> busy-loop. 'context-overflow' instead forces
|
|
131
|
-
// ONE deferred re-compact (debounce-bypassed, race-guarded) and fires NO
|
|
132
|
-
// blind retry nudge. The forced re-compact is shaped by the existing
|
|
133
|
-
// session_before_compact durable trim (it cannot lower pi's firstKeptEntryId).
|
|
113
|
+
if (/already compacted|compaction failed|nothing to compact|auto[\s-]?compaction failed/.test(s)) {
|
|
114
|
+
return { category: 'compaction-noop', signal: 'compaction-noop' };
|
|
115
|
+
}
|
|
134
116
|
if (/too long|context window|maximum context length|context length exceeded|requires at least \d+ tokens|even after compaction|reduce the conversation|reduce your input/.test(s)) {
|
|
135
|
-
return 'context-overflow';
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
return 'transient';
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// so "Connection aborted by peer" stays transient under R3. ECONNRESET and
|
|
150
|
-
// common errno names are included so a bare 0-token 'ECONNRESET' turn is not
|
|
151
|
-
// misclassified as poisoned (R6: ECONNRESET → transient).
|
|
152
|
-
if (/network|timeout|econnreset|econnrefused|epipe|connection (lost|refused|reset|aborted)|stream (interrupted|closed|ended|failed)|disconnected/.test(s))
|
|
153
|
-
return 'transient';
|
|
154
|
-
// --- poisoned-context (R3: ORDER AFTER specific transient markers, BEFORE
|
|
155
|
-
// the generic 'error' transient fallthrough) ---
|
|
156
|
-
// A DETERMINISTIC request-rejection that retrying cannot fix. Re-submitting
|
|
157
|
-
// the same prompt re-triggers the same rejection. The 2026-07-28 incident
|
|
158
|
-
// was a provider returning 0-token "Request failed — please retry." turns
|
|
159
|
-
// for every request; classifyError mapped stopReason 'error' → 'transient'
|
|
160
|
-
// and the session emitted ~9 nudge bursts (~60 context-bloating messages)
|
|
161
|
-
// before stopping.
|
|
162
|
-
//
|
|
163
|
-
// Signal 1: provider request-validation 400 that is NOT context-overflow
|
|
164
|
-
// (context-overflow already returned above). Orphaned tool result / malformed
|
|
165
|
-
// message structure / unexpected role ordering / empty content. These were
|
|
166
|
-
// previously classified 'permanent' (max 1 retry) — but a single retry
|
|
167
|
-
// re-submits the SAME malformed structure and re-400s, so they are poisoned,
|
|
168
|
-
// not permanent. Auth/permission 400s stay permanent (separate check below).
|
|
117
|
+
return { category: 'context-overflow', signal: 'context-overflow' };
|
|
118
|
+
}
|
|
119
|
+
if (isKnownRetryableTransient(s)) {
|
|
120
|
+
const r = { category: 'transient', signal: 'transient-marker' };
|
|
121
|
+
if (httpStatus !== undefined)
|
|
122
|
+
r.httpStatus = httpStatus;
|
|
123
|
+
return r;
|
|
124
|
+
}
|
|
125
|
+
if (httpStatus === 429 || (httpStatus !== undefined && httpStatus >= 500)) {
|
|
126
|
+
return { category: 'transient', signal: 'transient-status', httpStatus };
|
|
127
|
+
}
|
|
128
|
+
if (httpStatus === 401 || httpStatus === 403) {
|
|
129
|
+
return { category: 'permanent', signal: 'permanent-status', httpStatus };
|
|
130
|
+
}
|
|
169
131
|
if (/invalid_request_error|invalid request|malformed|bad request|orphaned tool|tool (result )?(without|for )|unexpected role|role (ordering|sequence)|empty (content|message|request)/.test(s)) {
|
|
170
|
-
return 'poisoned-context';
|
|
132
|
+
return { category: 'poisoned-context', signal: 'poisoned-invalid-request' };
|
|
171
133
|
}
|
|
172
|
-
// Signal 2: generic catch-all "request failed" / "request error" with no
|
|
173
|
-
// specific transient marker (transient markers already returned above).
|
|
174
|
-
// This is the exact phrasing the 2026-07-28 incident used. Without a
|
|
175
|
-
// specific retryable cause, treat the rejection as deterministic.
|
|
176
134
|
if (/request failed|request error/.test(s)) {
|
|
177
|
-
return 'poisoned-context';
|
|
178
|
-
}
|
|
179
|
-
// Signal 3: stopReason 'error' + usage explicitly 0 tokens (the turn never
|
|
180
|
-
// reached the model). Conservative: only when `usage` is PRESENT and 0 — an
|
|
181
|
-
// absent usage field means "unknown" and stays transient (mid-response
|
|
182
|
-
// deaths / partial-content turns often omit usage). The transient markers
|
|
183
|
-
// above already returned, so reaching here with 'error' text and 0 tokens
|
|
184
|
-
// means the request was rejected before any model work.
|
|
135
|
+
return { category: 'poisoned-context', signal: 'poisoned-request-failed' };
|
|
136
|
+
}
|
|
185
137
|
if (usagePresent && totalTokens === 0 && /error/.test(s)) {
|
|
186
|
-
return '
|
|
138
|
+
return { category: 'transient', signal: 'bare-0-token' };
|
|
187
139
|
}
|
|
188
|
-
// --- transient (generic 'error' stopReason fallthrough) ---
|
|
189
|
-
// Reaches here for a generic stopReason 'error' with no specific marker and
|
|
190
|
-
// no 0-token signal (usage absent or > 0). Stays transient — conservative.
|
|
191
140
|
if (s.includes('error') && !/\b(auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
|
|
192
|
-
return 'transient'
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
141
|
+
return { category: 'transient', signal: 'generic-error' };
|
|
142
|
+
}
|
|
143
|
+
if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) {
|
|
144
|
+
return { category: 'permanent', signal: 'permanent-auth' };
|
|
145
|
+
}
|
|
146
|
+
return { category: null, signal: 'unknown' };
|
|
147
|
+
}
|
|
148
|
+
/** S38.2: classify a turn-end error/stop signal into a retry category.
|
|
149
|
+
*
|
|
150
|
+
* `length` is returned as null — S28 owns the max-output-token length stopReason
|
|
151
|
+
* exclusively (its agent_end nudge path is separate and must not be doubled).
|
|
152
|
+
*
|
|
153
|
+
* Thin wrapper around classifyErrorDetailed — returns only the category.
|
|
154
|
+
*
|
|
155
|
+
* @param message the event.message (a pi AgentMessage) or an error string
|
|
156
|
+
* @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
|
|
157
|
+
*/
|
|
158
|
+
export function classifyError(message) {
|
|
159
|
+
return classifyErrorDetailed(message).category;
|
|
202
160
|
}
|
|
203
161
|
/** S38.2: exponential backoff for error-retry nudges.
|
|
204
162
|
* count is 1-based (the retry about to fire). baseMs is the unit
|
|
@@ -219,10 +177,33 @@ export function errorRetryBackoffMs(count, baseMs = 5000) {
|
|
|
219
177
|
* 'error' for retryable turns) so two turns with the same error content share
|
|
220
178
|
* a signature. Empty string when there is no error text (e.g. a bare
|
|
221
179
|
* stopReason 'error' with no content) — the caller skips repeat detection in
|
|
222
|
-
* that case (the stateless 0-token signal handles bare errors).
|
|
180
|
+
* that case (the stateless 0-token signal handles bare errors).
|
|
181
|
+
*
|
|
182
|
+
* R9: when the joined text is empty AND stopReason is 'error' AND usage is
|
|
183
|
+
* present with 0 total tokens, returns the constant "bare-0-token-error" so
|
|
184
|
+
* the repeat detector can corroborate bare 0-token errors (first turn is
|
|
185
|
+
* transient; repeated occurrences upgrade to poisoned at threshold). Bare
|
|
186
|
+
* 'error' WITHOUT usage keeps returning '' (absent usage = unknown; mid-
|
|
187
|
+
* response deaths stay out of repeat tracking). */
|
|
188
|
+
/** R12: normalize volatile tokens in an already-lowercased error string so that
|
|
189
|
+
* equivalent errors with varied model aliases, IPs, hex request-ids, retry
|
|
190
|
+
* counts, and long numeric ids produce the same signature. Order matters.
|
|
191
|
+
* 3-digit HTTP status codes (429/500/502/etc.) survive — the long-number rule
|
|
192
|
+
* only covers 4+ digits. Invariant: normalizeVolatileTokens never empties a
|
|
193
|
+
* non-empty string (every regex replaces with a non-empty literal token). */
|
|
194
|
+
function normalizeVolatileTokens(s) {
|
|
195
|
+
let n = s;
|
|
196
|
+
n = n.replace(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, '<ip>'); // 1. IP:port / bare IPv4
|
|
197
|
+
n = n.replace(/\b[\w-]+(\/[\w.:+-]+)+/g, '<model>'); // 2. slash-separated paths
|
|
198
|
+
n = n.replace(/\b[0-9a-f]{8,}\b/g, '<hex>'); // 3. hex ids (8+ chars)
|
|
199
|
+
n = n.replace(/\b\d{4,}\b/g, '<n>'); // 4. long numbers (4+ digits)
|
|
200
|
+
n = n.replace(/\bafter \d+ attempts?\b/g, 'after <n> attempts'); // 5. attempt phrasing
|
|
201
|
+
n = n.replace(/\s+/g, ' ').trim(); // 6. collapse whitespace
|
|
202
|
+
return n;
|
|
203
|
+
}
|
|
223
204
|
export function extractErrorSignature(message) {
|
|
224
205
|
if (typeof message === 'string')
|
|
225
|
-
return message.toLowerCase().trim();
|
|
206
|
+
return normalizeVolatileTokens(message.toLowerCase().trim());
|
|
226
207
|
if (!message || typeof message !== 'object')
|
|
227
208
|
return '';
|
|
228
209
|
const m = message;
|
|
@@ -249,5 +230,14 @@ export function extractErrorSignature(message) {
|
|
|
249
230
|
parts.push(JSON.stringify(err));
|
|
250
231
|
}
|
|
251
232
|
}
|
|
252
|
-
|
|
233
|
+
const joined = parts.join(' ').toLowerCase().trim();
|
|
234
|
+
const normalized = normalizeVolatileTokens(joined);
|
|
235
|
+
if (normalized === '' &&
|
|
236
|
+
m.stopReason === 'error' &&
|
|
237
|
+
m.usage &&
|
|
238
|
+
typeof m.usage === 'object' &&
|
|
239
|
+
(m.usage.inputTokens ?? 0) + (m.usage.outputTokens ?? 0) === 0) {
|
|
240
|
+
return 'bare-0-token-error';
|
|
241
|
+
}
|
|
242
|
+
return normalized;
|
|
253
243
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outage-advisor.ts — R10: calm "provider outage" advisory.
|
|
3
|
+
*
|
|
4
|
+
* When a provider/router flaps (transient errors: timeouts, 5xx, 429s),
|
|
5
|
+
* the extension retries silently and eventually goes quiet after caps. This
|
|
6
|
+
* module sends ONE user-facing advisory per outage episode so the user knows
|
|
7
|
+
* to wait rather than running /clear.
|
|
8
|
+
*
|
|
9
|
+
* Distinct from the poisoned-context /clear advise: the outage advisory is
|
|
10
|
+
* for transient errors where the user's context is fine.
|
|
11
|
+
*
|
|
12
|
+
* PREVENT-PI-003: sends via safeSendUserMessage (user-role only).
|
|
13
|
+
* PREVENT-PI-004: local ctx call, no network.
|
|
14
|
+
*/
|
|
15
|
+
import { safeSendUserMessage } from "./send-safe.js";
|
|
16
|
+
/**
|
|
17
|
+
* Check the provider-outage advisory condition and fire once per episode.
|
|
18
|
+
*
|
|
19
|
+
* Called from the transient/permanent retry branch of the turn_end handler,
|
|
20
|
+
* AFTER consecutiveErrors is incremented.
|
|
21
|
+
*
|
|
22
|
+
* @param effectiveCategory — must be "transient" for the advisory to fire.
|
|
23
|
+
* @param runtime — the live MegaRuntime (mutated on advisory fire).
|
|
24
|
+
* @param pi — pi ExtensionAPI for safeSendUserMessage.
|
|
25
|
+
* @param config — providerOutageAdviseThreshold config.
|
|
26
|
+
* @param detail — R11: optional signal + rawText for forensics.
|
|
27
|
+
*/
|
|
28
|
+
export async function maybeSendProviderOutageAdvisory(effectiveCategory, runtime, pi, config, detail) {
|
|
29
|
+
if (effectiveCategory !== "transient")
|
|
30
|
+
return;
|
|
31
|
+
if (config.providerOutageAdviseThreshold <= 0)
|
|
32
|
+
return;
|
|
33
|
+
if (runtime.rt.providerOutageAdvised)
|
|
34
|
+
return;
|
|
35
|
+
if (runtime.rt.consecutiveErrors < config.providerOutageAdviseThreshold)
|
|
36
|
+
return;
|
|
37
|
+
runtime.rt.providerOutageAdvised = true;
|
|
38
|
+
runtime.dashboard.event("provider_outage_advised", {
|
|
39
|
+
consecutiveErrors: runtime.rt.consecutiveErrors,
|
|
40
|
+
turnIndex: runtime.currentTurn,
|
|
41
|
+
sessionId: runtime.rt.sessionId,
|
|
42
|
+
...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
|
|
43
|
+
...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
|
|
44
|
+
});
|
|
45
|
+
runtime.logger.info("provider-outage-advised", {
|
|
46
|
+
sessionId: runtime.rt.sessionId,
|
|
47
|
+
consecutiveErrors: runtime.rt.consecutiveErrors,
|
|
48
|
+
turnIndex: runtime.currentTurn,
|
|
49
|
+
...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
|
|
50
|
+
...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
|
|
51
|
+
});
|
|
52
|
+
await safeSendUserMessage(pi, `[mega-compact] the provider is having issues (${runtime.rt.consecutiveErrors} consecutive failures — timeouts/5xx/rate-limits). Retries are bounded and continue automatically; your context is fine — do NOT clear or reset it. Work resumes as soon as the provider recovers.`);
|
|
53
|
+
}
|