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.
Files changed (112) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +9 -1
  2. package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  4. package/dist/extensions/dashboard-server/index-reader.js +22 -0
  5. package/dist/extensions/dashboard-server/routes-cache.js +80 -0
  6. package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
  7. package/dist/extensions/dashboard-server/routes-maintenance.js +7 -7
  8. package/dist/extensions/dashboard-server/routes-repo.js +24 -0
  9. package/dist/extensions/dashboard-server/routes.js +1 -0
  10. package/dist/extensions/dashboard-server/server.js +3 -1
  11. package/dist/extensions/mega-cache-replay.test.js +233 -24
  12. package/dist/extensions/mega-compact-s38.test.js +778 -20
  13. package/dist/extensions/mega-config.js +4 -0
  14. package/dist/extensions/mega-events/agent-handlers.js +44 -8
  15. package/dist/extensions/mega-events/context-handler.js +37 -19
  16. package/dist/extensions/mega-events/error-classifier.js +125 -135
  17. package/dist/extensions/mega-events/outage-advisor.js +53 -0
  18. package/dist/extensions/mega-runtime/reset-runtime.js +1 -0
  19. package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
  20. package/dist/extensions/mega-runtime/runtime.js +1 -0
  21. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  22. package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
  23. package/dist/extensions/mega-runtime/widget.js +4 -4
  24. package/dist/extensions/mega-runtime/widget.test.js +103 -12
  25. package/dist/src/pricing.js +78 -0
  26. package/dist/src/store/sqlite/perf-samples.js +104 -0
  27. package/dist/src/store/sqlite/perf-samples.test.js +93 -1
  28. package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js → AchievementTiles-vEo2MtE2.js} +2 -2
  29. package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
  30. package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
  31. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B8NqDt7I.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
  32. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
  33. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
  34. package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js → ConfigTab-Cm6ymbmx.js} +2 -2
  35. package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js → EventsTab-DXWl_YzI.js} +2 -2
  37. package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js → GameTab-BLDudcO5.js} +2 -2
  39. package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js.map → GameTab-BLDudcO5.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
  41. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
  42. package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js → MetricsTab-Cq4u-rZF.js} +2 -2
  43. package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
  44. package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js → OverviewTab-D4kGvsiQ.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
  47. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
  48. package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js → SessionsTab-DZC2-GCn.js} +2 -2
  49. package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
  50. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
  51. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
  52. package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js → TopicsTab-01G71hv1.js} +2 -2
  53. package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js.map → TopicsTab-01G71hv1.js.map} +1 -1
  54. package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js → TurnsTab-DLRg0LZT.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
  57. package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js → index-BX0bcgLu.js} +4 -4
  58. package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js.map → index-BX0bcgLu.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js → useSSE-BBYt5UPB.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js.map → useSSE-BBYt5UPB.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/index.html +2 -2
  62. package/extensions/dashboard-client/src/api/client.ts +6 -0
  63. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
  64. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
  65. package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
  66. package/extensions/dashboard-client/src/main.tsx +1 -0
  67. package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
  68. package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
  69. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
  70. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +301 -136
  71. package/extensions/dashboard-client/src/utils/format.ts +42 -7
  72. package/extensions/dashboard-server/api-contracts/endpoints.ts +31 -8
  73. package/extensions/dashboard-server/api-contracts/index.ts +4 -0
  74. package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
  75. package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
  76. package/extensions/dashboard-server/api-contracts.test.ts +3 -2
  77. package/extensions/dashboard-server/index-reader.ts +23 -0
  78. package/extensions/dashboard-server/routes-cache.test.ts +302 -0
  79. package/extensions/dashboard-server/routes-cache.ts +123 -0
  80. package/extensions/dashboard-server/routes-maintenance.ts +18 -11
  81. package/extensions/dashboard-server/routes-repo.ts +31 -4
  82. package/extensions/dashboard-server/routes.ts +1 -0
  83. package/extensions/dashboard-server/server.ts +2 -0
  84. package/extensions/mega-cache-replay.test.ts +481 -166
  85. package/extensions/mega-compact-s38.test.ts +785 -20
  86. package/extensions/mega-config.ts +14 -0
  87. package/extensions/mega-events/agent-handlers.ts +48 -7
  88. package/extensions/mega-events/context-handler.ts +59 -24
  89. package/extensions/mega-events/error-classifier.ts +167 -129
  90. package/extensions/mega-events/outage-advisor.ts +73 -0
  91. package/extensions/mega-runtime/helpers.ts +2 -0
  92. package/extensions/mega-runtime/reset-runtime.ts +1 -0
  93. package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
  94. package/extensions/mega-runtime/runtime.ts +1 -0
  95. package/extensions/mega-runtime/snapshot.test.ts +139 -0
  96. package/extensions/mega-runtime/snapshot.ts +6 -4
  97. package/extensions/mega-runtime/widget.test.ts +290 -105
  98. package/extensions/mega-runtime/widget.ts +29 -6
  99. package/package.json +1 -1
  100. package/src/pricing.ts +95 -0
  101. package/src/store/sqlite/perf-samples.test.ts +107 -3
  102. package/src/store/sqlite/perf-samples.ts +142 -6
  103. package/extensions/dashboard-client/dist/assets/AchievementsTab-B8NqDt7I.js +0 -2
  104. package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js +0 -2
  105. package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js.map +0 -1
  106. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +0 -2
  107. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +0 -1
  108. package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js +0 -2
  109. package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js.map +0 -1
  110. package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js +0 -2
  111. package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js.map +0 -1
  112. package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
@@ -44,6 +44,21 @@ function eventTypes(stateDir: string): string[] {
44
44
  .filter((t): t is string => typeof t === "string");
45
45
  }
46
46
 
47
+ /** R11: read events.log and return full JSON payloads for a given type. */
48
+ function eventPayloads(stateDir: string, type: string): Record<string, unknown>[] {
49
+ const { readFileSync: rf, existsSync: ex } =
50
+ require("node:fs") as typeof import("node:fs");
51
+ const { join: j } = require("node:path") as typeof import("node:path");
52
+ const logPath = j(stateDir, "events.log");
53
+ if (!ex(logPath)) return [];
54
+ const content = rf(logPath, "utf-8").trim();
55
+ if (content.length === 0) return [];
56
+ return content
57
+ .split("\n")
58
+ .map((line) => { try { return JSON.parse(line); } catch { return undefined; } })
59
+ .filter((p): p is Record<string, unknown> => typeof p === "object" && p !== null && p.type === type);
60
+ }
61
+
47
62
  /** Build a mock pi + ctx and load the extension into them. */
48
63
  function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
49
64
  const stateDir = join(baseTmp, `run-${counter++}`);
@@ -106,8 +121,8 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
106
121
  };
107
122
  }
108
123
 
109
- const { classifyError: classifyErrorFn } =
110
- require("./mega-events.js") as { classifyError: typeof import("./mega-events.js").classifyError };
124
+ const { classifyError: classifyErrorFn, classifyErrorDetailed: classifyErrorDetailedFn, extractErrorSignature: extractErrorSignatureFn } =
125
+ require("./mega-events.js") as { classifyError: typeof import("./mega-events.js").classifyError; classifyErrorDetailed: typeof import("./mega-events.js").classifyErrorDetailed; extractErrorSignature: typeof import("./mega-events.js").extractErrorSignature };
111
126
 
112
127
  /** S38 helper: fire a turn_end with a given stopReason + optional text, using a
113
128
  * low-pressure ctx so the durable-trim branch (ctx.compact) does NOT fire. */
@@ -318,14 +333,22 @@ test("S38: R1 burst of immediate transient errors fires 1 nudge (dedup), rest su
318
333
  // the queued nudge (deliverAs:'followUp') has not been consumed by a new
319
334
  // agent turn. errorRetryCount still advances for each error turn, so the
320
335
  // per-burst max + circuit breaker still bound the burst.
321
- const h = harness();
322
- for (let i = 0; i < 5; i++) await s38TurnEnd(h, "error", `internal server error ${i}`);
323
- assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
324
- // The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
325
- await s38TurnEnd(h, "error", "internal server error 5");
326
- assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
327
- assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
328
- assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
336
+ // R10: disable outage advisory so this test isolates nudge dedup only.
337
+ const prevOutage = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
338
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
339
+ try {
340
+ const h = harness();
341
+ for (let i = 0; i < 5; i++) await s38TurnEnd(h, "error", `internal server error ${i}`);
342
+ assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
343
+ // The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
344
+ await s38TurnEnd(h, "error", "internal server error 5");
345
+ assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
346
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
347
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
348
+ } finally {
349
+ if (prevOutage === undefined) delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
350
+ else process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = prevOutage;
351
+ }
329
352
  });
330
353
 
331
354
  test("S38: retry fires 1x for permanent errors then stops", async () => {
@@ -447,10 +470,13 @@ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", asy
447
470
 
448
471
  // ---- R3 classifier unit tests: poisoned-context signals ----
449
472
 
450
- test("R3 classifier: 0-token generic error (usage present, 0 tokens) → poisoned-context", () => {
451
- // The 2026-07-28 incident: stopReason 'error' + usage 0 tokens. The turn
452
- // never reached the model; retrying re-submits the same poisoned context.
453
- assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
473
+ test("R9 classifier: bare 0-token generic error (usage present, 0 tokens) → transient (corroboration required)", () => {
474
+ // R9: The 2026-07-30 incidents proved that 0-token deterministic rejection
475
+ // when a router fronts the provider (the request never reached any model).
476
+ // Signal 3 now returns 'transient'; the repeat detector in agent-handlers.ts
477
+ // becomes the corroboration mechanism. A bare 0-token error that repeats
478
+ // ≥ poisonedContextRepeatThreshold (default 3) upgrades to poisoned.
479
+ assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
454
480
  // Bare stopReason 'error' with NO usage field stays transient (unknown tokens
455
481
  // — conservative; preserves the pre-R3 mid-response/partial-content behavior).
456
482
  assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
@@ -511,8 +537,11 @@ test("R6(a): 10 consecutive identical 0-token transient failures produce at most
511
537
  // exercises the SESSION CAP, not the repeat signal.
512
538
  const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
513
539
  const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
540
+ const prevOutage = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
514
541
  process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
515
542
  process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
543
+ // R10: disable the outage advisory so this test isolates the session cap only.
544
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
516
545
  try {
517
546
  const h = harness();
518
547
  for (let i = 0; i < 10; i++) {
@@ -528,6 +557,8 @@ test("R6(a): 10 consecutive identical 0-token transient failures produce at most
528
557
  else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
529
558
  if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
530
559
  else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
560
+ if (prevOutage === undefined) delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
561
+ else process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = prevOutage;
531
562
  }
532
563
  });
533
564
 
@@ -562,9 +593,12 @@ test("R6(c): transient burst retries with backoff gating — second immediate nu
562
593
  const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
563
594
  const prevSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
564
595
  const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
596
+ const prevOutage = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
565
597
  process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
566
598
  process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999"; // don't let session cap bind
567
599
  process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999"; // don't let repeat upgrade bind
600
+ // R10: disable the outage advisory so this test isolates backoff gating only.
601
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
568
602
  try {
569
603
  const h = harness();
570
604
  // Turn 1: transient → nudge 1 fires (pending=true, backoff=1ms).
@@ -586,6 +620,8 @@ test("R6(c): transient burst retries with backoff gating — second immediate nu
586
620
  else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = prevSession;
587
621
  if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
588
622
  else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
623
+ if (prevOutage === undefined) delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
624
+ else process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = prevOutage;
589
625
  }
590
626
  });
591
627
 
@@ -624,9 +660,11 @@ test("R6(e): success resets retry-nudge-pending state", async () => {
624
660
 
625
661
  test("R3: repeated identical transient error text upgrades to poisoned-context at threshold", async () => {
626
662
  // The stateful repeat signal: 3 consecutive identical transient errors
627
- // (default threshold) upgrade to poisoned. Uses "5xx server error" (5xx
628
- // marker transient) so the classifier returns transient, then the repeat
629
- // tracker upgrades it. auto=false to skip the compact attempt.
663
+ // (default threshold) upgrade to poisoned. Uses "5xx server error"
664
+ // deliberately NO known-retryable marker ("5xx" is not /5\d\d/, and
665
+ // "server error" alone matches nothing): the classifier returns transient
666
+ // via the generic 'error' fallthrough, then the repeat tracker upgrades it.
667
+ // auto=false to skip the compact attempt.
630
668
  const prevAuto = process.env.MEGACOMPACT_AUTO;
631
669
  const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
632
670
  process.env.MEGACOMPACT_AUTO = "false";
@@ -652,6 +690,729 @@ test("R3: repeated identical transient error text upgrades to poisoned-context a
652
690
  }
653
691
  });
654
692
 
693
+ test("R9: extractErrorSignature fallback — bare 0-token error -> 'bare-0-token-error'", () => {
694
+ // The repeat detector needs a non-empty signature to track bare 0-token errors.
695
+ assert.equal(extractErrorSignatureFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "bare-0-token-error");
696
+ // Without usage, stopReason alone does NOT get the fallback (mid-response deaths stay out of repeat tracking).
697
+ assert.equal(extractErrorSignatureFn({ stopReason: "error" }), "");
698
+ // Normal text content is returned as-is.
699
+ assert.equal(extractErrorSignatureFn({ stopReason: "error", content: "x" }), "x");
700
+ });
701
+
702
+
703
+ // ---- R7 regression tests: network/throughput errors must NEVER upgrade to ----
704
+ // ---- poisoned-context (2026-07-30 false-alarm incident) ----
705
+
706
+ /** R7 helper: fire the same transient error text `count` times with turn_start
707
+ * + elapsed backoff between turns so each turn's nudge can fire. */
708
+ async function r7RepeatTurns(h: ReturnType<typeof harness>, text: string, count: number) {
709
+ for (let i = 0; i < count; i++) {
710
+ await s38TurnEnd(h, "error", text);
711
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
712
+ await new Promise((r) => setTimeout(r, 3));
713
+ }
714
+ }
715
+
716
+ /** R7 helper: shared assertions for "stays transient" — no poisoned upgrade,
717
+ * no /clear advise, and the transient retry path actually ran. */
718
+ function assertStaysTransient(h: ReturnType<typeof harness>, label: string) {
719
+ assert.ok(
720
+ !eventTypes(h.stateDir).includes("poisoned_context"),
721
+ `${label}: no poisoned_context event (network errors never upgrade)`,
722
+ );
723
+ assert.ok(
724
+ !h.sendUserMessages.some((m) => m.includes("/clear")),
725
+ `${label}: no /clear advise message`,
726
+ );
727
+ assert.ok(
728
+ eventTypes(h.stateDir).includes("error_retry"),
729
+ `${label}: transient retry path ran (error_retry event)`,
730
+ );
731
+ }
732
+
733
+ test("R7(a): repeated 'timed out' phrasing (2026-07-30 incident text) stays transient — no poisoned upgrade", async () => {
734
+ // The incident error text was "Request timed out or failed. Try again" —
735
+ // "timed out" (two words) does NOT match the old guard's /timeout/, so the
736
+ // 3rd repeat fired the /clear poisoned advise. Must stay transient.
737
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
738
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
739
+ process.env.MEGACOMPACT_AUTO = "false";
740
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
741
+ try {
742
+ const h = harness();
743
+ await r7RepeatTurns(h, "Request timed out or failed. Try again", 3);
744
+ assertStaysTransient(h, "timed out x3");
745
+ } finally {
746
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
747
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
748
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
749
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
750
+ }
751
+ });
752
+
753
+ test("R7(b): repeated ETIMEDOUT errno stays transient", async () => {
754
+ // Node's timeout errno lowercases to "etimedout" — does NOT contain the
755
+ // substring "timeout". Slipped through the old guard.
756
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
757
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
758
+ process.env.MEGACOMPACT_AUTO = "false";
759
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
760
+ try {
761
+ const h = harness();
762
+ await r7RepeatTurns(h, "connect ETIMEDOUT 142.250.80.46:443", 3);
763
+ assertStaysTransient(h, "ETIMEDOUT x3");
764
+ } finally {
765
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
766
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
767
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
768
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
769
+ }
770
+ });
771
+
772
+ test("R7(c): repeated 'socket hang up' stays transient", async () => {
773
+ // Node surfaces ECONNRESET as the message "socket hang up" (the errno lives
774
+ // in error.code, which extractErrorSignature never sees).
775
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
776
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
777
+ process.env.MEGACOMPACT_AUTO = "false";
778
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
779
+ try {
780
+ const h = harness();
781
+ await r7RepeatTurns(h, "socket hang up", 3);
782
+ assertStaysTransient(h, "socket hang up x3");
783
+ } finally {
784
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
785
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
786
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
787
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
788
+ }
789
+ });
790
+
791
+ test("R7(d): repeated 429 rate-limit stays transient", async () => {
792
+ // /clear cannot fix a rate limit — the classifier explicitly keeps 429
793
+ // transient, so the repeat-upgrade must not override that verdict.
794
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
795
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
796
+ process.env.MEGACOMPACT_AUTO = "false";
797
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
798
+ try {
799
+ const h = harness();
800
+ await r7RepeatTurns(h, "429 Too Many Requests: rate limit exceeded", 3);
801
+ assertStaysTransient(h, "429 x3");
802
+ } finally {
803
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
804
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
805
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
806
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
807
+ }
808
+ });
809
+
810
+ test("R7(e): 0-token 'timed out' turn is NOT poisoned on first occurrence", async () => {
811
+ // Deeper gap: with usage PRESENT at 0 tokens, the classifier's 0-token
812
+ // poisoned signal fires on turn ONE for "timed out" phrasing (the stopReason
813
+ // 'error' is in the text blob and no transient marker matched). The network
814
+ // markers must be checked before that signal so this returns transient.
815
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
816
+ process.env.MEGACOMPACT_AUTO = "false";
817
+ try {
818
+ const h = harness();
819
+ await s38TurnEndUsage(h, "error", "Request timed out or failed. Try again", 0);
820
+ assert.ok(
821
+ !eventTypes(h.stateDir).includes("poisoned_context"),
822
+ "0-token timed out: no poisoned_context on first turn",
823
+ );
824
+ assert.ok(
825
+ !h.sendUserMessages.some((m) => m.includes("/clear")),
826
+ "0-token timed out: no /clear advise on first turn",
827
+ );
828
+ } finally {
829
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
830
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
831
+ }
832
+ });
833
+
834
+ test("R7(f): repeated non-network transient still upgrades to poisoned-context (control)", async () => {
835
+ // True-positive control: an error with NO known-retryable marker that
836
+ // repeats identically must still upgrade (the R3 feature itself).
837
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
838
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
839
+ process.env.MEGACOMPACT_AUTO = "false";
840
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
841
+ try {
842
+ const h = harness();
843
+ await r7RepeatTurns(h, "upstream rejected the request", 3);
844
+ assert.ok(
845
+ eventTypes(h.stateDir).includes("poisoned_context"),
846
+ "non-network repeat: poisoned_context event logged",
847
+ );
848
+ assert.ok(
849
+ h.sendUserMessages.some((m) => m.includes("/clear")),
850
+ "non-network repeat: /clear advise fired",
851
+ );
852
+ } finally {
853
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
854
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
855
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
856
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
857
+ }
858
+ });
859
+
860
+ test("R7(g): 'Request failed — please retry.' 0-token stays poisoned-context (2026-07-28 control)", async () => {
861
+ // The deterministic-rejection incident that motivated R3 must remain
862
+ // poisoned: the shared marker pattern must NOT match this text.
863
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
864
+ process.env.MEGACOMPACT_AUTO = "false";
865
+ try {
866
+ const h = harness();
867
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
868
+ assert.ok(
869
+ eventTypes(h.stateDir).includes("poisoned_context"),
870
+ "deterministic rejection: poisoned_context event logged",
871
+ );
872
+ assert.ok(h.sendUserMessages.some((m) => m.includes("/clear")), "deterministic rejection: /clear advise fired");
873
+ } finally {
874
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
875
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
876
+ }
877
+ });
878
+
879
+ test("R7 classifier: 0-token 'timed out' error → transient (not poisoned)", () => {
880
+ // Unit-level pin of the R7(e) gap.
881
+ assert.equal(
882
+ classifyErrorFn({
883
+ stopReason: "error",
884
+ content: "Request timed out or failed. Try again",
885
+ usage: { inputTokens: 0, outputTokens: 0 },
886
+ }),
887
+ "transient",
888
+ );
889
+ });
890
+
891
+ test("R7 classifier: bare network phrasings → transient", () => {
892
+ assert.equal(classifyErrorFn("socket hang up"), "transient");
893
+ assert.equal(classifyErrorFn("the operation timed out"), "transient");
894
+ assert.equal(classifyErrorFn("connect ETIMEDOUT 10.0.0.1:443"), "transient");
895
+ });
896
+
897
+ test("R7 classifier: 429/rate-limit → transient (control)", () => {
898
+ assert.equal(classifyErrorFn("429 Too Many Requests: rate limit exceeded"), "transient");
899
+ });
900
+
901
+ // ---- R8 regression tests: router-wrapped infra errors (2026-07-30 incident #2) ----
902
+ // pi's console "Error: 500:" prefix is NOT part of the delivered message body,
903
+ // so router phrasings ("All targets failed", "No healthy target selected",
904
+ // "Too many concurrent requests") matched NO marker and the 0-token signal
905
+ // poisoned the session on the FIRST turn.
906
+
907
+ /** The exact error bodies from the 2026-07-30 incident (GLM router flapping). */
908
+ const R8_NO_HEALTHY_TARGET =
909
+ '{"message":"No healthy target selected for alias \'hf:zai-org/GLM-4.7\'","type":"api_error"}';
910
+ const R8_SOCKET_CLOSED =
911
+ "All targets failed: modal/zai-org/GLM-5.1-FP8. Last error: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()"; // guardrails-allow PREVENT-PI-004: verbatim 2026-07-30 incident error text (string fixture, not a network call)
912
+ const R8_TOO_MANY_CONCURRENT =
913
+ 'All targets failed: modal/zai-org/GLM-5.1-FP8. Last error: {"error": "Too many concurrent requests for this model"}';
914
+
915
+ test("R8 classifier: router phrasings → transient, even at 0 tokens", () => {
916
+ for (const text of [R8_NO_HEALTHY_TARGET, R8_SOCKET_CLOSED, R8_TOO_MANY_CONCURRENT]) {
917
+ assert.equal(
918
+ classifyErrorFn({ stopReason: "error", content: text, usage: { inputTokens: 0, outputTokens: 0 } }),
919
+ "transient",
920
+ `0-token router error must be transient: ${text.slice(0, 60)}`,
921
+ );
922
+ }
923
+ });
924
+
925
+ test("R8 classifier: structured status field wins over phrasing", () => {
926
+ // 5xx → transient even with zero recognizable text.
927
+ assert.equal(
928
+ classifyErrorFn({ stopReason: "error", error: { status: 502, message: "???" }, usage: { inputTokens: 0, outputTokens: 0 } }),
929
+ "transient",
930
+ );
931
+ // 429 structured → transient.
932
+ assert.equal(
933
+ classifyErrorFn({ stopReason: "error", error: { statusCode: 429, message: "???" }, usage: { inputTokens: 0, outputTokens: 0 } }),
934
+ "transient",
935
+ );
936
+ // 401 → permanent (not transient, not poisoned).
937
+ assert.equal(
938
+ classifyErrorFn({ stopReason: "error", error: { status: 401, message: "???" } }),
939
+ "permanent",
940
+ );
941
+ // 400 with deterministic-rejection text → still poisoned (text rules 4xx).
942
+ assert.equal(
943
+ classifyErrorFn({ stopReason: "error", error: { status: 400, message: "orphaned tool result: tooluse ids mismatch" }, usage: { inputTokens: 0, outputTokens: 0 } }),
944
+ "poisoned-context",
945
+ );
946
+ });
947
+
948
+ test("R8(a): 0-token 'No healthy target' turn is NOT poisoned on first occurrence", async () => {
949
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
950
+ process.env.MEGACOMPACT_AUTO = "false";
951
+ try {
952
+ const h = harness();
953
+ await s38TurnEndUsage(h, "error", R8_NO_HEALTHY_TARGET, 0);
954
+ assert.ok(!eventTypes(h.stateDir).includes("poisoned_context"), "no poisoned_context on first turn");
955
+ assert.ok(!h.sendUserMessages.some((m) => m.includes("/clear")), "no /clear advise");
956
+ } finally {
957
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
958
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
959
+ }
960
+ });
961
+
962
+ test("R8(b): repeated 'No healthy target' x3 stays transient", async () => {
963
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
964
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
965
+ process.env.MEGACOMPACT_AUTO = "false";
966
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
967
+ try {
968
+ const h = harness();
969
+ await r7RepeatTurns(h, R8_NO_HEALTHY_TARGET, 3);
970
+ assertStaysTransient(h, "no-healthy-target x3");
971
+ } finally {
972
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
973
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
974
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
975
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
976
+ }
977
+ });
978
+
979
+ test("R8(c): repeated 'All targets failed / socket closed' x3 stays transient", async () => {
980
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
981
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
982
+ process.env.MEGACOMPACT_AUTO = "false";
983
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
984
+ try {
985
+ const h = harness();
986
+ await r7RepeatTurns(h, R8_SOCKET_CLOSED, 3);
987
+ assertStaysTransient(h, "all-targets-failed x3");
988
+ } finally {
989
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
990
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
991
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
992
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
993
+ }
994
+ });
995
+
996
+
997
+ test("R9 handler: bare 0-token error turn x1 is transient (no poisoned, no advise)", async () => {
998
+ const h = harness();
999
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1000
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1001
+ process.env.MEGACOMPACT_AUTO = "false";
1002
+ try {
1003
+ // Single bare 0-token error turn
1004
+ await s38TurnEndUsage(h, "error", undefined, 0);
1005
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
1006
+ await new Promise((r) => setTimeout(r, 3));
1007
+ // No poisoned_context event should be logged
1008
+ assert.ok(!eventTypes(h.stateDir).includes("poisoned_context"), "bare 0-token single turn must not log poisoned_context");
1009
+ // No /clear advise message
1010
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 0, "bare 0-token single turn must not /clear");
1011
+ // An error_retry event should be emitted
1012
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "bare 0-token single turn must trigger error_retry");
1013
+ } finally {
1014
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1015
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1016
+ delete process.env.MEGACOMPACT_AUTO;
1017
+ }
1018
+ });
1019
+
1020
+ test("R9 handler: bare 0-token error x3 upgrades to poisoned at threshold (corroborated)", async () => {
1021
+ const h = harness();
1022
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1023
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1024
+ process.env.MEGACOMPACT_AUTO = "false";
1025
+ try {
1026
+ // Three consecutive bare 0-token error turns
1027
+ for (let i = 0; i < 3; i++) {
1028
+ await s38TurnEndUsage(h, "error", undefined, 0);
1029
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1030
+ await new Promise((r) => setTimeout(r, 3));
1031
+ }
1032
+ // poisoned_context event should eventually be logged
1033
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "bare 0-token x3 must log poisoned_context");
1034
+ // Exactly one /clear advise total
1035
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 1, "bare 0-token x3 must produce exactly one /clear advise");
1036
+ } finally {
1037
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1038
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1039
+ delete process.env.MEGACOMPACT_AUTO;
1040
+ }
1041
+ });
1042
+
1043
+
1044
+ // ---- R10 tests: provider outage advisory (2026-07-30) ----
1045
+
1046
+ test("R10: 3 consecutive transient failures fire one provider-outage advisory (no /clear)", async () => {
1047
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1048
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1049
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1050
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1051
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
1052
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
1053
+ try {
1054
+ const h = harness();
1055
+ for (let i = 0; i < 3; i++) {
1056
+ await s38TurnEnd(h, "error", "socket hang up");
1057
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1058
+ await new Promise((r) => setTimeout(r, 3));
1059
+ }
1060
+ const providerMessages = h.sendUserMessages.filter((m) => m.includes("provider is having issues"));
1061
+ assert.equal(providerMessages.length, 1, "exactly one provider-outage advisory");
1062
+ const clearMessages = h.sendUserMessages.filter((m) => m.includes("/clear"));
1063
+ assert.equal(clearMessages.length, 0, "no /clear in outage advisory");
1064
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry events present");
1065
+ } finally {
1066
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1067
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1068
+ if (origSession === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1069
+ else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1070
+ if (origRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1071
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1072
+ }
1073
+ });
1074
+
1075
+ test("R10: advisory fires once per outage episode", async () => {
1076
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1077
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1078
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1079
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1080
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
1081
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
1082
+ try {
1083
+ const h = harness();
1084
+ // Episode 1: 5 consecutive transient failures → 1 advisory at consecutiveErrors=3.
1085
+ for (let i = 0; i < 5; i++) {
1086
+ await s38TurnEnd(h, "error", "socket hang up");
1087
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1088
+ await new Promise((r) => setTimeout(r, 3));
1089
+ }
1090
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("provider is having issues")).length, 1, "episode 1: 1 advisory after 5 transient turns");
1091
+
1092
+ // Success turn: resets consecutiveErrors + providerOutageAdvised.
1093
+ await s38TurnEnd(h, "stop");
1094
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 8 }, h.ctx());
1095
+ await new Promise((r) => setTimeout(r, 3));
1096
+
1097
+ // Episode 2: 3 more transient failures → second advisory.
1098
+ for (let i = 0; i < 3; i++) {
1099
+ await s38TurnEnd(h, "error", "socket hang up");
1100
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 9 + i }, h.ctx());
1101
+ await new Promise((r) => setTimeout(r, 3));
1102
+ }
1103
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("provider is having issues")).length, 2, "episode 2: exactly 2 advisories total");
1104
+ } finally {
1105
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1106
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1107
+ if (origSession === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1108
+ else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1109
+ if (origRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1110
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1111
+ }
1112
+ });
1113
+
1114
+ test("R10: MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD=0 disables the advisory", async () => {
1115
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1116
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1117
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1118
+ const origThreshold = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
1119
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1120
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
1121
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
1122
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
1123
+ try {
1124
+ const h = harness();
1125
+ for (let i = 0; i < 5; i++) {
1126
+ await s38TurnEnd(h, "error", "socket hang up");
1127
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1128
+ await new Promise((r) => setTimeout(r, 3));
1129
+ }
1130
+ const providerMessages = h.sendUserMessages.filter((m) => m.includes("provider is having issues"));
1131
+ assert.equal(providerMessages.length, 0, "threshold=0: zero advisory messages");
1132
+ } finally {
1133
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1134
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1135
+ if (origSession === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1136
+ else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1137
+ if (origRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1138
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1139
+ if (origThreshold === undefined) delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
1140
+ else process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = origThreshold;
1141
+ }
1142
+ });
1143
+
1144
+ test("R10: poisoned-context path does NOT fire the outage advisory", async () => {
1145
+ // Single "Request failed — please retry." 0-token turn: poisoned on turn 1
1146
+ // (auto=false to skip the compact attempt).
1147
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1148
+ const origAuto = process.env.MEGACOMPACT_AUTO;
1149
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1150
+ process.env.MEGACOMPACT_AUTO = "false";
1151
+ try {
1152
+ const h = harness();
1153
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
1154
+ // /clear advise should fire.
1155
+ assert.ok(h.sendUserMessages.some((m) => m.includes("/clear")), "poisoned: /clear advise fires");
1156
+ // No provider-outage advisory.
1157
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("provider is having issues")).length, 0, "poisoned path: no outage advisory");
1158
+ } finally {
1159
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1160
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1161
+ if (origAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
1162
+ else process.env.MEGACOMPACT_AUTO = origAuto;
1163
+ }
1164
+ });
1165
+
1166
+
1167
+ // ── R11: diagnostic signal + rawText logging ──────────────────────────────
1168
+
1169
+ test("R11 classifier: classifyErrorDetailed signal tags", () => {
1170
+ // length → signal 'length-guard', category null
1171
+ const len = classifyErrorDetailedFn({ stopReason: "length" });
1172
+ assert.equal(len.signal, "length-guard");
1173
+ assert.equal(len.category, null);
1174
+
1175
+ // aborted → 'cancelled'
1176
+ const abort = classifyErrorDetailedFn({ stopReason: "aborted" });
1177
+ assert.equal(abort.signal, "cancelled");
1178
+ assert.equal(abort.category, "cancelled");
1179
+
1180
+ // stop → 'success'
1181
+ assert.equal(classifyErrorDetailedFn({ stopReason: "stop" }).signal, "success");
1182
+
1183
+ // compaction-noop
1184
+ const compact = classifyErrorDetailedFn("Error: Already compacted");
1185
+ assert.equal(compact.signal, "compaction-noop");
1186
+ assert.equal(compact.category, "compaction-noop");
1187
+
1188
+ // context-overflow
1189
+ const overflow = classifyErrorDetailedFn("too long for this model");
1190
+ assert.equal(overflow.signal, "context-overflow");
1191
+ assert.equal(overflow.category, "context-overflow");
1192
+
1193
+ // transient-marker (text-matched)
1194
+ const tm = classifyErrorDetailedFn("socket hang up");
1195
+ assert.equal(tm.signal, "transient-marker");
1196
+ assert.equal(tm.category, "transient");
1197
+
1198
+ // transient-status (HTTP status-matched) — use outer-level status + non-5xx-matching error text
1199
+ // so extractHttpStatus finds it but the text doesn't contain "5xx" digits
1200
+ const ts = classifyErrorDetailedFn({ stopReason: "error", error: { type: "upstream_error", message: "request aborted" }, status: 502 });
1201
+ assert.equal(ts.signal, "transient-status");
1202
+ assert.equal(ts.category, "transient");
1203
+ assert.equal(ts.httpStatus, 502);
1204
+
1205
+ // permanent-status
1206
+ const ps = classifyErrorDetailedFn({ stopReason: "error", error: { type: "auth_error", message: "session expired" }, status: 401 });
1207
+ assert.equal(ps.signal, "permanent-status");
1208
+ assert.equal(ps.category, "permanent");
1209
+ assert.equal(ps.httpStatus, 401);
1210
+
1211
+ // poisoned-invalid-request (needs stopReason so sr is non-empty)
1212
+ const pir = classifyErrorDetailedFn({ stopReason: "error", error: { type: "invalid_request_error", message: "bad request" } });
1213
+ assert.equal(pir.signal, "poisoned-invalid-request");
1214
+ assert.equal(pir.category, "poisoned-context");
1215
+
1216
+ // poisoned-request-failed (from R9/R3)
1217
+ const prf = classifyErrorDetailedFn("Request failed — please retry.");
1218
+ assert.equal(prf.signal, "poisoned-request-failed");
1219
+ assert.equal(prf.category, "poisoned-context");
1220
+
1221
+ // bare-0-token (R9) — usage present with 0 tokens, stopReason error
1222
+ const b0 = classifyErrorDetailedFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } });
1223
+ assert.equal(b0.signal, "bare-0-token");
1224
+ assert.equal(b0.category, "transient");
1225
+
1226
+ // generic-error
1227
+ const ge = classifyErrorDetailedFn({ stopReason: "error", content: "something odd" });
1228
+ assert.equal(ge.signal, "generic-error");
1229
+ assert.equal(ge.category, "transient");
1230
+
1231
+ // permanent-auth
1232
+ const pa = classifyErrorDetailedFn("unauthorized: invalid api key");
1233
+ assert.equal(pa.signal, "permanent-auth");
1234
+ assert.equal(pa.category, "permanent");
1235
+
1236
+ // unknown (plain string, no markers)
1237
+ const unk = classifyErrorDetailedFn("some random text with no pattern");
1238
+ assert.equal(unk.signal, "unknown");
1239
+ assert.equal(unk.category, null);
1240
+ });
1241
+
1242
+ test("R11: poisoned_context event carries signal + rawText", async () => {
1243
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
1244
+ process.env.MEGACOMPACT_AUTO = "false";
1245
+ try {
1246
+ const h = harness();
1247
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
1248
+ const payloads = eventPayloads(h.stateDir, "poisoned_context");
1249
+ assert.ok(payloads.length >= 1, "expected a poisoned_context event");
1250
+ const last = payloads[payloads.length - 1];
1251
+ assert.equal(last.signal, "poisoned-request-failed", "signal field present and correct");
1252
+ assert.ok(
1253
+ typeof last.rawText === "string" && (last.rawText as string).toLowerCase().includes("request failed"),
1254
+ "rawText contains error text",
1255
+ );
1256
+ } finally {
1257
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
1258
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
1259
+ }
1260
+ });
1261
+
1262
+ test("R11: provider outage advisory payload carries signal + rawText", async () => {
1263
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1264
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1265
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1266
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1267
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
1268
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
1269
+ try {
1270
+ const h = harness();
1271
+ for (let i = 0; i < 3; i++) {
1272
+ await s38TurnEnd(h, "error", "socket hang up");
1273
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1274
+ await new Promise((r) => setTimeout(r, 3));
1275
+ }
1276
+ const payloads = eventPayloads(h.stateDir, "provider_outage_advised");
1277
+ assert.ok(payloads.length >= 1, "expected provider_outage_advised event");
1278
+ const last = payloads[payloads.length - 1];
1279
+ assert.equal(last.signal, "transient-marker", "outage event carries signal");
1280
+ assert.ok(
1281
+ typeof last.rawText === "string" && (last.rawText as string).includes("socket hang up"),
1282
+ "outage event carries rawText",
1283
+ );
1284
+ } finally {
1285
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1286
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1287
+ if (origSession === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1288
+ else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1289
+ if (origRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1290
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1291
+ }
1292
+ });
1293
+
1294
+ test("R11: repeat-upgrade-declined signal is transient-marker for retryable marker", () => {
1295
+ // The repeat-upgrade-declined log fires when isKnownRetryable(signature) is true
1296
+ // but the count hasn't reached the threshold yet (decline path). Since the
1297
+ // test harness doesn't expose internal logger output, we unit-test the signal
1298
+ // tag that the handler would log.
1299
+ const detail = classifyErrorDetailedFn("Request timed out or failed. Try again");
1300
+ assert.equal(detail.signal, "transient-marker", "repeat-guard text gets transient-marker signal");
1301
+ assert.equal(detail.category, "transient");
1302
+ });
1303
+
1304
+ // ── R12: normalize volatile tokens in extractErrorSignature ──────────────
1305
+
1306
+ test("R12: extractErrorSignature normalizes volatile tokens", () => {
1307
+ // Model/provider paths → <model>
1308
+ const sigA = extractErrorSignatureFn("All targets failed: modal/zai-org/GLM-5.1-FP8. Last error: boom");
1309
+ const sigB = extractErrorSignatureFn("All targets failed: hf/other-org/GLM-4.7. Last error: boom");
1310
+ assert.equal(sigA, sigB, "different model paths must normalize to the same signature");
1311
+ assert.ok(sigA.includes("<model>"), "normalized sig should contain <model>");
1312
+ assert.ok(!sigA.includes("modal"), "original model alias must be replaced");
1313
+ assert.ok(!sigA.includes("hf/other-org"), "original model alias must be replaced");
1314
+
1315
+ // IP:port → <ip>
1316
+ const ipA = extractErrorSignatureFn("connect ETIMEDOUT 10.0.0.1:443");
1317
+ const ipB = extractErrorSignatureFn("connect ETIMEDOUT 192.168.0.2:8443");
1318
+ assert.equal(ipA, ipB, "different IPs must normalize to the same signature");
1319
+ assert.ok(ipA.includes("<ip>"), "normalized sig should contain <ip>");
1320
+ assert.ok(!ipA.includes("10.0.0.1"), "original IP must be replaced");
1321
+
1322
+ // Hex ids (8+ chars) → <hex>
1323
+ const hexA = extractErrorSignatureFn("request id a1b2c3d4e5f6 failed");
1324
+ const hexB = extractErrorSignatureFn("request id 9f8e7d6c5b4a failed");
1325
+ assert.equal(hexA, hexB, "different hex ids must normalize to the same signature");
1326
+ assert.ok(hexA.includes("<hex>"), "normalized sig should contain <hex>");
1327
+
1328
+ // "after N attempts" → "after <n> attempts"
1329
+ const rA = extractErrorSignatureFn("Retry failed after 3 attempts: boom");
1330
+ const rB = extractErrorSignatureFn("Retry failed after 5 attempts: boom");
1331
+ assert.equal(rA, rB, "different attempt counts must normalize to the same signature");
1332
+ assert.ok(rA.includes("after <n> attempts"), "normalized sig should contain 'after <n> attempts'");
1333
+
1334
+ // Status codes (3-digit) survive — NOT merged by the 4+ digit rule
1335
+ const s500 = extractErrorSignatureFn("error 500 now");
1336
+ const s502 = extractErrorSignatureFn("error 502 now");
1337
+ assert.notEqual(s500, s502, "3-digit status codes must NOT merge");
1338
+
1339
+ // Empty + bare 0-token error object still returns "bare-0-token-error" (R9 preserved)
1340
+ assert.equal(
1341
+ extractErrorSignatureFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }),
1342
+ "bare-0-token-error",
1343
+ "R9 bare-0-token fallback must survive normalization change",
1344
+ );
1345
+ // Empty content with no usage keeps returning ""
1346
+ assert.equal(extractErrorSignatureFn({ stopReason: "error" }), "", "bare error with no usage must return empty");
1347
+ });
1348
+
1349
+ test("R12: alternating-but-equivalent marker-less errors upgrade at threshold", async () => {
1350
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1351
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1352
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1353
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "3";
1354
+ process.env.MEGACOMPACT_AUTO = "false";
1355
+ try {
1356
+ const h = harness();
1357
+ // Two texts that differ ONLY in model alias — no known-retryable markers.
1358
+ // "upstream rejected alias modal/a-b/X-1" has no network/timeout/socket/
1359
+ // 429/5xx markers in KNOWN_RETRYABLE_TRANSIENT_PATTERN.
1360
+ const textA = "upstream rejected alias modal/a-b/X-1";
1361
+ const textB = "upstream rejected alias hf/c-d/Y-2";
1362
+ // Turn 1 (textA): count → 1
1363
+ await s38TurnEnd(h, "error", textA);
1364
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
1365
+ await new Promise((r) => setTimeout(r, 3));
1366
+ // Turn 2 (textB): normalized matches → count → 2
1367
+ await s38TurnEnd(h, "error", textB);
1368
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 3 }, h.ctx());
1369
+ await new Promise((r) => setTimeout(r, 3));
1370
+ // Turn 3 (textA again): normalized matches → count → 3 → upgrade
1371
+ await s38TurnEnd(h, "error", textA);
1372
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 4 }, h.ctx());
1373
+ await new Promise((r) => setTimeout(r, 3));
1374
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "equivalent alternating errors must upgrade to poisoned at threshold");
1375
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 1, "exactly one /clear advise");
1376
+ } finally {
1377
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1378
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1379
+ if (origRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1380
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1381
+ delete process.env.MEGACOMPACT_AUTO;
1382
+ }
1383
+ });
1384
+
1385
+ test("R12: genuinely different marker-less errors do NOT merge", async () => {
1386
+ // Characterization/control test: two truly distinct error messages must NOT
1387
+ // merge under normalization — their signatures stay different and the repeat
1388
+ // counter never reaches threshold. This should pass both before and after
1389
+ // the R12 normalization change (guard against over-normalization).
1390
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1391
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1392
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1393
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "3";
1394
+ process.env.MEGACOMPACT_AUTO = "false";
1395
+ try {
1396
+ const h = harness();
1397
+ const textA = "upstream rejected the request";
1398
+ const textB = "provider returned an empty response";
1399
+ // Alternate a,b,a,b — counter never exceeds 1.
1400
+ for (let i = 0; i < 4; i++) {
1401
+ await s38TurnEnd(h, "error", i % 2 === 0 ? textA : textB);
1402
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1403
+ await new Promise((r) => setTimeout(r, 3));
1404
+ }
1405
+ assert.ok(!eventTypes(h.stateDir).includes("poisoned_context"), "truly different errors must NOT trigger poisoned_context");
1406
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 0, "no /clear for non-repeating errors");
1407
+ } finally {
1408
+ if (origBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1409
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1410
+ if (origRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1411
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1412
+ delete process.env.MEGACOMPACT_AUTO;
1413
+ }
1414
+ });
1415
+
655
1416
  test("cleanup", async () => {
656
1417
  // PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
657
1418
  try {
@@ -661,8 +1422,12 @@ test("cleanup", async () => {
661
1422
  // Force-exit: each harness() creates a MegaRuntime with an fs.watch
662
1423
  // game-state watcher that is never disposed (no session_shutdown in tests).
663
1424
  // Those handles keep the event loop alive indefinitely after all tests
664
- // complete, so `node --test` (without --test-force-exit) would hang. The
665
- // streaming reporter has already printed every test result by this point;
666
- // process.exit(0) just forces the exit the watchers are preventing.
1425
+ // complete, so `node --test` (without --test-force-exit) would hang.
1426
+ // Drain stdout/stderr, then defer the exit: a bare process.exit() discards
1427
+ // unflushed pipe buffers observed 2026-07-30: when piped, the trailing
1428
+ // tests' results and the run summary silently vanished from the report.
1429
+ await new Promise((r) => process.stdout.write("", r));
1430
+ await new Promise((r) => process.stderr.write("", r));
1431
+ await new Promise((r) => setTimeout(r, 1500));
667
1432
  process.exit(0);
668
1433
  });