pi-mega-compact 0.8.24 → 0.8.26

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.
Files changed (111) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/mega-compact-s38.test.js +263 -14
  3. package/dist/extensions/mega-compact.js +15 -0
  4. package/dist/extensions/mega-config.js +3 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  6. package/dist/extensions/mega-events/context-handler.js +45 -7
  7. package/dist/extensions/mega-events/error-classifier.js +125 -18
  8. package/dist/extensions/mega-pipeline/compact.js +24 -13
  9. package/dist/extensions/mega-pipeline/recall.js +31 -2
  10. package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
  12. package/dist/extensions/mega-runtime/runtime.js +58 -5
  13. package/dist/src/boundary.js +79 -43
  14. package/dist/src/boundary.test.js +119 -2
  15. package/dist/src/canary.js +10 -0
  16. package/dist/src/config/dedup.js +14 -0
  17. package/dist/src/config.js +3 -1
  18. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  19. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  20. package/dist/src/dedup/raptor/index.js +38 -0
  21. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  22. package/dist/src/dedup/raptor/multilevel.js +17 -5
  23. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  24. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  25. package/dist/src/dedup/raptor/retrieval.js +14 -2
  26. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  27. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  28. package/dist/src/dedup/raptor/summarizer.js +1 -0
  29. package/dist/src/dedup/raptor/tree.js +16 -2
  30. package/dist/src/engine.js +18 -2
  31. package/dist/src/httpEmbedder.js +96 -6
  32. package/dist/src/httpEmbedder.test.js +277 -0
  33. package/dist/src/mechanical-fix.test.js +65 -0
  34. package/dist/src/raptor-inject-summaries.test.js +162 -0
  35. package/dist/src/recall.js +153 -24
  36. package/dist/src/recall.test.js +179 -4
  37. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  38. package/dist/src/store/sqlite/maintenance.js +2 -2
  39. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  40. package/dist/src/store/sqlite/memories.js +5 -5
  41. package/dist/src/store/sqlite/meta.js +1 -1
  42. package/dist/src/store/sqlite/raptor.js +56 -17
  43. package/dist/src/store/sqlite/raptor.test.js +106 -0
  44. package/dist/src/store/sqlite/schema.js +90 -1
  45. package/dist/src/store/sqlite/session-state.js +9 -3
  46. package/dist/src/store/sqlite/stats.js +9 -5
  47. package/dist/src/store/sqlite/turns.js +179 -0
  48. package/dist/src/store/sqlite/turns.test.js +183 -0
  49. package/dist/src/store/sqlite/utils.js +15 -4
  50. package/dist/src/store/sqlite.js +1 -0
  51. package/dist/src/store.js +2 -2
  52. package/dist/src/vector-search-cache.test.js +157 -0
  53. package/dist/src/vector-search.js +107 -15
  54. package/dist/src/vectorStore.js +36 -8
  55. package/extensions/mega-compact-s38.test.ts +259 -14
  56. package/extensions/mega-compact.ts +15 -0
  57. package/extensions/mega-config.ts +18 -0
  58. package/extensions/mega-dashboard.ts +10 -1
  59. package/extensions/mega-events/agent-handlers.ts +211 -26
  60. package/extensions/mega-events/context-handler.ts +43 -7
  61. package/extensions/mega-events/error-classifier.ts +125 -17
  62. package/extensions/mega-pipeline/compact.ts +28 -16
  63. package/extensions/mega-pipeline/recall.ts +34 -2
  64. package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
  65. package/extensions/mega-runtime/helpers.ts +25 -1
  66. package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
  67. package/extensions/mega-runtime/runtime.ts +69 -23
  68. package/package.json +1 -1
  69. package/src/boundary.test.ts +128 -2
  70. package/src/boundary.ts +75 -39
  71. package/src/canary.ts +10 -0
  72. package/src/config/dedup.ts +25 -0
  73. package/src/config.ts +3 -1
  74. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  75. package/src/dedup/raptor/buildHistory.ts +259 -0
  76. package/src/dedup/raptor/index.ts +38 -0
  77. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  78. package/src/dedup/raptor/multilevel.test.ts +47 -0
  79. package/src/dedup/raptor/multilevel.ts +18 -8
  80. package/src/dedup/raptor/raptor.test.ts +59 -0
  81. package/src/dedup/raptor/retrieval.test.ts +118 -0
  82. package/src/dedup/raptor/retrieval.ts +14 -2
  83. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  84. package/src/dedup/raptor/summarizer.ts +1 -0
  85. package/src/dedup/raptor/tree.ts +17 -2
  86. package/src/engine.ts +32 -3
  87. package/src/httpEmbedder.test.ts +286 -0
  88. package/src/httpEmbedder.ts +98 -8
  89. package/src/mechanical-fix.test.ts +70 -0
  90. package/src/raptor-inject-summaries.test.ts +228 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +462 -265
  93. package/src/store/sqlite/dedup-mirror.ts +35 -18
  94. package/src/store/sqlite/maintenance.ts +2 -2
  95. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  96. package/src/store/sqlite/memories.ts +5 -5
  97. package/src/store/sqlite/meta.ts +1 -1
  98. package/src/store/sqlite/raptor.test.ts +139 -0
  99. package/src/store/sqlite/raptor.ts +135 -81
  100. package/src/store/sqlite/schema.ts +90 -1
  101. package/src/store/sqlite/session-state.ts +9 -3
  102. package/src/store/sqlite/stats.ts +10 -8
  103. package/src/store/sqlite/turns.test.ts +218 -0
  104. package/src/store/sqlite/turns.ts +302 -0
  105. package/src/store/sqlite/utils.ts +14 -4
  106. package/src/store/sqlite.ts +1 -0
  107. package/src/store.ts +9 -2
  108. package/src/vector-search-cache.test.ts +190 -0
  109. package/src/vector-search.ts +273 -156
  110. package/src/vectorStore.ts +443 -382
  111. package/extensions/mega-runtime/reset-runtime.ts +0 -80
@@ -146,12 +146,21 @@ export interface DashboardSnapshot {
146
146
  liveTrimFires: number;
147
147
  liveTrimReplays: number;
148
148
  };
149
- /** S38.8: error-retry state for dashboard "retries" tile. */
149
+ /** S38.8: error-retry state for dashboard "retries" tile.
150
+ * R7 (retry redesign): sessionRetryCount / sessionMax / poisonedCount are
151
+ * ADDITIVE fields — older dashboards that don't read them stay
152
+ * backward-compatible. */
150
153
  retries?: {
151
154
  errorRetryCount: number;
152
155
  consecutiveErrors: number;
153
156
  maxConsecutiveErrors: number;
154
157
  errorRetryHardStop: boolean;
158
+ /** R2: total S38 nudges fired this session across ALL bursts. */
159
+ sessionRetryCount: number;
160
+ /** R2: session-global cap (errorRetrySessionMax). 0 = disabled. */
161
+ sessionMax: number;
162
+ /** R3/R7: number of poisoned-context events this session. */
163
+ poisonedCount: number;
155
164
  };
156
165
  }
157
166
 
@@ -14,11 +14,12 @@ import {
14
14
  memoryReviewCadence,
15
15
  type MegaConfig,
16
16
  } from "../mega-config.js";
17
- import { recordScore } from "../../src/store/sqlite.js";
17
+ import { recordScore, recordTurn } from "../../src/store/sqlite.js";
18
18
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
19
+ import { ensureConversationId } from "../../src/store/sqlite/turns.js";
19
20
  import { isMegaCache } from "../../src/game/scoring.js";
20
21
  import { resolveRepoRoot } from "../mega-config.js";
21
- import { classifyError } from "./error-classifier.js";
22
+ import { classifyError, errorRetryBackoffMs, extractErrorSignature } from "./error-classifier.js";
22
23
  import { safeSendUserMessage } from "./send-safe.js";
23
24
  import { vectorStats } from "../../src/vectorStore.js";
24
25
 
@@ -230,6 +231,15 @@ export function registerAgentHandlers(
230
231
  runtime.currentTurn = event.turnIndex;
231
232
  runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
232
233
  runtime.rt.errorRetryCount = 0; // S38: reset error-retry counter each user turn
234
+ // R4 (turn_end hygiene): a genuine new user prompt consumes any queued
235
+ // retry nudge (deliverAs:'followUp') — pi dispatches it as the prompt for
236
+ // this turn. Clearing retryNudgePending re-arms the dedup gate so the
237
+ // next error turn can fire a fresh nudge (subject to backoff).
238
+ // NOTE: the poisoned-repeat tracker (lastErrorText / errorTextRepeatCount)
239
+ // is NOT reset here — a retry turn consuming the queued nudge is still the
240
+ // same error sequence. The tracker resets on a SUCCESSFUL turn (null) or
241
+ // when a different error text appears, not on the turn boundary.
242
+ runtime.rt.retryNudgePending = false;
233
243
  runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
234
244
  runtime.snapshot(ctx);
235
245
  });
@@ -238,6 +248,26 @@ export function registerAgentHandlers(
238
248
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
239
249
  runtime.snapshot(ctx);
240
250
 
251
+ // S43 (per-turn tracking): record one turn row with the cached metrics so
252
+ // the turn layer is queryable + forkable. Best-effort + non-fatal: a write
253
+ // failure never breaks the agent loop.
254
+ try {
255
+ const convId = ensureConversationId(runtime.rt.sessionId, runtime.currentStateDir);
256
+ recordTurn({
257
+ conversationId: convId,
258
+ sessionId: runtime.rt.sessionId,
259
+ turnIndex: event.turnIndex,
260
+ endedAt: Date.now(),
261
+ startedAt: undefined,
262
+ ctxTokens: runtime.lastCtxTokens ?? undefined,
263
+ ctxPercent: runtime.lastCtxPercent ?? undefined,
264
+ pressureBand: runtime.pressureBand ?? undefined,
265
+ modelId: runtime.currentModel?.modelId ?? undefined,
266
+ }, runtime.currentStateDir);
267
+ } catch {
268
+ /* non-fatal: per-turn tracking never breaks the agent loop */
269
+ }
270
+
241
271
  // S33: game-mode scoring — record turns + cache metrics per repo, and arm
242
272
  // the MEGA CACHE flare (oopsie gag) when the real dedup hit rate exceeds
243
273
  // 100%. Gated behind game_mode_on (no scoring when off). Best-effort +
@@ -319,9 +349,11 @@ export function registerAgentHandlers(
319
349
 
320
350
  // S38: broader error-retry safety net. S28 only catches stopReason==='length';
321
351
  // this catches ALL other error types (provider failure, network timeout, 5xx,
322
- // 429, auth, compaction-noop) that surface at turn_end. Non-fatal: wrapped in
323
- // try/catch so a classifier/retry failure never breaks the agent loop.
324
- // PREVENT-PI-003: retry nudge fires via pi.sendUserMessage (user-role).
352
+ // 429, auth, compaction-noop, poisoned-context) that surface at turn_end.
353
+ // Non-fatal: wrapped in try/catch so a classifier/retry failure never breaks
354
+ // the agent loop. PREVENT-PI-003: retry nudge fires via pi.sendUserMessage
355
+ // (user-role). R1-R4 (retry redesign) bound the loop: in-flight dedup,
356
+ // gating backoff, session-global cap, poisoned-context detection.
325
357
  try {
326
358
  // (1) S28 owns length — skip the classifier entirely for it.
327
359
  const sr = (event.message as { stopReason?: string } | undefined)?.stopReason;
@@ -329,17 +361,51 @@ export function registerAgentHandlers(
329
361
  // S28 handles; nothing for S38 to do here.
330
362
  } else {
331
363
  const category = classifyError(event.message);
332
- if (category === null) {
364
+ // R3: stateful poisoned-context signal — repeated identical error text
365
+ // across consecutive turns. The classifier is stateless, so this
366
+ // upgrade happens here. A 'transient' (or 'poisoned-context') turn
367
+ // whose normalized error signature matches the previous one bumps
368
+ // errorTextRepeatCount; once it crosses the threshold, the category
369
+ // is upgraded to 'poisoned-context' (the request is deterministic).
370
+ // Conservative: only upgrade, never downgrade, and only for transient
371
+ // (a 'permanent' auth error repeating is still permanent).
372
+ let effectiveCategory = category;
373
+ const errSig = extractErrorSignature(event.message);
374
+ if (category === 'transient' || category === 'poisoned-context') {
375
+ if (errSig) {
376
+ if (runtime.rt.lastErrorText === errSig) {
377
+ runtime.rt.errorTextRepeatCount++;
378
+ } else {
379
+ runtime.rt.lastErrorText = errSig;
380
+ runtime.rt.errorTextRepeatCount = 1;
381
+ }
382
+ if (
383
+ runtime.rt.errorTextRepeatCount >= config.poisonedContextRepeatThreshold &&
384
+ category === 'transient'
385
+ ) {
386
+ effectiveCategory = 'poisoned-context';
387
+ }
388
+ }
389
+ } else {
390
+ // Non-transient (success, cancelled, compaction-noop, context-overflow,
391
+ // permanent): a fresh non-identical signal clears the repeat tracker.
392
+ runtime.rt.lastErrorText = undefined;
393
+ runtime.rt.errorTextRepeatCount = 0;
394
+ }
395
+ if (effectiveCategory === null) {
333
396
  // (3) success / normal flow / unknown-but-non-retryable — reset.
334
397
  runtime.rt.errorRetryCount = 0;
335
398
  runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
336
- } else if (category === 'compaction-noop') {
399
+ // R4: a successful assistant turn consumes any queued nudge.
400
+ runtime.rt.retryNudgePending = false;
401
+ } else if (effectiveCategory === 'compaction-noop') {
337
402
  // (4) pi race / manual compact catch — NOT retryable. The compaction
338
403
  // already succeeded via pi's native path; retrying would race again
339
404
  // (FAIL-2026071701). Log a diagnostic, reset the counter, and surface
340
405
  // the original error WITHOUT firing a retry nudge.
341
406
  runtime.rt.errorRetryCount = 0;
342
407
  runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset
408
+ runtime.rt.retryNudgePending = false; // R4: terminal for this burst
343
409
  runtime.dashboard.event('compaction_noop_diagnostic', {
344
410
  turnIndex: event.turnIndex,
345
411
  sessionId: runtime.rt.sessionId,
@@ -348,13 +414,14 @@ export function registerAgentHandlers(
348
414
  sessionId: runtime.rt.sessionId,
349
415
  turnIndex: event.turnIndex,
350
416
  });
351
- } else if (category === 'cancelled') {
417
+ } else if (effectiveCategory === 'cancelled') {
352
418
  // (4c) User ESC / Ctrl-C — stopReason === 'aborted'. NOT retryable:
353
419
  // nudging would restart a task the user explicitly stopped.
354
420
  // Reset both counters (a cancel is not an error for circuit-breaker
355
421
  // purposes). Emit a diagnostic so the dashboard shows it.
356
422
  runtime.rt.errorRetryCount = 0;
357
423
  runtime.rt.consecutiveErrors = 0;
424
+ runtime.rt.retryNudgePending = false; // R4: terminal for this burst
358
425
  runtime.dashboard.event('error_retry_cancelled', {
359
426
  turnIndex: event.turnIndex,
360
427
  sessionId: runtime.rt.sessionId,
@@ -363,7 +430,7 @@ export function registerAgentHandlers(
363
430
  sessionId: runtime.rt.sessionId,
364
431
  turnIndex: event.turnIndex,
365
432
  });
366
- } else if (category === 'context-overflow') {
433
+ } else if (effectiveCategory === 'context-overflow') {
367
434
  // (4b) context-window overflow 400 ("too long... even after compaction").
368
435
  // NOT a blind retry: re-submitting the same oversized prompt would just
369
436
  // re-400 and busy-loop. Reset the counters (this turn is terminal, not
@@ -376,6 +443,7 @@ export function registerAgentHandlers(
376
443
  // separate nudge here.
377
444
  runtime.rt.errorRetryCount = 0;
378
445
  runtime.rt.consecutiveErrors = 0;
446
+ runtime.rt.retryNudgePending = false; // R4: terminal for this burst
379
447
  runtime.dashboard.event('context_overflow', {
380
448
  turnIndex: event.turnIndex,
381
449
  sessionId: runtime.rt.sessionId,
@@ -405,13 +473,76 @@ export function registerAgentHandlers(
405
473
  }, 500);
406
474
  }
407
475
  }
476
+ } else if (effectiveCategory === 'poisoned-context') {
477
+ // R3: poisoned context — a DETERMINISTIC request-rejection that
478
+ // retrying cannot fix (0-token 'error', generic "request failed",
479
+ // 400 non-overflow, or repeated identical error text). NO blind
480
+ // retry nudge — re-submitting the same poisoned prompt re-triggers
481
+ // the same rejection (the 2026-07-28 incident: ~60-message spam).
482
+ // Instead: (a) dashboard event + warn log naming the suspected
483
+ // poison; (b) ONE user-role advise message (/clear or /new) per
484
+ // session, throttled; (c) if config.auto, ONE guarded compact per
485
+ // error signature (same race-guarded deferred path as context-
486
+ // overflow) — only if not already attempted for this signature.
487
+ runtime.rt.errorRetryCount = 0; // poisoned is terminal for this burst
488
+ runtime.rt.consecutiveErrors++; // still counts toward the circuit breaker
489
+ runtime.rt.poisonedCount++; // R7: dashboard counter
490
+ const sig = errSig || 'unknown';
491
+ // (a) dashboard + log
492
+ runtime.dashboard.event('poisoned_context', {
493
+ signature: sig,
494
+ repeatCount: runtime.rt.errorTextRepeatCount,
495
+ turnIndex: event.turnIndex,
496
+ sessionId: runtime.rt.sessionId,
497
+ });
498
+ runtime.logger.warn('poisoned-context', {
499
+ sessionId: runtime.rt.sessionId,
500
+ turnIndex: event.turnIndex,
501
+ signature: sig,
502
+ repeatCount: runtime.rt.errorTextRepeatCount,
503
+ });
504
+ // (b) one-per-session advise message (throttled by poisonedAdviseSent)
505
+ if (!runtime.rt.poisonedAdviseSent) {
506
+ runtime.rt.poisonedAdviseSent = true;
507
+ // PREVENT-PI-003: user-role sendUserMessage only.
508
+ await safeSendUserMessage(
509
+ pi,
510
+ '[mega-compact] this session\'s context may be poisoned (the provider is rejecting every request). Run /clear or /new to start a fresh context.',
511
+ );
512
+ }
513
+ // (c) one guarded compact per error signature (attempt to remove
514
+ // the poisoned region). Race-guarded + deferred, mirroring the
515
+ // context-overflow path. Only if not already attempted for this
516
+ // signature (poisonedCompactSignatures).
517
+ if (config.auto && !runtime.rt.poisonedCompactSignatures.has(sig)) {
518
+ runtime.rt.poisonedCompactSignatures.add(sig);
519
+ const nowP = Date.now();
520
+ const cooldownMsP = config.raceGuardStrict ? 30_000 : 10_000;
521
+ const sinceCompactP = nowP - (runtime.rt.lastNativeCompactAt ?? 0);
522
+ if (sinceCompactP >= cooldownMsP && !piCompactWouldNoop(ctx)) {
523
+ runtime.debounceUntil = nowP + 0;
524
+ const stampP = runtime.rt.lastNativeCompactAt;
525
+ const liveSidP = runtime.rt.sessionId;
526
+ setTimeout(() => {
527
+ try {
528
+ if (runtime.rt.sessionId !== liveSidP) return; // session reset
529
+ const sinceP = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
530
+ if (runtime.rt.lastNativeCompactAt !== stampP && sinceP < cooldownMsP) return;
531
+ if (piCompactWouldNoop(ctx)) return;
532
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated. One guarded compact after a poisoned-context detection.
533
+ } catch {
534
+ /* non-fatal */
535
+ }
536
+ }, 500);
537
+ }
538
+ }
408
539
  } else {
409
540
  // (5) transient or permanent — retry with exponential backoff.
410
541
  // S38.7: hard-stop switch — bypass ALL retry logic when set.
411
542
  if (config.errorRetryHardStop) {
412
543
  runtime.rt.errorRetryCount = 0;
413
544
  runtime.dashboard.event('error_retry_disabled', {
414
- category,
545
+ category: effectiveCategory,
415
546
  turnIndex: event.turnIndex,
416
547
  reason: 'hard-stop',
417
548
  });
@@ -433,7 +564,7 @@ export function registerAgentHandlers(
433
564
  return; // early exit — circuit breaker tripped
434
565
  }
435
566
  const max =
436
- category === 'transient'
567
+ effectiveCategory === 'transient'
437
568
  ? config.autoRetryTransientMax
438
569
  : config.autoRetryPermanentMax;
439
570
  // max === 0 disables the category entirely (revert to S28-only).
@@ -444,39 +575,93 @@ export function registerAgentHandlers(
444
575
  if (runtime.rt.errorRetryCount > max) {
445
576
  // Exhausted — surface the error, reset for the next burst.
446
577
  runtime.dashboard.event('error_retry_exhausted', {
447
- category,
578
+ category: effectiveCategory,
448
579
  count: runtime.rt.errorRetryCount,
449
580
  max,
450
581
  turnIndex: event.turnIndex,
451
582
  });
452
583
  runtime.logger.info('error-retry-exhausted', {
453
584
  sessionId: runtime.rt.sessionId,
454
- category,
585
+ category: effectiveCategory,
455
586
  count: runtime.rt.errorRetryCount,
456
587
  max,
457
588
  });
458
589
  runtime.rt.errorRetryCount = 0;
459
590
  } else {
460
- // S38: fire the retry nudge. Each error turn fires its own
461
- // nudge up to `max` we intentionally do NOT debounce on the
462
- // backoff window (errorRetryUntil) here, because that would
463
- // suppress turns 2..max on a fast-erroring provider (the exact
464
- // scenario this feature targets) and the user could see zero
465
- // retries followed by error_retry_exhausted. The per-turn cap
466
- // (errorRetryCount <= max) plus the session circuit breaker
467
- // (consecutiveErrors > maxConsecutiveErrors) already bound the
468
- // loop, so a tight turn_end storm cannot busy-loop unbounded.
469
- // errorRetryUntil + errorRetryBackoffMs() are retained on the
470
- // runtime for future/optional pacing but are not gating.
591
+ // R2: session-global cap total S38 nudges per session across
592
+ // ALL bursts. Independent of the per-burst max and the circuit
593
+ // breaker. Hitting it is terminal for the session: log +
594
+ // dashboard event, stop nudging. `0` disables (reverts to
595
+ // per-burst + circuit-breaker only).
596
+ if (
597
+ config.errorRetrySessionMax > 0 &&
598
+ runtime.rt.errorRetrySessionCount >= config.errorRetrySessionMax
599
+ ) {
600
+ runtime.dashboard.event('error_retry_session_exhausted', {
601
+ count: runtime.rt.errorRetrySessionCount,
602
+ max: config.errorRetrySessionMax,
603
+ category: effectiveCategory,
604
+ turnIndex: event.turnIndex,
605
+ });
606
+ runtime.logger.warn('error-retry-session-exhausted', {
607
+ sessionId: runtime.rt.sessionId,
608
+ count: runtime.rt.errorRetrySessionCount,
609
+ max: config.errorRetrySessionMax,
610
+ category: effectiveCategory,
611
+ });
612
+ runtime.rt.errorRetryCount = 0;
613
+ return; // terminal for the session — no nudge
614
+ }
615
+ // R1: in-flight nudge dedup — a nudge queued via
616
+ // deliverAs:'followUp' must not be re-sent until it has been
617
+ // consumed by an actual new agent turn (turn_start resets
618
+ // retryNudgePending). Without this, a fast-erroring provider +
619
+ // a per-turn nudge → N nudges queue up and pi dispatches N
620
+ // retry turns, each re-submitting the same failing prompt
621
+ // (the 2026-07-28 incident). errorRetryCount still advances
622
+ // (this IS an error turn), so the per-burst max + circuit
623
+ // breaker still bound the burst.
624
+ if (runtime.rt.retryNudgePending) {
625
+ runtime.dashboard.event('error_retry_dedup_skip', {
626
+ category: effectiveCategory,
627
+ count: runtime.rt.errorRetryCount,
628
+ max,
629
+ turnIndex: event.turnIndex,
630
+ });
631
+ return; // pending nudge not yet consumed — skip
632
+ }
633
+ // R1: gating backoff — errorRetryUntil is now GATING
634
+ // (previously documented as non-gating). A nudge cannot fire
635
+ // before the previous backoff elapses. This paces retries
636
+ // (5s/10s/20s/30s by default) so a fast-erroring provider
637
+ // doesn't slam N nudges in <1s.
638
+ const now = Date.now();
639
+ if (now < runtime.rt.errorRetryUntil) {
640
+ runtime.dashboard.event('error_retry_backoff_skip', {
641
+ category: effectiveCategory,
642
+ count: runtime.rt.errorRetryCount,
643
+ max,
644
+ turnIndex: event.turnIndex,
645
+ });
646
+ return; // backoff not elapsed — skip
647
+ }
648
+ // Fire the retry nudge. Set pending (R1 dedup) + backoff
649
+ // (R1 pacing) + session count (R2 cap) BEFORE the await so a
650
+ // re-entrant turn_end during the send can't double-fire.
651
+ runtime.rt.retryNudgePending = true;
652
+ runtime.rt.lastErrorRetryAt = now;
653
+ runtime.rt.errorRetryUntil =
654
+ now + errorRetryBackoffMs(runtime.rt.errorRetryCount, config.errorRetryBackoffMs);
655
+ runtime.rt.errorRetrySessionCount++;
471
656
  runtime.dashboard.event('error_retry', {
472
- category,
657
+ category: effectiveCategory,
473
658
  count: runtime.rt.errorRetryCount,
474
659
  max,
475
660
  turnIndex: event.turnIndex,
476
661
  });
477
662
  runtime.logger.info('error-retry', {
478
663
  sessionId: runtime.rt.sessionId,
479
- category,
664
+ category: effectiveCategory,
480
665
  count: runtime.rt.errorRetryCount,
481
666
  max,
482
667
  });
@@ -29,12 +29,43 @@ import {
29
29
  pressureRatio,
30
30
  type MegaConfig,
31
31
  } from "../mega-config.js";
32
- import { createHash } from "node:crypto";
32
+ import { computeContentDigest } from "../../src/dedup/digest.js";
33
+
34
+ /**
35
+ * Recursively canonicalize a value for deterministic JSON serialization:
36
+ * - Objects: keys sorted alphabetically, values recursively canonicalized
37
+ * - Arrays: elements recursively canonicalized (array ORDER is preserved)
38
+ * - Primitives: returned as-is
39
+ *
40
+ * F5 fix: shallow-sorted JSON.stringify(content, Object.keys(content).sort())
41
+ * omitted nested keys not in the top-level keys array and only sorted one level.
42
+ * This recursively sorts every object at every depth so semantically-equal
43
+ * differently-ordered content hashes identically.
44
+ */
45
+ function canonicalize(value: unknown): unknown {
46
+ if (value === null || value === undefined) return value;
47
+ if (typeof value !== "object") return value;
48
+ if (Array.isArray(value)) return value.map(canonicalize);
49
+ // Plain object: sort keys, recursively canonicalize values.
50
+ const sorted = Object.keys(value as Record<string, unknown>).sort();
51
+ const out: Record<string, unknown> = {};
52
+ for (const k of sorted) {
53
+ out[k] = canonicalize((value as Record<string, unknown>)[k]);
54
+ }
55
+ return out;
56
+ }
33
57
 
34
58
  /**
35
59
  * Convert a pi AgentMessage to a RawTranscriptRow for the DB mirror.
36
- * content_bytes is canonical JSON (sorted keys) for deterministic hashing.
37
- * Returns null if the message has no usable content.
60
+ * content_bytes is canonical JSON (sorted keys, recursive) for deterministic
61
+ * storage; contentHash is the canonical digest (normalize + hash) that matches
62
+ * what dedupTranscript computes so the two pipelines use the same linkage key.
63
+ *
64
+ * F4 fix: the hash key MUST match on both sides of the append/dedup split.
65
+ * Using computeContentDigest here (normalize → hash) ensures content_ref set
66
+ * by dedupTranscript always resolves to an existing dedup_mirror row even for
67
+ * whitespace/case-variant content. F5 fix: recursive canonicalize replaces the
68
+ * broken shallow-sorted JSON.stringify replacer.
38
69
  */
39
70
  function toRawTranscriptRow(
40
71
  msg: AgentMessage,
@@ -50,12 +81,17 @@ function toRawTranscriptRow(
50
81
  };
51
82
  const content = m.content;
52
83
  if (content == null || content === "") return null;
53
- // Canonical form: sort object keys for deterministic hashing.
84
+ // Canonical bytes: string content normalize for consistent byte content;
85
+ // object content → recursive canonicalize then JSON (no replacer).
54
86
  const contentBytes =
55
87
  typeof content === "string"
56
- ? content
57
- : JSON.stringify(content, Object.keys(content as object).sort());
58
- const contentHash = createHash("sha256").update(contentBytes).digest("hex");
88
+ ? content // F5: strings are primitives; their byte content is fixed
89
+ : JSON.stringify(canonicalize(content));
90
+ // F4 fix: use the same digest as dedupTranscript so contentHash is consistent
91
+ // on both sides of the append/dedup split. computeContentDigest normalizes
92
+ // (strip ANSI, NFC, case-fold, collapse whitespace) then hashes → the same
93
+ // key is stored in raw_transcript.content_hash AND used as the dedup link.
94
+ const { contentHash } = computeContentDigest(contentBytes);
59
95
  return {
60
96
  contentHash,
61
97
  sessionId,
@@ -4,6 +4,12 @@
4
4
  * Classifies turn-end error/stop signals into retry categories.
5
5
  * Ordering matters: compaction-noop is matched BEFORE generic transient
6
6
  * so a pi race / manual compact catch is never misclassified.
7
+ *
8
+ * R3 (retry redesign): a new 'poisoned-context' category for DETERMINISTIC
9
+ * request-rejection failures that retrying cannot fix. See agent-handlers.ts
10
+ * for the poisoned handling (no blind retry; one /clear advise; one guarded
11
+ * compact per signature). Conservative: when unsure between transient and
12
+ * poisoned, choose transient (bounded by R1 dedup + R2 session cap).
7
13
  */
8
14
 
9
15
  /** S38.2: classify a turn-end error/stop signal into a retry category.
@@ -12,7 +18,7 @@
12
18
  * exclusively (its agent_end nudge path is separate and must not be doubled).
13
19
  *
14
20
  * @param message the event.message (a pi AgentMessage) or an error string
15
- * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | null (success/unknown)
21
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
16
22
  */
17
23
  export function classifyError(message: unknown):
18
24
  | 'transient'
@@ -20,9 +26,18 @@ export function classifyError(message: unknown):
20
26
  | 'compaction-noop'
21
27
  | 'context-overflow'
22
28
  | 'cancelled'
29
+ | 'poisoned-context'
23
30
  | null {
24
31
  // Resolve a searchable text blob from a pi AgentMessage or raw string.
25
32
  let text = '';
33
+ // R3: usage-token signal. Only when `usage` is explicitly present with
34
+ // inputTokens=0 AND outputTokens=0 do we know the turn never reached the
35
+ // model. An ABSENT usage field means "unknown" (the event didn't carry
36
+ // token counts) — we do NOT treat that as the 0-token poisoned signal,
37
+ // because mid-response stream deaths and partial-content turns routinely
38
+ // omit usage while still being transient (preserves all pre-R3 tests).
39
+ let usagePresent = false;
40
+ let totalTokens = 0;
26
41
  if (typeof message === 'string') {
27
42
  text = message;
28
43
  } else if (message && typeof message === 'object') {
@@ -30,6 +45,7 @@ export function classifyError(message: unknown):
30
45
  stopReason?: string;
31
46
  content?: unknown;
32
47
  error?: unknown;
48
+ usage?: { inputTokens?: number; outputTokens?: number } | undefined;
33
49
  };
34
50
  const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
35
51
  // S28 guard: length stopReason is handled exclusively by the S28 path.
@@ -39,6 +55,15 @@ export function classifyError(message: unknown):
39
55
  if (sr === 'aborted') return 'cancelled';
40
56
  // Success / normal tool flow — not an error, nothing to retry.
41
57
  if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return null;
58
+ // R3: extract usage tokens for the 0-token poisoned-context signal.
59
+ const usage = m.usage;
60
+ usagePresent = usage != null && typeof usage === 'object';
61
+ if (usagePresent) {
62
+ const u = usage as { inputTokens?: number; outputTokens?: number };
63
+ totalTokens =
64
+ (typeof u.inputTokens === 'number' ? u.inputTokens : 0) +
65
+ (typeof u.outputTokens === 'number' ? u.outputTokens : 0);
66
+ }
42
67
  const parts: string[] = [];
43
68
  if (sr) parts.push(sr);
44
69
  const c = m.content;
@@ -47,7 +72,7 @@ export function classifyError(message: unknown):
47
72
  for (const b of c) {
48
73
  if (b && typeof b === 'object' && 'text' in b) {
49
74
  parts.push(String((b as { text?: string }).text ?? ''));
50
- }
75
+ }
51
76
  }
52
77
  }
53
78
  if (m.error) {
@@ -109,31 +134,114 @@ export function classifyError(message: unknown):
109
134
  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)) {
110
135
  return 'context-overflow';
111
136
  }
112
- // --- transient (retryable) ---
113
- if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
114
- return 'transient'; // generic pi stopReason 'error' / 'aborted'
137
+ // --- transient (specific markers FIRST — these override poisoned signals) ---
138
+ // R3: network failures (timeout, ECONNRESET, 5xx, 429) MUST stay transient
139
+ // even when usage is 0 tokens, because they are retryable. The specific
140
+ // markers below return before any poisoned signal is evaluated.
141
+ if (/max(imum)? output token/.test(s)) return 'transient';
142
+ if (/rate[\s.-]?limit|429|too many requests/.test(s)) return 'transient';
143
+ if (/5\d\d|internal server|bad gateway|service unavailable/.test(s)) return 'transient';
144
+ // 'connection aborted' is a network failure (ECONNABORTED), NOT a user ESC
145
+ // (which is stopReason 'aborted', handled by the early-return above). Added
146
+ // so "Connection aborted by peer" stays transient under R3. ECONNRESET and
147
+ // common errno names are included so a bare 0-token 'ECONNRESET' turn is not
148
+ // misclassified as poisoned (R6: ECONNRESET → transient).
149
+ if (/network|timeout|econnreset|econnrefused|epipe|connection (lost|refused|reset|aborted)|stream (interrupted|closed|ended|failed)|disconnected/.test(s)) return 'transient';
150
+ // --- poisoned-context (R3: ORDER AFTER specific transient markers, BEFORE
151
+ // the generic 'error' transient fallthrough) ---
152
+ // A DETERMINISTIC request-rejection that retrying cannot fix. Re-submitting
153
+ // the same prompt re-triggers the same rejection. The 2026-07-28 incident
154
+ // was a provider returning 0-token "Request failed — please retry." turns
155
+ // for every request; classifyError mapped stopReason 'error' → 'transient'
156
+ // and the session emitted ~9 nudge bursts (~60 context-bloating messages)
157
+ // before stopping.
158
+ //
159
+ // Signal 1: provider request-validation 400 that is NOT context-overflow
160
+ // (context-overflow already returned above). Orphaned tool result / malformed
161
+ // message structure / unexpected role ordering / empty content. These were
162
+ // previously classified 'permanent' (max 1 retry) — but a single retry
163
+ // re-submits the SAME malformed structure and re-400s, so they are poisoned,
164
+ // not permanent. Auth/permission 400s stay permanent (separate check below).
165
+ 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)) {
166
+ return 'poisoned-context';
167
+ }
168
+ // Signal 2: generic catch-all "request failed" / "request error" with no
169
+ // specific transient marker (transient markers already returned above).
170
+ // This is the exact phrasing the 2026-07-28 incident used. Without a
171
+ // specific retryable cause, treat the rejection as deterministic.
172
+ if (/request failed|request error/.test(s)) {
173
+ return 'poisoned-context';
174
+ }
175
+ // Signal 3: stopReason 'error' + usage explicitly 0 tokens (the turn never
176
+ // reached the model). Conservative: only when `usage` is PRESENT and 0 — an
177
+ // absent usage field means "unknown" and stays transient (mid-response
178
+ // deaths / partial-content turns often omit usage). The transient markers
179
+ // above already returned, so reaching here with 'error' text and 0 tokens
180
+ // means the request was rejected before any model work.
181
+ if (usagePresent && totalTokens === 0 && /error/.test(s)) {
182
+ return 'poisoned-context';
183
+ }
184
+ // --- transient (generic 'error' stopReason fallthrough) ---
185
+ // Reaches here for a generic stopReason 'error' with no specific marker and
186
+ // no 0-token signal (usage absent or > 0). Stays transient — conservative.
187
+ if (s.includes('error') && !/\b(auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
188
+ return 'transient'; // generic pi stopReason 'error'
115
189
  }
116
190
  // NOTE: 'aborted' stopReason is handled by the sr==='aborted' early-return above.
117
191
  // The text-based s.includes('aborted') was removed — it was the old path that
118
192
  // misclassified user ESC/Ctrl-C as 'transient' (5 retry nudges after cancel).
119
- if (/max(imum)? output token/.test(s)) return 'transient';
120
- if (/rate[\s.-]?limit|429|too many requests/.test(s)) return 'transient';
121
- if (/5\d\d|internal server|bad gateway|service unavailable/.test(s)) return 'transient';
122
- if (/network|timeout|connection (lost|refused|reset)|stream (interrupted|closed|ended|failed)|disconnected/.test(s)) return 'transient';
123
193
  // --- permanent (NOT retryable beyond 1) ---
124
194
  if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) return 'permanent';
125
- if (/invalid request|malformed|bad request/.test(s)) return 'permanent';
126
195
  // Unknown — do not retry (avoid busy-looping on an unclassified signal).
127
196
  return null;
128
197
  }
129
198
 
130
- /** S38.2: exponential backoff for error-retry nudges (5s,10s,20s,30s,30s cap).
131
- * count is 1-based (the retry about to fire). */
132
- export function errorRetryBackoffMs(count: number): number {
199
+ /** S38.2: exponential backoff for error-retry nudges.
200
+ * count is 1-based (the retry about to fire). baseMs is the unit
201
+ * (config.errorRetryBackoffMs, default 5000 → 5s/10s/20s/30s cap).
202
+ * R1: errorRetryUntil is now GATING — a nudge cannot fire before the previous
203
+ * backoff elapses (previously documented as non-gating). */
204
+ export function errorRetryBackoffMs(count: number, baseMs = 5000): number {
133
205
  switch (count) {
134
- case 1: return 5_000;
135
- case 2: return 10_000;
136
- case 3: return 20_000;
137
- default: return 30_000; // cap from the 4th retry onward
206
+ case 1: return baseMs;
207
+ case 2: return baseMs * 2;
208
+ case 3: return baseMs * 4;
209
+ default: return baseMs * 6; // cap from the 4th retry onward
210
+ }
211
+ }
212
+
213
+ /** R3: extract a normalized error-signature string from a turn_end message for
214
+ * the stateful "repeated identical error text" signal. Returns the lowercased
215
+ * content + error.message blob (WITHOUT the stopReason, which is constant
216
+ * 'error' for retryable turns) so two turns with the same error content share
217
+ * a signature. Empty string when there is no error text (e.g. a bare
218
+ * stopReason 'error' with no content) — the caller skips repeat detection in
219
+ * that case (the stateless 0-token signal handles bare errors). */
220
+ export function extractErrorSignature(message: unknown): string {
221
+ if (typeof message === 'string') return message.toLowerCase().trim();
222
+ if (!message || typeof message !== 'object') return '';
223
+ const m = message as {
224
+ content?: unknown;
225
+ error?: unknown;
226
+ };
227
+ const parts: string[] = [];
228
+ const c = m.content;
229
+ if (typeof c === 'string') parts.push(c);
230
+ else if (Array.isArray(c)) {
231
+ for (const b of c) {
232
+ if (b && typeof b === 'object' && 'text' in b) {
233
+ parts.push(String((b as { text?: string }).text ?? ''));
234
+ }
235
+ }
236
+ }
237
+ if (m.error) {
238
+ const err = m.error;
239
+ if (typeof err === 'string') parts.push(err);
240
+ else if (err && typeof err === 'object') {
241
+ const errObj = err as Record<string, unknown>;
242
+ if (typeof errObj.message === 'string') parts.push(errObj.message);
243
+ else parts.push(JSON.stringify(err));
244
+ }
138
245
  }
246
+ return parts.join(' ').toLowerCase().trim();
139
247
  }