pi-mega-compact 0.8.14 → 0.8.16

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 (89) hide show
  1. package/README.md +51 -463
  2. package/dist/extensions/dashboard-client/src/hooks/useApi.js +51 -0
  3. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +63 -0
  4. package/dist/extensions/dashboard-server/html.js +15 -2
  5. package/dist/extensions/mega-cache-replay.test.js +4 -2
  6. package/dist/extensions/mega-compact-s38.test.js +317 -0
  7. package/dist/extensions/mega-compact.js +14 -0
  8. package/dist/extensions/mega-compact.test.js +37 -8
  9. package/dist/extensions/mega-config.js +5 -0
  10. package/dist/extensions/mega-events/agent-handlers.js +184 -4
  11. package/dist/extensions/mega-events/context-handler.js +35 -2
  12. package/dist/extensions/mega-events/error-classifier.js +118 -0
  13. package/dist/extensions/mega-events.js +1 -0
  14. package/dist/extensions/mega-runtime/state.js +82 -0
  15. package/dist/extensions/mega-runtime/state.test.js +37 -0
  16. package/dist/extensions/mega-runtime/widget.js +6 -2
  17. package/dist/extensions/mega-runtime/widget.test.js +47 -0
  18. package/dist/extensions/mega-teamrun.test.js +14 -1
  19. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js → AchievementTiles-BozP_h3c.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js.map → AchievementTiles-BozP_h3c.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js → AchievementsTab-DKgqx1RQ.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js.map → AchievementsTab-DKgqx1RQ.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js → CacheTab-CPvso4-0.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js.map → CacheTab-CPvso4-0.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js +2 -0
  26. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +1 -0
  27. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js → EventsTab-BGeuYvM3.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js.map → EventsTab-BGeuYvM3.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js → GameTab-Qj19W1Y-.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js.map → GameTab-Qj19W1Y-.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js → MetricsTab-DA27vA95.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js.map → MetricsTab-DA27vA95.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js → OverviewTab-lVGR1Jl_.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js.map → OverviewTab-lVGR1Jl_.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js → ReposTab-_HNR3qYj.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js.map → ReposTab-_HNR3qYj.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js → TimeSavedCard-B7aPcBMs.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js.map → TimeSavedCard-B7aPcBMs.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js → index-B2JJExE9.js} +3 -3
  40. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js.map → index-B2JJExE9.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/index.html +1 -1
  42. package/extensions/dashboard-client/src/styles/base.css +2 -1
  43. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +19 -2
  44. package/extensions/dashboard-server/html.ts +15 -2
  45. package/extensions/mega-cache-replay.test.ts +3 -3
  46. package/extensions/mega-compact-s38.test.ts +330 -0
  47. package/extensions/mega-compact.test.ts +41 -13
  48. package/extensions/mega-compact.ts +14 -0
  49. package/extensions/mega-config.ts +24 -0
  50. package/extensions/mega-dashboard.ts +7 -0
  51. package/extensions/mega-events/agent-handlers.ts +179 -5
  52. package/extensions/mega-events/context-handler.ts +31 -2
  53. package/extensions/mega-events/error-classifier.ts +109 -0
  54. package/extensions/mega-events.ts +1 -0
  55. package/extensions/mega-runtime/helpers.ts +5 -0
  56. package/extensions/mega-runtime/state.test.ts +33 -0
  57. package/extensions/mega-runtime/state.ts +81 -1
  58. package/extensions/mega-runtime/widget.test.ts +48 -0
  59. package/extensions/mega-runtime/widget.ts +6 -2
  60. package/extensions/mega-teamrun.test.ts +13 -1
  61. package/package.json +4 -1
  62. package/dist/extensions/dashboard-server/helpers.js +0 -37
  63. package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
  64. package/dist/extensions/dashboard-server/html/body-open.js +0 -23
  65. package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
  66. package/dist/extensions/dashboard-server/html/head-open.js +0 -16
  67. package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
  68. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
  69. package/dist/extensions/dashboard-server/html/script.js +0 -259
  70. package/dist/extensions/dashboard-server/html/styles.js +0 -103
  71. package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
  72. package/dist/extensions/dashboard-server/html-template.js +0 -41
  73. package/dist/src/store/sqlite/connection.js +0 -35
  74. package/dist/src/store/sqlite/index-store.js +0 -167
  75. package/dist/src/store/sqlite/memory.js +0 -54
  76. package/dist/src/store/sqlite/minhash-lsh.js +0 -47
  77. package/dist/src/store/sqlite/sessions.js +0 -39
  78. package/dist/src/store/sqlite/transaction.js +0 -19
  79. package/dist/src/vectorStore/add.js +0 -260
  80. package/dist/src/vectorStore/dedup.js +0 -52
  81. package/dist/src/vectorStore/index.js +0 -10
  82. package/dist/src/vectorStore/queries.js +0 -83
  83. package/dist/src/vectorStore/search.js +0 -95
  84. package/dist/src/vectorStore/session.js +0 -19
  85. package/dist/src/vectorStore/store.js +0 -105
  86. package/dist/src/vectorStore/types.js +0 -6
  87. package/dist/src/vectorStore/utils.js +0 -23
  88. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js +0 -2
  89. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js.map +0 -1
@@ -18,6 +18,7 @@ import { recordScore } from "../../src/store/sqlite.js";
18
18
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
19
19
  import { isMegaCache } from "../../src/game/scoring.js";
20
20
  import { resolveRepoRoot } from "../mega-config.js";
21
+ import { classifyError } from "./error-classifier.js";
21
22
 
22
23
  /** Register agent/turn tracking event handlers. */
23
24
  export function registerAgentHandlers(
@@ -65,11 +66,32 @@ export function registerAgentHandlers(
65
66
  const idle = ctx.isIdle?.() ?? true;
66
67
  const queued = ctx.hasPendingMessages?.() ?? false;
67
68
  const now = Date.now();
69
+ // S38.5: read LIVE pressure from ctx.getContextUsage() instead of the
70
+ // stale runtime.lastCtxTokens (only updated by the `context` event).
71
+ // agent_end may fire without a preceding context event this turn (e.g.
72
+ // a sub-agent settling), so the cached value can be null/stale and the
73
+ // durable-trim branch would be unreachable. ctx.getContextUsage() is the
74
+ // authoritative live reading (mirrors context-handler.ts:86). Fall back
75
+ // to the cached value only if the ctx omits it.
76
+ const liveUsage = ctx.getContextUsage?.();
77
+ const liveTokens =
78
+ typeof liveUsage?.tokens === "number"
79
+ ? liveUsage.tokens
80
+ : runtime.lastCtxTokens ?? 0;
81
+ // Keep the cache fresh for snapshot()/diag regardless of which source we use.
82
+ if (typeof liveUsage?.tokens === "number") {
83
+ runtime.lastCtxTokens = liveUsage.tokens;
84
+ }
85
+ if (typeof liveUsage?.percent === "number") {
86
+ runtime.lastCtxPercent = liveUsage.percent;
87
+ }
88
+ if (typeof liveUsage?.contextWindow === "number") {
89
+ runtime.lastCtxWindow = liveUsage.contextWindow;
90
+ }
68
91
  // DIAG (team-run relief): surface whether the agent is idle + over
69
92
  // threshold at agent_end so we can see if a mid-run durable-trim trigger
70
93
  // *should* have fired but didn't.
71
- const overThreshold =
72
- (runtime.lastCtxTokens ?? 0) >= runtime.effectiveThreshold;
94
+ const overThreshold = liveTokens >= runtime.effectiveThreshold;
73
95
  runtime.diagAgentEndIdle++;
74
96
  runtime.logger.info("agent-end-idle", {
75
97
  sessionId: runtime.rt.sessionId,
@@ -117,11 +139,20 @@ export function registerAgentHandlers(
117
139
  // synchronous `piCompactWouldNoop` branch check misses a native
118
140
  // compaction that hasn't appended its entry yet — calling
119
141
  // ctx.compact() then races with pi and throws "Already compacted"
120
- // to the user. The `lastCompactAt` cooldown (updated by the
142
+ // to the user. The `lastNativeCompactAt` cooldown (updated by the
121
143
  // session_compact listener for EVERY compaction, native or
122
144
  // extension-supplied) closes that race window.
145
+ //
146
+ // S38.5: strict race guard widens the cooldown 10s -> 30s AND defers
147
+ // ctx.compact() via setTimeout(500) with a re-check, so pi's
148
+ // about-to-run native _checkCompaction can append its `compaction`
149
+ // branch entry first (closes the first-race-in-burst window). Gated
150
+ // by MEGACOMPACT_RACE_GUARD_STRICT (default true); false reverts to
151
+ // the v0.7.4 synchronous 10s guard. Mirrors the legacy path in
152
+ // context-handler.ts:258-287 so both call sites stay in sync.
153
+ const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
123
154
  const sinceCompact = now - (runtime.rt.lastNativeCompactAt ?? 0);
124
- if (sinceCompact < 10_000) {
155
+ if (sinceCompact < cooldownMs) {
125
156
  runtime.diagAgentEndDurableSkipRecent++;
126
157
  } else if (!piCompactWouldNoop(ctx)) {
127
158
  runtime.debounceUntil = now + 2000;
@@ -132,7 +163,31 @@ export function registerAgentHandlers(
132
163
  thresholdTokens: config.thresholdTokens,
133
164
  queued,
134
165
  });
135
- ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort. Race-guarded by lastCompactAt cooldown above (ctx.compact returns void → throw is surfaced by pi as compaction_end; the cooldown prevents the call entirely).
166
+ if (config.raceGuardStrict) {
167
+ // Strict: defer ctx.compact() with a re-check so pi's
168
+ // about-to-run native _checkCompaction can append its
169
+ // `compaction` branch entry first. setTimeout(500) — pi's
170
+ // compaction-summary append is async I/O, so queueMicrotask
171
+ // would re-check before it lands.
172
+ const stamp = runtime.rt.lastNativeCompactAt;
173
+ const liveSid = runtime.rt.sessionId;
174
+ setTimeout(() => {
175
+ try {
176
+ if (runtime.rt.sessionId !== liveSid) return; // session reset
177
+ const since2 =
178
+ now - (runtime.rt.lastNativeCompactAt ?? 0);
179
+ if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs) return;
180
+ if (piCompactWouldNoop(ctx)) return;
181
+ ctx.compact({
182
+ customInstructions: undefined,
183
+ }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated.
184
+ } catch {
185
+ /* non-fatal */
186
+ }
187
+ }, 500);
188
+ } else {
189
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort. Race-guarded by lastNativeCompactAt cooldown above (ctx.compact returns void → throw is surfaced by pi as compaction_end; the cooldown prevents the call entirely).
190
+ }
136
191
  didDurableTrim = true;
137
192
  }
138
193
  }
@@ -172,6 +227,7 @@ export function registerAgentHandlers(
172
227
  pi.on("turn_start", async (event, ctx) => {
173
228
  runtime.currentTurn = event.turnIndex;
174
229
  runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
230
+ runtime.rt.errorRetryCount = 0; // S38: reset error-retry counter each user turn
175
231
  runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
176
232
  runtime.snapshot(ctx);
177
233
  });
@@ -258,5 +314,123 @@ export function registerAgentHandlers(
258
314
  runtime.rt.lengthStopPending = true;
259
315
  runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
260
316
  }
317
+
318
+ // S38: broader error-retry safety net. S28 only catches stopReason==='length';
319
+ // this catches ALL other error types (provider failure, network timeout, 5xx,
320
+ // 429, auth, compaction-noop) that surface at turn_end. Non-fatal: wrapped in
321
+ // try/catch so a classifier/retry failure never breaks the agent loop.
322
+ // PREVENT-PI-003: retry nudge fires via pi.sendUserMessage (user-role).
323
+ try {
324
+ // (1) S28 owns length — skip the classifier entirely for it.
325
+ const sr = (event.message as { stopReason?: string } | undefined)?.stopReason;
326
+ if (sr === 'length') {
327
+ // S28 handles; nothing for S38 to do here.
328
+ } else {
329
+ const category = classifyError(event.message);
330
+ if (category === null) {
331
+ // (3) success / normal flow / unknown-but-non-retryable — reset.
332
+ runtime.rt.errorRetryCount = 0;
333
+ runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
334
+ } else if (category === 'compaction-noop') {
335
+ // (4) pi race / manual compact catch — NOT retryable. The compaction
336
+ // already succeeded via pi's native path; retrying would race again
337
+ // (FAIL-2026071701). Log a diagnostic, reset the counter, and surface
338
+ // the original error WITHOUT firing a retry nudge.
339
+ runtime.rt.errorRetryCount = 0;
340
+ runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset
341
+ runtime.dashboard.event('compaction_noop_diagnostic', {
342
+ turnIndex: event.turnIndex,
343
+ sessionId: runtime.rt.sessionId,
344
+ });
345
+ runtime.logger.info('compaction-noop-diagnostic', {
346
+ sessionId: runtime.rt.sessionId,
347
+ turnIndex: event.turnIndex,
348
+ });
349
+ } else {
350
+ // (5) transient or permanent — retry with exponential backoff.
351
+ // S38.7: hard-stop switch — bypass ALL retry logic when set.
352
+ if (config.errorRetryHardStop) {
353
+ runtime.rt.errorRetryCount = 0;
354
+ runtime.dashboard.event('error_retry_disabled', {
355
+ category,
356
+ turnIndex: event.turnIndex,
357
+ reason: 'hard-stop',
358
+ });
359
+ return; // early exit — no retry
360
+ }
361
+ // S38.6: circuit-breaker — stop retrying after too many consecutive errors.
362
+ runtime.rt.consecutiveErrors++;
363
+ if (runtime.rt.consecutiveErrors > config.maxConsecutiveErrors) {
364
+ runtime.dashboard.event('error_retry_circuit_open', {
365
+ consecutive: runtime.rt.consecutiveErrors,
366
+ max: config.maxConsecutiveErrors,
367
+ turnIndex: event.turnIndex,
368
+ });
369
+ runtime.logger.warn('error-retry-circuit-open', {
370
+ sessionId: runtime.rt.sessionId,
371
+ consecutive: runtime.rt.consecutiveErrors,
372
+ max: config.maxConsecutiveErrors,
373
+ });
374
+ return; // early exit — circuit breaker tripped
375
+ }
376
+ const max =
377
+ category === 'transient'
378
+ ? config.autoRetryTransientMax
379
+ : config.autoRetryPermanentMax;
380
+ // max === 0 disables the category entirely (revert to S28-only).
381
+ if (max <= 0) {
382
+ runtime.rt.errorRetryCount = 0;
383
+ } else {
384
+ runtime.rt.errorRetryCount++;
385
+ if (runtime.rt.errorRetryCount > max) {
386
+ // Exhausted — surface the error, reset for the next burst.
387
+ runtime.dashboard.event('error_retry_exhausted', {
388
+ category,
389
+ count: runtime.rt.errorRetryCount,
390
+ max,
391
+ turnIndex: event.turnIndex,
392
+ });
393
+ runtime.logger.info('error-retry-exhausted', {
394
+ sessionId: runtime.rt.sessionId,
395
+ category,
396
+ count: runtime.rt.errorRetryCount,
397
+ max,
398
+ });
399
+ runtime.rt.errorRetryCount = 0;
400
+ } else {
401
+ // S38: fire the retry nudge. Each error turn fires its own
402
+ // nudge up to `max` — we intentionally do NOT debounce on the
403
+ // backoff window (errorRetryUntil) here, because that would
404
+ // suppress turns 2..max on a fast-erroring provider (the exact
405
+ // scenario this feature targets) and the user could see zero
406
+ // retries followed by error_retry_exhausted. The per-turn cap
407
+ // (errorRetryCount <= max) plus the session circuit breaker
408
+ // (consecutiveErrors > maxConsecutiveErrors) already bound the
409
+ // loop, so a tight turn_end storm cannot busy-loop unbounded.
410
+ // errorRetryUntil + errorRetryBackoffMs() are retained on the
411
+ // runtime for future/optional pacing but are not gating.
412
+ runtime.dashboard.event('error_retry', {
413
+ category,
414
+ count: runtime.rt.errorRetryCount,
415
+ max,
416
+ turnIndex: event.turnIndex,
417
+ });
418
+ runtime.logger.info('error-retry', {
419
+ sessionId: runtime.rt.sessionId,
420
+ category,
421
+ count: runtime.rt.errorRetryCount,
422
+ max,
423
+ });
424
+ // PREVENT-PI-003: user-role sendUserMessage only.
425
+ pi.sendUserMessage(
426
+ '[mega-compact] the last turn ended with an error; please retry.',
427
+ );
428
+ }
429
+ }
430
+ }
431
+ }
432
+ } catch {
433
+ /* non-fatal: a classifier/retry failure never breaks the agent loop */
434
+ }
261
435
  });
262
436
  }
@@ -253,9 +253,38 @@ export function registerContextHandler(
253
253
  // NATIVE compaction just fired (avoids racing pi and surfacing a spurious
254
254
  // "Already compacted" / "Nothing to compact" toast). Uses lastNativeCompactAt
255
255
  // (NOT lastCompactAt, which runCompact also stamps for our own checkpoint).
256
+ // S38.5: strict race guard widens the cooldown 10s -> 30s (gated by
257
+ // MEGACOMPACT_RACE_GUARD_STRICT; false reverts to v0.7.4 10s).
258
+ const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
256
259
  const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
257
- if (sinceCompact < 10_000 || piCompactWouldNoop(ctx)) return;
258
- ctx.compact({ customInstructions: undefined }); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void not catchable; the cooldown prevents the call)
260
+ if (sinceCompact < cooldownMs || piCompactWouldNoop(ctx)) return;
261
+ // S38.5: defer ctx.compact() with a re-check so pi's about-to-run native
262
+ // _checkCompaction can append its `compaction` branch entry first (closes
263
+ // the first-race-in-burst window). setTimeout(500) — pi's compaction-summary
264
+ // append is async I/O, so queueMicrotask would re-check before it lands.
265
+ // Non-strict (v0.7.4) keeps the synchronous call.
266
+ if (config.raceGuardStrict) {
267
+ const stamp = runtime.rt.lastNativeCompactAt;
268
+ const liveSid = runtime.rt.sessionId;
269
+ setTimeout(() => {
270
+ try {
271
+ if (runtime.rt.sessionId !== liveSid) return; // session reset
272
+ const since2 =
273
+ Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
274
+ if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs) return;
275
+ if (piCompactWouldNoop(ctx)) return;
276
+ ctx.compact({
277
+ customInstructions: undefined,
278
+ }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated.
279
+ } catch {
280
+ /* non-fatal */
281
+ }
282
+ }, 500);
283
+ } else {
284
+ ctx.compact({
285
+ customInstructions: undefined,
286
+ }); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void → not catchable; the cooldown prevents the call)
287
+ }
259
288
  return;
260
289
  }
261
290
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * error-classifier.ts — S38.2 error classification for retry logic.
3
+ *
4
+ * Classifies turn-end error/stop signals into retry categories.
5
+ * Ordering matters: compaction-noop is matched BEFORE generic transient
6
+ * so a pi race / manual compact catch is never misclassified.
7
+ */
8
+
9
+ /** S38.2: classify a turn-end error/stop signal into a retry category.
10
+ *
11
+ * `length` is returned as null — S28 owns the max-output-token length stopReason
12
+ * exclusively (its agent_end nudge path is separate and must not be doubled).
13
+ *
14
+ * @param message the event.message (a pi AgentMessage) or an error string
15
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | null (success/unknown)
16
+ */
17
+ export function classifyError(message: unknown):
18
+ | 'transient'
19
+ | 'permanent'
20
+ | 'compaction-noop'
21
+ | null {
22
+ // Resolve a searchable text blob from a pi AgentMessage or raw string.
23
+ let text = '';
24
+ if (typeof message === 'string') {
25
+ text = message;
26
+ } else if (message && typeof message === 'object') {
27
+ const m = message as {
28
+ stopReason?: string;
29
+ content?: unknown;
30
+ error?: unknown;
31
+ };
32
+ const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
33
+ // S28 guard: length stopReason is handled exclusively by the S28 path.
34
+ if (sr === 'length') return null;
35
+ // Success / normal tool flow — not an error, nothing to retry.
36
+ if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return null;
37
+ const parts: string[] = [];
38
+ if (sr) parts.push(sr);
39
+ const c = m.content;
40
+ if (typeof c === 'string') parts.push(c);
41
+ else if (Array.isArray(c)) {
42
+ for (const b of c) {
43
+ if (b && typeof b === 'object' && 'text' in b) {
44
+ parts.push(String((b as { text?: string }).text ?? ''));
45
+ }
46
+ }
47
+ }
48
+ if (m.error) {
49
+ // Extract message from error objects for pattern matching.
50
+ const err = m.error;
51
+ if (typeof err === 'string') {
52
+ parts.push(err);
53
+ } else if (err && typeof err === 'object') {
54
+ const errObj = err as Record<string, unknown>;
55
+ if (typeof errObj.message === 'string') {
56
+ parts.push(errObj.message);
57
+ } else {
58
+ parts.push(JSON.stringify(err));
59
+ }
60
+ }
61
+ }
62
+ // S38: detect mid-response errors where the stream died without a
63
+ // proper stopReason (empty/undefined) — this catches provider failures
64
+ // that cut off the response mid-stream, INCLUDING the case where the
65
+ // provider emitted partial content before dying (a truncated response
66
+ // with no stop reason is a mid-stream death, not a success).
67
+ // A genuine success ALWAYS carries stop/tool_use/toolUse (which
68
+ // short-circuit to null at the top), so any message reaching here with a
69
+ // falsy stopReason is a stream failure → retryable.
70
+ if (!sr) {
71
+ return 'transient';
72
+ }
73
+ text = parts.join(' ');
74
+ }
75
+ if (!text) return null;
76
+ const s = text.toLowerCase();
77
+ // --- compaction-noop (ORDER FIRST: pi race / manual compact catch) ---
78
+ // FAIL-2026071701: these are NOT retryable — the compaction already
79
+ // succeeded via pi's native path; retrying would race again.
80
+ if (/already compacted/.test(s)) return 'compaction-noop';
81
+ if (/compaction failed/.test(s)) return 'compaction-noop';
82
+ if (/nothing to compact/.test(s)) return 'compaction-noop';
83
+ if (/auto[\s-]?compaction failed/.test(s)) return 'compaction-noop';
84
+ // --- transient (retryable) ---
85
+ if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
86
+ return 'transient'; // generic pi stopReason 'error' / 'aborted'
87
+ }
88
+ if (s.includes('aborted')) return 'transient';
89
+ if (/max(imum)? output token/.test(s)) return 'transient';
90
+ if (/rate[\s.-]?limit|429|too many requests/.test(s)) return 'transient';
91
+ if (/5\d\d|internal server|bad gateway|service unavailable/.test(s)) return 'transient';
92
+ if (/network|timeout|connection (lost|refused|reset)|stream (interrupted|closed|ended|failed)|disconnected/.test(s)) return 'transient';
93
+ // --- permanent (NOT retryable beyond 1) ---
94
+ if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) return 'permanent';
95
+ if (/invalid request|malformed|bad request/.test(s)) return 'permanent';
96
+ // Unknown — do not retry (avoid busy-looping on an unclassified signal).
97
+ return null;
98
+ }
99
+
100
+ /** S38.2: exponential backoff for error-retry nudges (5s,10s,20s,30s,30s cap).
101
+ * count is 1-based (the retry about to fire). */
102
+ export function errorRetryBackoffMs(count: number): number {
103
+ switch (count) {
104
+ case 1: return 5_000;
105
+ case 2: return 10_000;
106
+ case 3: return 20_000;
107
+ default: return 30_000; // cap from the 4th retry onward
108
+ }
109
+ }
@@ -16,3 +16,4 @@ export * from "./mega-events/agent-handlers.js";
16
16
  export * from "./mega-events/context-handler.js";
17
17
  export * from "./mega-events/compact-handlers.js";
18
18
  export * from "./mega-events/perf-handler.js";
19
+ export * from "./mega-events/error-classifier.js";
@@ -43,6 +43,11 @@ export interface SessionRuntime {
43
43
  recallInjections: number; // recall blocks injected this session-instance
44
44
  cacheHitTokens: number; // tokens saved via cache hits (dedup + recall) this session
45
45
  lengthStopPending: boolean; // S28: set on turn_end when stopReason==='length'
46
+ errorRetryCount: number; // S38: consecutive error turns, reset on success/turn_start
47
+ errorRetryUntil: number; // S38: wall-clock ms debounce for error-retry nudge
48
+ // S38.6: circuit-breaker state — consecutive error turns across the session.
49
+ // When this exceeds maxConsecutiveErrors, the extension stops retrying.
50
+ consecutiveErrors: number; // reset to 0 on successful turn_end
46
51
  }
47
52
 
48
53
  // ── ownVersion ─────────────────────────────────────────────────────────────
@@ -116,6 +116,39 @@ describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
116
116
  });
117
117
  });
118
118
 
119
+ describe("MegaRuntime pushTicker dedupe (P1)", () => {
120
+ const dirs: string[] = [];
121
+ after(() => {
122
+ for (const d of dirs) {
123
+ try { closeStore(d); } catch { /* */ }
124
+ }
125
+ delete process.env.MEGACOMPACT_STATE_DIR;
126
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
127
+ });
128
+
129
+ it("pushTicker skips consecutive identical entries (keeps ring at capacity for variety)", () => {
130
+ const dir = freshDir(); dirs.push(dir);
131
+ process.env.MEGACOMPACT_STATE_DIR = dir;
132
+ const rt = new MegaRuntime(minimalConfig(dir));
133
+ rt.pushTicker("compact");
134
+ rt.pushTicker("compact"); // dedup — skipped
135
+ rt.pushTicker("compact"); // dedup — skipped
136
+ rt.pushTicker("recall");
137
+ assert.equal(rt.ticker.length, 2, "only 2 unique entries kept");
138
+ assert.equal(rt.ticker[0].text, "compact");
139
+ assert.equal(rt.ticker[1].text, "recall");
140
+ });
141
+
142
+ it("pushTicker keeps distinct entries and respects TICKER_MAX", () => {
143
+ const dir = freshDir(); dirs.push(dir);
144
+ process.env.MEGACOMPACT_STATE_DIR = dir;
145
+ const rt = new MegaRuntime(minimalConfig(dir));
146
+ for (let i = 0; i < rt.TICKER_MAX + 3; i++) rt.pushTicker(`t${i}`);
147
+ assert.equal(rt.ticker.length, rt.TICKER_MAX, "capped at TICKER_MAX");
148
+ assert.equal(rt.ticker[rt.TICKER_MAX - 1].text, `t${rt.TICKER_MAX + 2}`);
149
+ });
150
+ });
151
+
119
152
  describe("MegaRuntime game-state cache (S31)", () => {
120
153
  const dirs: string[] = [];
121
154
  after(() => {
@@ -87,6 +87,9 @@ export class MegaRuntime {
87
87
  recallInjections: 0,
88
88
  cacheHitTokens: 0,
89
89
  lengthStopPending: false,
90
+ errorRetryCount: 0,
91
+ errorRetryUntil: 0,
92
+ consecutiveErrors: 0,
90
93
  };
91
94
  // v0.8.6 cache-stability: the cached live-trim view for the current
92
95
  // compaction epoch. Set after a fresh runCompact + computeLiveTrimCut, and
@@ -210,6 +213,11 @@ export class MegaRuntime {
210
213
  // getCachedGameState() snapshot re-queries the DB anyway.
211
214
  private gameStateWatcher?: FSWatcher;
212
215
  private gameStateWatchDir?: string;
216
+ // P2: the last ExtensionContext handed to snapshot()/renderWidget(), stashed
217
+ // so the fs.watch game-state callback can force a widget re-render without
218
+ // a context event (cross-process dashboard edits while pi is idle). Cleared
219
+ // implicitly on construction (undefined → watcher skips until first snap).
220
+ private lastWidgetCtx?: ExtensionContext;
213
221
 
214
222
  /**
215
223
  * DIAG counters for the "team run doesn't relieve context" investigation.
@@ -376,6 +384,7 @@ export class MegaRuntime {
376
384
 
377
385
  /** Collect live state and write it to disk (+ paint the above-editor widget). */
378
386
  snapshot(ctx?: ExtensionContext): void {
387
+ if (ctx) this.lastWidgetCtx = ctx;
379
388
  if (ctx) this.bindRepo(ctx.cwd);
380
389
  // v0.8.5: gate the expensive body (6 sync SQLite opens +
381
390
  // writeFileSync(dashboard.json)) behind a cheap material-change signature.
@@ -548,6 +557,16 @@ export class MegaRuntime {
548
557
  cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
549
558
  },
550
559
  model,
560
+ // S38.8: error-retry state for the dashboard "retries" tile. The field is
561
+ // declared on DashboardSnapshot (mega-dashboard.ts) and surfaced here so the
562
+ // dashboard can render live retry/circuit-breaker status alongside the event
563
+ // stream (which already carries per-retry events).
564
+ retries: {
565
+ errorRetryCount: this.rt.errorRetryCount,
566
+ consecutiveErrors: this.rt.consecutiveErrors,
567
+ maxConsecutiveErrors: this.config.maxConsecutiveErrors,
568
+ errorRetryHardStop: this.config.errorRetryHardStop,
569
+ },
551
570
  diag: {
552
571
  ctxFastGate: this.diagCtxFastGate,
553
572
  liveTrimFires: this.diagLiveTrimFires,
@@ -829,8 +848,11 @@ export class MegaRuntime {
829
848
  recallInjections: 0,
830
849
  cacheHitTokens: 0,
831
850
  lengthStopPending: false,
851
+ errorRetryCount: 0,
852
+ errorRetryUntil: 0,
853
+ consecutiveErrors: 0,
832
854
  };
833
- this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
855
+ this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
834
856
  this.statusKey = undefined;
835
857
  this.activeAgents = 0;
836
858
  this.currentTurn = 0;
@@ -983,6 +1005,29 @@ export class MegaRuntime {
983
1005
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
984
1006
  this.cachedGameState = undefined;
985
1007
  this.gameStateBump++;
1008
+ // P2: force a widget re-render so a dashboard-made theme/toggle/
1009
+ // tui-mode change reflects in the live TUI immediately, even when
1010
+ // pi is idle (no context event to drive snapshot()). Use the
1011
+ // LIGHTWEIGHT refreshWidgetGameState() — NOT the full snapshot():
1012
+ // snapshot() recomputes 6 sync SQLite opens + writes dashboard.json
1013
+ // + writes to the store, and those store writes RETRIGGER this
1014
+ // same fs.watch callback (it fires on every sqlite.db* write) →
1015
+ // re-entrant thrash → 190s test timeout under mega-compact.test.js
1016
+ // / mega-teamrun.test.js. The lightweight path re-reads ONLY the
1017
+ // game_state row and patches the three game-mode fields on the
1018
+ // existing widgetData, then re-registers the factory via
1019
+ // renderWidget() — it writes nothing to the store or
1020
+ // dashboard.json, so it cannot retrigger itself. Guard: skip until
1021
+ // the first snapshot stashed a ctx (no widget registered yet →
1022
+ // nothing to refresh). Non-fatal: next context event re-snapshots.
1023
+ const ctx = this.lastWidgetCtx;
1024
+ if (ctx) {
1025
+ try {
1026
+ this.refreshWidgetGameState(ctx);
1027
+ } catch {
1028
+ /* non-fatal */
1029
+ }
1030
+ }
986
1031
  }
987
1032
  },
988
1033
  );
@@ -1060,6 +1105,33 @@ export class MegaRuntime {
1060
1105
  return this.cachedGameState;
1061
1106
  }
1062
1107
 
1108
+ /** P2 cross-process re-render: lightweight game-state refresh for the
1109
+ * fs.watch callback. Eviction of cachedGameState + gameStateBump++ happens
1110
+ * in the caller BEFORE this runs. Here we re-read ONLY the game_state row
1111
+ * via getCachedGameState() (one SELECT; the cache is already evicted) and
1112
+ * patch ONLY the three game-mode fields on the EXISTING widgetData, then
1113
+ * re-register the widget factory via renderWidget() so pi redraws next
1114
+ * frame.
1115
+ *
1116
+ * WHY a lightweight path: the full snapshot(ctx) recomputes 6 synchronous
1117
+ * SQLite opens + writeFileSync(dashboard.json) + store writes, and those
1118
+ * store writes RETRIGGER this same fs.watch callback → re-entrant thrash
1119
+ * (the watcher fires on every sqlite.db* write, including context-event
1120
+ * checkpoint writes) → 190s test timeout under mega-compact.test.js /
1121
+ * mega-teamrun.test.js. This path writes NOTHING to the store or
1122
+ * dashboard.json, so it cannot retrigger itself.
1123
+ *
1124
+ * Guard: no-op when widgetData is null (no snapshot has run yet → nothing
1125
+ * to patch) or ctx is undefined. Field values mirror snapshot() exactly. */
1126
+ refreshWidgetGameState(ctx: ExtensionContext): void {
1127
+ if (!this.widgetData || !ctx) return;
1128
+ const gs = this.getCachedGameState();
1129
+ this.widgetData.gameMode = gs.game_mode_on;
1130
+ this.widgetData.theme = getTheme(gs.theme) ? gs.theme : "transparent";
1131
+ this.widgetData.tuiMode = gs.tui_display_mode;
1132
+ this.renderWidget(ctx);
1133
+ }
1134
+
1063
1135
  /** S31: evict the cached game-mode state so the next widget render re-reads
1064
1136
  * the game_state row. Called by /mega-game after every setGameState() so
1065
1137
  * the panel picks up theme/mode/toggle changes live. */
@@ -1148,6 +1220,14 @@ export class MegaRuntime {
1148
1220
 
1149
1221
  // Phase 3 — recall/activity ticker ring buffer.
1150
1222
  pushTicker(text: string): void {
1223
+ // P1: dedupe consecutive identical entries — skip the append when the
1224
+ // last entry's text matches, so a re-fired compact/recall/dedup event
1225
+ // doesn't flood the ring (keeps it at TICKER_MAX for real variety).
1226
+ // `at` is NOT refreshed on a skip (the original event time stands).
1227
+ if (this.ticker[this.ticker.length - 1]?.text === text) {
1228
+ this.lastActivityAt = Date.now();
1229
+ return;
1230
+ }
1151
1231
  this.ticker.push({ text, at: Date.now() });
1152
1232
  while (this.ticker.length > this.TICKER_MAX) this.ticker.shift();
1153
1233
  this.lastActivityAt = Date.now();
@@ -212,6 +212,54 @@ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
212
212
  });
213
213
  });
214
214
 
215
+ describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
216
+ // P1: the L5 ticker branch used to re-pick the head text every 250ms via
217
+ // `step = floor(Date.now()/250)`, flipping the footer line on a 250·N ms
218
+ // cycle. After the fix it pins to the most-recent entry, so two renders of
219
+ // the SAME WidgetData at t and t+500ms (well past one rotation slot) must
220
+ // produce byte-identical footer lines. Proves the metronome is gone.
221
+ const stabBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
222
+ theme: DEFAULT_THEME, tuiMode: "full", gameMode: false, level: 1, cachePct: 42, ...overrides,
223
+ });
224
+
225
+ it("ticker footer is byte-identical across simulated frames 500ms apart", () => {
226
+ const wd = stabBase({
227
+ ticker: [
228
+ { text: "first", at: 1000 },
229
+ { text: "second", at: 2000 },
230
+ { text: "third", at: 3000 },
231
+ ],
232
+ lastWhy: "because",
233
+ });
234
+ const realNow = Date.now;
235
+ try {
236
+ Date.now = () => 1_000_000; // t
237
+ const a = buildWidgetLines(wd, WIDTH, 0);
238
+ Date.now = () => 1_000_500; // t+500ms (would have rotated twice under the old code)
239
+ const b = buildWidgetLines(wd, WIDTH, 0);
240
+ assert.deepEqual(a, b, "footer byte-identical across 500ms with no state change");
241
+ // And the pinned head is the most-recent entry (third), not a rotation:
242
+ assert.ok(a.some((l) => l.includes("third")), "shows most-recent ticker entry");
243
+ assert.ok(a.some((l) => l.includes("(+2 more)")), "(+N more) suffix preserved");
244
+ assert.ok(a.some((l) => l.includes("because")), "lastWhy preserved");
245
+ } finally {
246
+ Date.now = realNow;
247
+ }
248
+ });
249
+
250
+ it("footer still flips when the ticker actually changes (new pushTicker)", () => {
251
+ const realNow = Date.now;
252
+ try {
253
+ Date.now = () => 1_000_000;
254
+ const before = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }] }), WIDTH, 0);
255
+ const after = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }, { text: "b", at: 2 }] }), WIDTH, 0);
256
+ assert.notDeepEqual(before, after, "footer changes when ticker grows");
257
+ } finally {
258
+ Date.now = realNow;
259
+ }
260
+ });
261
+ });
262
+
215
263
  describe("buildWidgetLines achievement flare (S35)", () => {
216
264
  const achBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
217
265
  theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
@@ -388,8 +388,12 @@ export function buildWidgetLines(
388
388
  if (wd.tierTrace && wd.fresh) {
389
389
  lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width, panelBg));
390
390
  } else if (wd.ticker.length > 0) {
391
- const step = Math.floor(Date.now() / 250);
392
- const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
391
+ // P1: pin to the most-recent entry (no 250ms rotation). The old
392
+ // `step = floor(Date.now()/250)` re-picked the head every TUI frame,
393
+ // flipping the footer line on a 250·N ms cycle. Pinning makes the
394
+ // footer hold stable until a real state change (new pushTicker /
395
+ // tierTrace / pulsing transition). (+N more) + lastWhy are preserved.
396
+ const idx = wd.ticker.length - 1;
393
397
  const head = wd.ticker[idx].text;
394
398
  const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
395
399
  const more =