pi-mega-compact 0.8.23 → 0.8.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +8 -0
- package/dist/extensions/dashboard-server/api-contracts/game-types.js +7 -0
- package/dist/extensions/mega-compact-s38.test.js +263 -14
- package/dist/extensions/mega-compact.js +15 -0
- package/dist/extensions/mega-config.js +3 -0
- package/dist/extensions/mega-events/agent-handlers.js +211 -26
- package/dist/extensions/mega-events/context-handler.js +45 -7
- package/dist/extensions/mega-events/error-classifier.js +125 -18
- package/dist/extensions/mega-pipeline/compact.js +24 -13
- package/dist/extensions/mega-pipeline/recall.js +31 -2
- package/dist/extensions/mega-runtime/append-event.js +24 -0
- package/dist/extensions/mega-runtime/bind-repo.js +65 -0
- package/dist/extensions/mega-runtime/capture-model.js +87 -0
- package/dist/extensions/mega-runtime/dashboard-snapshot.js +122 -0
- package/dist/extensions/mega-runtime/effects.js +86 -0
- package/dist/extensions/mega-runtime/engine-view.js +11 -0
- package/dist/extensions/mega-runtime/game-state.js +116 -0
- package/dist/extensions/mega-runtime/get-state-dir.js +10 -0
- package/dist/extensions/mega-runtime/perf.js +49 -0
- package/dist/extensions/mega-runtime/pressure-getters.js +64 -0
- package/dist/extensions/mega-runtime/render-widget.js +17 -0
- package/dist/extensions/mega-runtime/reset-runtime.js +50 -0
- package/dist/extensions/mega-runtime/runtime-helpers.js +73 -0
- package/dist/extensions/mega-runtime/runtime-snapshot.js +208 -0
- package/dist/extensions/mega-runtime/runtime.js +405 -0
- package/dist/extensions/mega-runtime/snapshot.js +142 -0
- package/dist/extensions/mega-runtime/state.js +5 -1151
- package/dist/extensions/mega-runtime/status.js +11 -0
- package/dist/extensions/mega-runtime/widget-ansi.js +207 -0
- package/dist/extensions/mega-runtime/widget-types.js +8 -0
- package/dist/extensions/mega-runtime/widget.js +15 -204
- package/dist/extensions/openclaw-mega-compact.js +291 -0
- package/dist/src/boundary.js +79 -43
- package/dist/src/boundary.test.js +119 -2
- package/dist/src/canary.js +10 -0
- package/dist/src/config/dedup.js +14 -0
- package/dist/src/config.js +3 -1
- package/dist/src/dedup/raptor/buildHistory.js +164 -0
- package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
- package/dist/src/dedup/raptor/index.js +38 -0
- package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
- package/dist/src/dedup/raptor/multilevel.js +17 -5
- package/dist/src/dedup/raptor/multilevel.test.js +36 -1
- package/dist/src/dedup/raptor/raptor.test.js +43 -0
- package/dist/src/dedup/raptor/retrieval.js +14 -2
- package/dist/src/dedup/raptor/retrieval.test.js +95 -0
- package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
- package/dist/src/dedup/raptor/summarizer.js +1 -0
- package/dist/src/dedup/raptor/tree.js +16 -2
- package/dist/src/engine.js +18 -2
- package/dist/src/httpEmbedder.js +96 -6
- package/dist/src/httpEmbedder.test.js +277 -0
- package/dist/src/mechanical-fix.test.js +65 -0
- package/dist/src/minilm.js +92 -0
- package/dist/src/raptor-inject-summaries.test.js +155 -0
- package/dist/src/recall.js +135 -21
- package/dist/src/recall.test.js +179 -4
- package/dist/src/store/sqlite/dedup-mirror.js +32 -15
- package/dist/src/store/sqlite/maintenance.js +2 -2
- package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
- package/dist/src/store/sqlite/memories.js +5 -5
- package/dist/src/store/sqlite/meta.js +1 -1
- package/dist/src/store/sqlite/raptor.js +56 -17
- package/dist/src/store/sqlite/raptor.test.js +106 -0
- package/dist/src/store/sqlite/schema.js +90 -1
- package/dist/src/store/sqlite/session-state.js +9 -3
- package/dist/src/store/sqlite/stats.js +9 -5
- package/dist/src/store/sqlite/turns.js +181 -0
- package/dist/src/store/sqlite/turns.test.js +183 -0
- package/dist/src/store/sqlite/utils.js +15 -4
- package/dist/src/store/sqlite.js +1 -0
- package/dist/src/store.js +2 -2
- package/dist/src/vector-search-cache.test.js +157 -0
- package/dist/src/vector-search.js +107 -15
- package/dist/src/vectorStore.js +36 -8
- package/dist/src/wordpiece.js +129 -0
- package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +1 -1
- package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -155
- package/extensions/dashboard-server/api-contracts/game-types.ts +172 -0
- package/extensions/mega-compact-s38.test.ts +259 -14
- package/extensions/mega-compact.ts +15 -0
- package/extensions/mega-config.ts +18 -0
- package/extensions/mega-dashboard.ts +10 -1
- package/extensions/mega-events/agent-handlers.ts +211 -26
- package/extensions/mega-events/context-handler.ts +43 -7
- package/extensions/mega-events/error-classifier.ts +125 -17
- package/extensions/mega-pipeline/compact.ts +28 -16
- package/extensions/mega-pipeline/recall.ts +34 -2
- package/extensions/mega-runtime/DECOMPOSITION.md +180 -0
- package/extensions/mega-runtime/README.md +38 -0
- package/extensions/mega-runtime/append-event.ts +40 -0
- package/extensions/mega-runtime/bind-repo.ts +81 -0
- package/extensions/mega-runtime/capture-model.ts +101 -0
- package/extensions/mega-runtime/dashboard-snapshot.ts +181 -0
- package/extensions/mega-runtime/effects.ts +129 -0
- package/extensions/mega-runtime/engine-view.ts +17 -0
- package/extensions/mega-runtime/game-state.ts +149 -0
- package/extensions/mega-runtime/get-state-dir.ts +19 -0
- package/extensions/mega-runtime/helpers.ts +25 -1
- package/extensions/mega-runtime/perf.ts +60 -0
- package/extensions/mega-runtime/pressure-getters.ts +96 -0
- package/extensions/mega-runtime/render-widget.ts +41 -0
- package/extensions/mega-runtime/runtime-helpers.ts +119 -0
- package/extensions/mega-runtime/runtime-snapshot.ts +293 -0
- package/extensions/mega-runtime/runtime.ts +483 -0
- package/extensions/mega-runtime/snapshot.ts +230 -0
- package/extensions/mega-runtime/state.ts +5 -1268
- package/extensions/mega-runtime/status.ts +26 -0
- package/extensions/mega-runtime/widget-ansi.ts +217 -0
- package/extensions/mega-runtime/widget-types.ts +80 -0
- package/extensions/mega-runtime/widget.ts +34 -285
- package/package.json +1 -1
- package/src/boundary.test.ts +128 -2
- package/src/boundary.ts +75 -39
- package/src/canary.ts +10 -0
- package/src/config/dedup.ts +25 -0
- package/src/config.ts +3 -1
- package/src/dedup/raptor/buildHistory.test.ts +353 -0
- package/src/dedup/raptor/buildHistory.ts +259 -0
- package/src/dedup/raptor/index.ts +38 -0
- package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
- package/src/dedup/raptor/multilevel.test.ts +47 -0
- package/src/dedup/raptor/multilevel.ts +18 -8
- package/src/dedup/raptor/raptor.test.ts +59 -0
- package/src/dedup/raptor/retrieval.test.ts +118 -0
- package/src/dedup/raptor/retrieval.ts +14 -2
- package/src/dedup/raptor/serve-gate.test.ts +348 -0
- package/src/dedup/raptor/summarizer.ts +1 -0
- package/src/dedup/raptor/tree.ts +17 -2
- package/src/engine.ts +32 -3
- package/src/httpEmbedder.test.ts +286 -0
- package/src/httpEmbedder.ts +98 -8
- package/src/mechanical-fix.test.ts +70 -0
- package/src/raptor-inject-summaries.test.ts +212 -0
- package/src/recall.test.ts +220 -4
- package/src/recall.ts +151 -22
- package/src/store/sqlite/dedup-mirror.ts +35 -18
- package/src/store/sqlite/maintenance.ts +2 -2
- package/src/store/sqlite/mechanical-fix.test.ts +162 -0
- package/src/store/sqlite/memories.ts +5 -5
- package/src/store/sqlite/meta.ts +1 -1
- package/src/store/sqlite/raptor.test.ts +139 -0
- package/src/store/sqlite/raptor.ts +135 -81
- package/src/store/sqlite/schema.ts +90 -1
- package/src/store/sqlite/session-state.ts +9 -3
- package/src/store/sqlite/stats.ts +10 -8
- package/src/store/sqlite/turns.test.ts +218 -0
- package/src/store/sqlite/turns.ts +292 -0
- package/src/store/sqlite/utils.ts +14 -4
- package/src/store/sqlite.ts +1 -0
- package/src/store.ts +9 -2
- package/src/vector-search-cache.test.ts +190 -0
- package/src/vector-search.ts +273 -156
- package/src/vectorStore.ts +443 -382
- package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
- package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
|
@@ -119,6 +119,17 @@ async function s38TurnEnd(h: ReturnType<typeof harness>, stopReason: string | un
|
|
|
119
119
|
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** R3 helper: like s38TurnEnd but attaches a `usage` object so the classifier's
|
|
123
|
+
* 0-token poisoned-context signal is exercised (usage PRESENT with 0 tokens).
|
|
124
|
+
* Pass `tokens` > 0 to simulate a turn that reached the model. */
|
|
125
|
+
async function s38TurnEndUsage(h: ReturnType<typeof harness>, stopReason: string | undefined, text: string | undefined, tokens: number) {
|
|
126
|
+
const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
|
|
127
|
+
const message: any = { role: "assistant", usage: { inputTokens: tokens, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } };
|
|
128
|
+
if (stopReason !== undefined) message.stopReason = stopReason;
|
|
129
|
+
if (text) message.content = text;
|
|
130
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
// ---- classifier unit tests (no extension harness needed) ----
|
|
123
134
|
|
|
124
135
|
test("S38: classifyError returns 'transient' for error stopReason", () => {
|
|
@@ -301,13 +312,20 @@ test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", as
|
|
|
301
312
|
assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
|
|
302
313
|
});
|
|
303
314
|
|
|
304
|
-
test("S38:
|
|
315
|
+
test("S38: R1 burst of immediate transient errors fires 1 nudge (dedup), rest suppressed by retryNudgePending", async () => {
|
|
316
|
+
// R1 redesign: a burst of immediate error turn_ends (no turn_start between)
|
|
317
|
+
// produces ONE nudge — the rest are suppressed by retryNudgePending because
|
|
318
|
+
// the queued nudge (deliverAs:'followUp') has not been consumed by a new
|
|
319
|
+
// agent turn. errorRetryCount still advances for each error turn, so the
|
|
320
|
+
// per-burst max + circuit breaker still bound the burst.
|
|
305
321
|
const h = harness();
|
|
306
|
-
for (let i = 0; i < 5; i++) await s38TurnEnd(h, "error",
|
|
307
|
-
assert.equal(h.sendUserMessages.length,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
assert.
|
|
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");
|
|
311
329
|
});
|
|
312
330
|
|
|
313
331
|
test("S38: retry fires 1x for permanent errors then stops", async () => {
|
|
@@ -320,18 +338,31 @@ test("S38: retry fires 1x for permanent errors then stops", async () => {
|
|
|
320
338
|
});
|
|
321
339
|
|
|
322
340
|
test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
341
|
+
// R1: backoff is now gating, so use a tiny backoff + small wait to let the
|
|
342
|
+
// second nudge fire after the success reset clears retryNudgePending (R4).
|
|
343
|
+
const prev = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
344
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
345
|
+
try {
|
|
346
|
+
const h = harness();
|
|
347
|
+
await s38TurnEnd(h, "error", "5xx server error 0");
|
|
348
|
+
assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
|
|
349
|
+
await s38TurnEnd(h, "stop");
|
|
350
|
+
assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending (R4)");
|
|
351
|
+
await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
|
|
352
|
+
await s38TurnEnd(h, "error", "5xx server error 1");
|
|
353
|
+
assert.equal(h.sendUserMessages.length, 2, "success reset -> transient fires again from count=1");
|
|
354
|
+
} finally {
|
|
355
|
+
if (prev === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
356
|
+
else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prev;
|
|
357
|
+
}
|
|
329
358
|
});
|
|
330
359
|
|
|
331
360
|
test("S38: error_retry_exhausted event logged when max exceeded", async () => {
|
|
361
|
+
// R3: "malformed bad request" is now poisoned-context (not permanent), so
|
|
362
|
+
// use an auth-derived permanent error to exercise the per-burst exhausted path.
|
|
332
363
|
const h = harness();
|
|
333
|
-
await s38TurnEnd(h, "error", "
|
|
334
|
-
await s38TurnEnd(h, "error", "
|
|
364
|
+
await s38TurnEnd(h, "error", "unauthorized: invalid api key");
|
|
365
|
+
await s38TurnEnd(h, "error", "unauthorized: invalid api key");
|
|
335
366
|
assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
|
|
336
367
|
});
|
|
337
368
|
|
|
@@ -414,10 +445,224 @@ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", asy
|
|
|
414
445
|
}
|
|
415
446
|
});
|
|
416
447
|
|
|
448
|
+
// ---- R3 classifier unit tests: poisoned-context signals ----
|
|
449
|
+
|
|
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");
|
|
454
|
+
// Bare stopReason 'error' with NO usage field stays transient (unknown tokens
|
|
455
|
+
// — conservative; preserves the pre-R3 mid-response/partial-content behavior).
|
|
456
|
+
assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("R3 classifier: ECONNRESET (0-token) → transient (network failures stay transient)", () => {
|
|
460
|
+
// R3: network failures must stay transient even with 0 tokens.
|
|
461
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "ECONNRESET", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
462
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "connection reset by peer", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
463
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "timeout", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
464
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "503 service unavailable", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("R3 classifier: 'request failed' generic (no transient marker) → poisoned-context", () => {
|
|
468
|
+
// The exact incident phrasing: "Request failed — please retry." with no
|
|
469
|
+
// specific transient cause → deterministic rejection.
|
|
470
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "Request failed — please retry.", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
|
|
471
|
+
assert.equal(classifyErrorFn("request failed"), "poisoned-context");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("R3 classifier: orphaned-tool-result 400 (non-overflow) → poisoned-context", () => {
|
|
475
|
+
// Provider request-validation 400 that is NOT context-overflow: orphaned
|
|
476
|
+
// tool result / malformed message structure. Previously 'permanent' (1
|
|
477
|
+
// retry), now 'poisoned-context' (retry re-submits the same malformed shape).
|
|
478
|
+
assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"orphaned tool result: tooluse ids mismatch"}'), "poisoned-context");
|
|
479
|
+
assert.equal(classifyErrorFn("invalid request: unexpected role ordering"), "poisoned-context");
|
|
480
|
+
assert.equal(classifyErrorFn("malformed message structure"), "poisoned-context");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("R3 classifier: context-overflow phrasing still context-overflow (not poisoned)", () => {
|
|
484
|
+
// Regression guard: the context-overflow check runs BEFORE the poisoned
|
|
485
|
+
// signals, so a 400 "too long" stays context-overflow (forced re-compact),
|
|
486
|
+
// not poisoned (advise + compact).
|
|
487
|
+
assert.equal(
|
|
488
|
+
classifyErrorFn({ stopReason: "error", content: "Your conversation is too long for this model's context window even after compaction.", usage: { inputTokens: 0, outputTokens: 0 } }),
|
|
489
|
+
"context-overflow",
|
|
490
|
+
);
|
|
491
|
+
assert.equal(
|
|
492
|
+
classifyErrorFn('{"type":"invalid_request_error","message":"maximum context length is 200000 tokens. requires at least 201070 tokens."}'),
|
|
493
|
+
"context-overflow",
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("R3 classifier: auth/permission stays permanent (not poisoned)", () => {
|
|
498
|
+
// Auth errors are retryable-once (permanent), not poisoned — the user can
|
|
499
|
+
// fix the key and retry.
|
|
500
|
+
assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
|
|
501
|
+
assert.equal(classifyErrorFn("permission denied"), "permanent");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ---- R6 integration tests (retry redesign) ----
|
|
505
|
+
|
|
506
|
+
test("R6(a): 10 consecutive identical 0-token transient failures produce at most errorRetrySessionMax nudges", async () => {
|
|
507
|
+
// Use TRANSIENT 0-token failures (network text so they're transient, not
|
|
508
|
+
// poisoned) + turn_start + tiny backoff between each so the nudge is
|
|
509
|
+
// consumed and the next turn can fire. sessionMax default = 3. Repeat
|
|
510
|
+
// threshold raised to disable the stateful poisoned upgrade so this
|
|
511
|
+
// exercises the SESSION CAP, not the repeat signal.
|
|
512
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
513
|
+
const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
514
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
515
|
+
process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
|
|
516
|
+
try {
|
|
517
|
+
const h = harness();
|
|
518
|
+
for (let i = 0; i < 10; i++) {
|
|
519
|
+
// 0-token transient: usage present with 0 tokens + "connection reset" (network marker).
|
|
520
|
+
await s38TurnEndUsage(h, "error", "connection reset", 0);
|
|
521
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
|
|
522
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
523
|
+
}
|
|
524
|
+
assert.ok(h.sendUserMessages.length <= 3, `R6(a): at most sessionMax (3) nudges, got ${h.sendUserMessages.length}`);
|
|
525
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_session_exhausted"), "session_exhausted event logged");
|
|
526
|
+
} finally {
|
|
527
|
+
if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
528
|
+
else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
529
|
+
if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
530
|
+
else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("R6(b): poisoned-context fires zero retry nudges and exactly one advise message", async () => {
|
|
535
|
+
// auto=false so the guarded compact attempt (R3c) is skipped — this test
|
|
536
|
+
// focuses on the advise + no-retry behavior. The compact path is the same
|
|
537
|
+
// race-guarded deferred mechanism already covered by the context-overflow tests.
|
|
538
|
+
const prevAuto = process.env.MEGACOMPACT_AUTO;
|
|
539
|
+
process.env.MEGACOMPACT_AUTO = "false";
|
|
540
|
+
try {
|
|
541
|
+
const h = harness();
|
|
542
|
+
// 0-token generic "request failed" (no transient marker) → poisoned.
|
|
543
|
+
await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
|
|
544
|
+
assert.equal(h.sendUserMessages.length, 1, "poisoned: exactly one advise message");
|
|
545
|
+
assert.ok(
|
|
546
|
+
h.sendUserMessages[0].includes("/clear") || h.sendUserMessages[0].includes("/new"),
|
|
547
|
+
"advise mentions /clear or /new",
|
|
548
|
+
);
|
|
549
|
+
assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "poisoned_context event logged");
|
|
550
|
+
assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "poisoned: zero retry nudges (no error_retry event)");
|
|
551
|
+
// Second poisoned turn: advise throttled to one per session.
|
|
552
|
+
await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
|
|
553
|
+
assert.equal(h.sendUserMessages.length, 1, "poisoned: advise throttled (one per session)");
|
|
554
|
+
assert.ok(eventTypes(h.stateDir).filter((t) => t === "poisoned_context").length >= 2, "poisoned_context logged each turn");
|
|
555
|
+
} finally {
|
|
556
|
+
if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
|
|
557
|
+
else process.env.MEGACOMPACT_AUTO = prevAuto;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("R6(c): transient burst retries with backoff gating — second immediate nudge suppressed while one pending", async () => {
|
|
562
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
563
|
+
const prevSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
|
|
564
|
+
const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
565
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
566
|
+
process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999"; // don't let session cap bind
|
|
567
|
+
process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999"; // don't let repeat upgrade bind
|
|
568
|
+
try {
|
|
569
|
+
const h = harness();
|
|
570
|
+
// Turn 1: transient → nudge 1 fires (pending=true, backoff=1ms).
|
|
571
|
+
await s38TurnEnd(h, "error", "internal server error 0");
|
|
572
|
+
assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
|
|
573
|
+
// Turn 2: immediate (no turn_start) → suppressed by retryNudgePending.
|
|
574
|
+
await s38TurnEnd(h, "error", "internal server error 1");
|
|
575
|
+
assert.equal(h.sendUserMessages.length, 1, "second immediate nudge suppressed (retryNudgePending)");
|
|
576
|
+
// turn_start consumes the pending nudge (resets pending + count).
|
|
577
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
|
|
578
|
+
await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
|
|
579
|
+
// Turn 3: transient → nudge 2 fires (pending cleared, backoff elapsed).
|
|
580
|
+
await s38TurnEnd(h, "error", "internal server error 2");
|
|
581
|
+
assert.equal(h.sendUserMessages.length, 2, "after turn_start + backoff: nudge fires");
|
|
582
|
+
} finally {
|
|
583
|
+
if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
584
|
+
else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
585
|
+
if (prevSession === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
|
|
586
|
+
else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = prevSession;
|
|
587
|
+
if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
588
|
+
else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("R6(d): user abort (stopReason aborted) never nudges, even across repeated aborts", async () => {
|
|
593
|
+
const h = harness();
|
|
594
|
+
await s38TurnEnd(h, "aborted", "Operation aborted");
|
|
595
|
+
assert.equal(h.sendUserMessages.length, 0, "aborted: no nudge");
|
|
596
|
+
await s38TurnEnd(h, "aborted", "Aborted after 3 retry attempts");
|
|
597
|
+
assert.equal(h.sendUserMessages.length, 0, "aborted: still no nudge after repeated aborts");
|
|
598
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_cancelled"), "cancelled event logged");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("R6(e): success resets retry-nudge-pending state", async () => {
|
|
602
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
603
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
604
|
+
try {
|
|
605
|
+
const h = harness();
|
|
606
|
+
// Turn 1: transient → nudge fires, pending=true.
|
|
607
|
+
await s38TurnEnd(h, "error", "internal server error 0");
|
|
608
|
+
assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
|
|
609
|
+
// Turn 2: immediate transient → suppressed by pending (no turn_start).
|
|
610
|
+
await s38TurnEnd(h, "error", "internal server error 1");
|
|
611
|
+
assert.equal(h.sendUserMessages.length, 1, "second immediate suppressed by pending");
|
|
612
|
+
// Turn 3: success (stop) → resets pending (R4), no nudge.
|
|
613
|
+
await s38TurnEnd(h, "stop");
|
|
614
|
+
assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending");
|
|
615
|
+
await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
|
|
616
|
+
// Turn 4: transient → nudge fires again (pending was reset by success).
|
|
617
|
+
await s38TurnEnd(h, "error", "internal server error 2");
|
|
618
|
+
assert.equal(h.sendUserMessages.length, 2, "after success reset pending: transient nudge fires");
|
|
619
|
+
} finally {
|
|
620
|
+
if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
621
|
+
else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("R3: repeated identical transient error text upgrades to poisoned-context at threshold", async () => {
|
|
626
|
+
// 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.
|
|
630
|
+
const prevAuto = process.env.MEGACOMPACT_AUTO;
|
|
631
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
632
|
+
process.env.MEGACOMPACT_AUTO = "false";
|
|
633
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
634
|
+
try {
|
|
635
|
+
const h = harness();
|
|
636
|
+
// Turns 1-2: transient (repeatCount 1, 2) → nudges fire.
|
|
637
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
638
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
|
|
639
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
640
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
641
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: 3 }, h.ctx());
|
|
642
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
643
|
+
// Turn 3: repeatCount=3 ≥ threshold → upgraded to poisoned. No nudge, advise fires.
|
|
644
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
645
|
+
assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "repeat threshold reached: poisoned_context event logged");
|
|
646
|
+
assert.ok(h.sendUserMessages.some((m) => m.includes("/clear") || m.includes("/new")), "repeat threshold: advise message fired");
|
|
647
|
+
} finally {
|
|
648
|
+
if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
|
|
649
|
+
else process.env.MEGACOMPACT_AUTO = prevAuto;
|
|
650
|
+
if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
651
|
+
else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
417
655
|
test("cleanup", async () => {
|
|
418
656
|
// PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
|
|
419
657
|
try {
|
|
420
658
|
await Promise.race([closeVectorIndex(), new Promise((r) => setTimeout(r, 3000))]);
|
|
421
659
|
} catch { /* ignore */ }
|
|
422
660
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
661
|
+
// Force-exit: each harness() creates a MegaRuntime with an fs.watch
|
|
662
|
+
// game-state watcher that is never disposed (no session_shutdown in tests).
|
|
663
|
+
// 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.
|
|
667
|
+
process.exit(0);
|
|
423
668
|
});
|
|
@@ -52,6 +52,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
52
52
|
console.warn('[mega-compact] MEGACOMPACT_MAX_CONSECUTIVE_ERRORS must be >= 1; using default 10');
|
|
53
53
|
config.maxConsecutiveErrors = 10;
|
|
54
54
|
}
|
|
55
|
+
// R2: session-global cap validation — 0 disables (valid), negative is invalid.
|
|
56
|
+
if (config.errorRetrySessionMax < 0) {
|
|
57
|
+
console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_SESSION_MAX must be >= 0; using default 3');
|
|
58
|
+
config.errorRetrySessionMax = 3;
|
|
59
|
+
}
|
|
60
|
+
// R1: backoff base must be >= 0 (0 means no gating, useful for tests).
|
|
61
|
+
if (config.errorRetryBackoffMs < 0) {
|
|
62
|
+
console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_BACKOFF_MS must be >= 0; using default 5000');
|
|
63
|
+
config.errorRetryBackoffMs = 5000;
|
|
64
|
+
}
|
|
65
|
+
// R3: repeat threshold must be >= 1.
|
|
66
|
+
if (config.poisonedContextRepeatThreshold < 1) {
|
|
67
|
+
console.warn('[mega-compact] MEGACOMPACT_POISONED_REPEAT_THRESHOLD must be >= 1; using default 3');
|
|
68
|
+
config.poisonedContextRepeatThreshold = 3;
|
|
69
|
+
}
|
|
55
70
|
const runtime = new MegaRuntime(config);
|
|
56
71
|
registerEventHandlers(pi, runtime, config);
|
|
57
72
|
registerCommands(pi, runtime, config);
|
|
@@ -89,6 +89,21 @@ export interface MegaConfig {
|
|
|
89
89
|
* Default false. Set via env to force S28-only behavior (length-stop continues
|
|
90
90
|
* only). */
|
|
91
91
|
errorRetryHardStop: boolean;
|
|
92
|
+
/** R1 (retry redesign): base unit (ms) for errorRetryBackoffMs(count) pacing.
|
|
93
|
+
* The schedule is base, 2*base, 4*base, 6*base (cap) — so the default 5000
|
|
94
|
+
* yields 5s/10s/20s/30s. errorRetryUntil is now GATING (previously it was
|
|
95
|
+
* documented as non-gating); a nudge cannot fire before errorRetryUntil
|
|
96
|
+
* elapses. */
|
|
97
|
+
errorRetryBackoffMs: number;
|
|
98
|
+
/** R2: session-global cap on total S38 nudges across ALL bursts. Hitting it
|
|
99
|
+
* is terminal for the session — the extension stops nudging entirely,
|
|
100
|
+
* independent of the per-burst max and the circuit breaker. Default 3.
|
|
101
|
+
* `0` disables (reverts to per-burst + circuit-breaker only). */
|
|
102
|
+
errorRetrySessionMax: number;
|
|
103
|
+
/** R3: consecutive identical error-text count at which a 'transient'
|
|
104
|
+
* classification is upgraded to 'poisoned-context' (the stateful repeat
|
|
105
|
+
* signal). Default 3. Raise to make the upgrade less aggressive. */
|
|
106
|
+
poisonedContextRepeatThreshold: number;
|
|
92
107
|
/** S29: override the auto-compact fire point for tiered configs, as a
|
|
93
108
|
* fraction of the context window (e.g. 0.85). null = inherit the tier's
|
|
94
109
|
* tierPct (default; preserves existing fire points). The context-handler
|
|
@@ -262,6 +277,9 @@ export function loadConfig(): MegaConfig {
|
|
|
262
277
|
raceGuardStrict: envBool("MEGACOMPACT_RACE_GUARD_STRICT", true),
|
|
263
278
|
maxConsecutiveErrors: envFlag("MEGACOMPACT_MAX_CONSECUTIVE_ERRORS", 10),
|
|
264
279
|
errorRetryHardStop: envBool("MEGACOMPACT_ERROR_RETRY_HARD_STOP", false),
|
|
280
|
+
errorRetryBackoffMs: envFlag("MEGACOMPACT_ERROR_RETRY_BACKOFF_MS", 5000),
|
|
281
|
+
errorRetrySessionMax: envFlag("MEGACOMPACT_ERROR_RETRY_SESSION_MAX", 3),
|
|
282
|
+
poisonedContextRepeatThreshold: envFlag("MEGACOMPACT_POISONED_REPEAT_THRESHOLD", 3),
|
|
265
283
|
autoPctTrigger,
|
|
266
284
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
267
285
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
@@ -146,12 +146,21 @@ export interface DashboardSnapshot {
|
|
|
146
146
|
liveTrimFires: number;
|
|
147
147
|
liveTrimReplays: number;
|
|
148
148
|
};
|
|
149
|
-
/** S38.8: error-retry state for dashboard "retries" tile.
|
|
149
|
+
/** S38.8: error-retry state for dashboard "retries" tile.
|
|
150
|
+
* R7 (retry redesign): sessionRetryCount / sessionMax / poisonedCount are
|
|
151
|
+
* ADDITIVE fields — older dashboards that don't read them stay
|
|
152
|
+
* backward-compatible. */
|
|
150
153
|
retries?: {
|
|
151
154
|
errorRetryCount: number;
|
|
152
155
|
consecutiveErrors: number;
|
|
153
156
|
maxConsecutiveErrors: number;
|
|
154
157
|
errorRetryHardStop: boolean;
|
|
158
|
+
/** R2: total S38 nudges fired this session across ALL bursts. */
|
|
159
|
+
sessionRetryCount: number;
|
|
160
|
+
/** R2: session-global cap (errorRetrySessionMax). 0 = disabled. */
|
|
161
|
+
sessionMax: number;
|
|
162
|
+
/** R3/R7: number of poisoned-context events this session. */
|
|
163
|
+
poisonedCount: number;
|
|
155
164
|
};
|
|
156
165
|
}
|
|
157
166
|
|