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
package/README.md
CHANGED
|
@@ -86,6 +86,32 @@ npm run lint # Type check + guardrails scan
|
|
|
86
86
|
|
|
87
87
|
Testing guide: [`TESTER_GUIDE.md`](TESTER_GUIDE.md)
|
|
88
88
|
|
|
89
|
+
## Troubleshooting
|
|
90
|
+
|
|
91
|
+
### npm lifecycle scripts disabled
|
|
92
|
+
|
|
93
|
+
Some package managers disable lifecycle scripts by default. PGlite and `@mongodb-js/zstd` need scripts enabled to build their native components.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Re-enable install scripts
|
|
97
|
+
npm config set ignore-scripts false
|
|
98
|
+
npm install
|
|
99
|
+
|
|
100
|
+
# Or allow scripts for specific packages only
|
|
101
|
+
npm install --install-strategy=linked
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### node:sqlite not found / experimental flag required
|
|
105
|
+
|
|
106
|
+
pi-mega-compact requires Node ≥22.13 for the built-in `node:sqlite` module. If you're on an older version or your Node build doesn't include it by default:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
export NODE_OPTIONS="--experimental-sqlite"
|
|
110
|
+
pi
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Add the export to your shell profile (`.bashrc`, `.zshrc`, etc.) to make it permanent.
|
|
114
|
+
|
|
89
115
|
## License
|
|
90
116
|
|
|
91
117
|
BSD 3-Clause
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* Sprint A1 — PREVENT-PI-004: zero network code (type definitions only).
|
|
9
9
|
* PREVENT-011: no `any` type — all types are explicit.
|
|
10
10
|
*/
|
|
11
|
+
// ─── Game Score & Achievement Types ─────────────────────────────────────────
|
|
12
|
+
// (moved to game-types.ts; re-exported for backwards compatibility)
|
|
13
|
+
// ─── Sessions Memory Graph (S39) ─────────────────────────────────────────
|
|
14
|
+
// (moved to game-types.ts; re-exported for backwards compatibility)
|
|
15
|
+
// ─── Game State Patch (PUT request body) ────────────────────────────────────
|
|
16
|
+
// (moved to game-types.ts; re-exported for backwards compatibility)
|
|
17
|
+
// ─── SSE Endpoint Definition ────────────────────────────────────────────────
|
|
18
|
+
// (moved to game-types.ts; re-exported for backwards compatibility)
|
|
11
19
|
// ─── ENDPOINTS Registry ─────────────────────────────────────────────────────
|
|
12
20
|
/**
|
|
13
21
|
* Central registry of all 13 dashboard API endpoints. Each entry is an
|
|
@@ -121,6 +121,18 @@ async function s38TurnEnd(h, stopReason, text) {
|
|
|
121
121
|
message.content = text;
|
|
122
122
|
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
|
|
123
123
|
}
|
|
124
|
+
/** R3 helper: like s38TurnEnd but attaches a `usage` object so the classifier's
|
|
125
|
+
* 0-token poisoned-context signal is exercised (usage PRESENT with 0 tokens).
|
|
126
|
+
* Pass `tokens` > 0 to simulate a turn that reached the model. */
|
|
127
|
+
async function s38TurnEndUsage(h, stopReason, text, tokens) {
|
|
128
|
+
const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
|
|
129
|
+
const message = { role: "assistant", usage: { inputTokens: tokens, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } };
|
|
130
|
+
if (stopReason !== undefined)
|
|
131
|
+
message.stopReason = stopReason;
|
|
132
|
+
if (text)
|
|
133
|
+
message.content = text;
|
|
134
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
|
|
135
|
+
}
|
|
124
136
|
// ---- classifier unit tests (no extension harness needed) ----
|
|
125
137
|
test("S38: classifyError returns 'transient' for error stopReason", () => {
|
|
126
138
|
assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
|
|
@@ -252,14 +264,21 @@ test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", as
|
|
|
252
264
|
assert.equal(h.sendUserMessages.length, 0, "compaction-noop: no sendUserMessage");
|
|
253
265
|
assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
|
|
254
266
|
});
|
|
255
|
-
test("S38:
|
|
267
|
+
test("S38: R1 burst of immediate transient errors fires 1 nudge (dedup), rest suppressed by retryNudgePending", async () => {
|
|
268
|
+
// R1 redesign: a burst of immediate error turn_ends (no turn_start between)
|
|
269
|
+
// produces ONE nudge — the rest are suppressed by retryNudgePending because
|
|
270
|
+
// the queued nudge (deliverAs:'followUp') has not been consumed by a new
|
|
271
|
+
// agent turn. errorRetryCount still advances for each error turn, so the
|
|
272
|
+
// per-burst max + circuit breaker still bound the burst.
|
|
256
273
|
const h = harness();
|
|
257
274
|
for (let i = 0; i < 5; i++)
|
|
258
|
-
await s38TurnEnd(h, "error",
|
|
259
|
-
assert.equal(h.sendUserMessages.length,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
assert.
|
|
275
|
+
await s38TurnEnd(h, "error", `internal server error ${i}`);
|
|
276
|
+
assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
|
|
277
|
+
// The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
|
|
278
|
+
await s38TurnEnd(h, "error", "internal server error 5");
|
|
279
|
+
assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
|
|
280
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
|
|
281
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
|
|
263
282
|
});
|
|
264
283
|
test("S38: retry fires 1x for permanent errors then stops", async () => {
|
|
265
284
|
const h = harness();
|
|
@@ -270,17 +289,33 @@ test("S38: retry fires 1x for permanent errors then stops", async () => {
|
|
|
270
289
|
assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "permanent exhausted logged");
|
|
271
290
|
});
|
|
272
291
|
test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
292
|
+
// R1: backoff is now gating, so use a tiny backoff + small wait to let the
|
|
293
|
+
// second nudge fire after the success reset clears retryNudgePending (R4).
|
|
294
|
+
const prev = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
295
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
296
|
+
try {
|
|
297
|
+
const h = harness();
|
|
298
|
+
await s38TurnEnd(h, "error", "5xx server error 0");
|
|
299
|
+
assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
|
|
300
|
+
await s38TurnEnd(h, "stop");
|
|
301
|
+
assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending (R4)");
|
|
302
|
+
await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
|
|
303
|
+
await s38TurnEnd(h, "error", "5xx server error 1");
|
|
304
|
+
assert.equal(h.sendUserMessages.length, 2, "success reset -> transient fires again from count=1");
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
if (prev === undefined)
|
|
308
|
+
delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
309
|
+
else
|
|
310
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prev;
|
|
311
|
+
}
|
|
279
312
|
});
|
|
280
313
|
test("S38: error_retry_exhausted event logged when max exceeded", async () => {
|
|
314
|
+
// R3: "malformed bad request" is now poisoned-context (not permanent), so
|
|
315
|
+
// use an auth-derived permanent error to exercise the per-burst exhausted path.
|
|
281
316
|
const h = harness();
|
|
282
|
-
await s38TurnEnd(h, "error", "
|
|
283
|
-
await s38TurnEnd(h, "error", "
|
|
317
|
+
await s38TurnEnd(h, "error", "unauthorized: invalid api key");
|
|
318
|
+
await s38TurnEnd(h, "error", "unauthorized: invalid api key");
|
|
284
319
|
assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
|
|
285
320
|
});
|
|
286
321
|
test("S38: MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX=0 disables transient retries cleanly", async () => {
|
|
@@ -366,6 +401,213 @@ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", asy
|
|
|
366
401
|
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
|
|
367
402
|
}
|
|
368
403
|
});
|
|
404
|
+
// ---- R3 classifier unit tests: poisoned-context signals ----
|
|
405
|
+
test("R3 classifier: 0-token generic error (usage present, 0 tokens) → poisoned-context", () => {
|
|
406
|
+
// The 2026-07-28 incident: stopReason 'error' + usage 0 tokens. The turn
|
|
407
|
+
// never reached the model; retrying re-submits the same poisoned context.
|
|
408
|
+
assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
|
|
409
|
+
// Bare stopReason 'error' with NO usage field stays transient (unknown tokens
|
|
410
|
+
// — conservative; preserves the pre-R3 mid-response/partial-content behavior).
|
|
411
|
+
assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
|
|
412
|
+
});
|
|
413
|
+
test("R3 classifier: ECONNRESET (0-token) → transient (network failures stay transient)", () => {
|
|
414
|
+
// R3: network failures must stay transient even with 0 tokens.
|
|
415
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "ECONNRESET", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
416
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "connection reset by peer", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
417
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "timeout", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
418
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "503 service unavailable", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
|
|
419
|
+
});
|
|
420
|
+
test("R3 classifier: 'request failed' generic (no transient marker) → poisoned-context", () => {
|
|
421
|
+
// The exact incident phrasing: "Request failed — please retry." with no
|
|
422
|
+
// specific transient cause → deterministic rejection.
|
|
423
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "Request failed — please retry.", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
|
|
424
|
+
assert.equal(classifyErrorFn("request failed"), "poisoned-context");
|
|
425
|
+
});
|
|
426
|
+
test("R3 classifier: orphaned-tool-result 400 (non-overflow) → poisoned-context", () => {
|
|
427
|
+
// Provider request-validation 400 that is NOT context-overflow: orphaned
|
|
428
|
+
// tool result / malformed message structure. Previously 'permanent' (1
|
|
429
|
+
// retry), now 'poisoned-context' (retry re-submits the same malformed shape).
|
|
430
|
+
assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"orphaned tool result: tooluse ids mismatch"}'), "poisoned-context");
|
|
431
|
+
assert.equal(classifyErrorFn("invalid request: unexpected role ordering"), "poisoned-context");
|
|
432
|
+
assert.equal(classifyErrorFn("malformed message structure"), "poisoned-context");
|
|
433
|
+
});
|
|
434
|
+
test("R3 classifier: context-overflow phrasing still context-overflow (not poisoned)", () => {
|
|
435
|
+
// Regression guard: the context-overflow check runs BEFORE the poisoned
|
|
436
|
+
// signals, so a 400 "too long" stays context-overflow (forced re-compact),
|
|
437
|
+
// not poisoned (advise + compact).
|
|
438
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "Your conversation is too long for this model's context window even after compaction.", usage: { inputTokens: 0, outputTokens: 0 } }), "context-overflow");
|
|
439
|
+
assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"maximum context length is 200000 tokens. requires at least 201070 tokens."}'), "context-overflow");
|
|
440
|
+
});
|
|
441
|
+
test("R3 classifier: auth/permission stays permanent (not poisoned)", () => {
|
|
442
|
+
// Auth errors are retryable-once (permanent), not poisoned — the user can
|
|
443
|
+
// fix the key and retry.
|
|
444
|
+
assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
|
|
445
|
+
assert.equal(classifyErrorFn("permission denied"), "permanent");
|
|
446
|
+
});
|
|
447
|
+
// ---- R6 integration tests (retry redesign) ----
|
|
448
|
+
test("R6(a): 10 consecutive identical 0-token transient failures produce at most errorRetrySessionMax nudges", async () => {
|
|
449
|
+
// Use TRANSIENT 0-token failures (network text so they're transient, not
|
|
450
|
+
// poisoned) + turn_start + tiny backoff between each so the nudge is
|
|
451
|
+
// consumed and the next turn can fire. sessionMax default = 3. Repeat
|
|
452
|
+
// threshold raised to disable the stateful poisoned upgrade so this
|
|
453
|
+
// exercises the SESSION CAP, not the repeat signal.
|
|
454
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
455
|
+
const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
456
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
457
|
+
process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
|
|
458
|
+
try {
|
|
459
|
+
const h = harness();
|
|
460
|
+
for (let i = 0; i < 10; i++) {
|
|
461
|
+
// 0-token transient: usage present with 0 tokens + "connection reset" (network marker).
|
|
462
|
+
await s38TurnEndUsage(h, "error", "connection reset", 0);
|
|
463
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
|
|
464
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
465
|
+
}
|
|
466
|
+
assert.ok(h.sendUserMessages.length <= 3, `R6(a): at most sessionMax (3) nudges, got ${h.sendUserMessages.length}`);
|
|
467
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_session_exhausted"), "session_exhausted event logged");
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
if (prevBackoff === undefined)
|
|
471
|
+
delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
472
|
+
else
|
|
473
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
474
|
+
if (prevRepeat === undefined)
|
|
475
|
+
delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
476
|
+
else
|
|
477
|
+
process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
test("R6(b): poisoned-context fires zero retry nudges and exactly one advise message", async () => {
|
|
481
|
+
// auto=false so the guarded compact attempt (R3c) is skipped — this test
|
|
482
|
+
// focuses on the advise + no-retry behavior. The compact path is the same
|
|
483
|
+
// race-guarded deferred mechanism already covered by the context-overflow tests.
|
|
484
|
+
const prevAuto = process.env.MEGACOMPACT_AUTO;
|
|
485
|
+
process.env.MEGACOMPACT_AUTO = "false";
|
|
486
|
+
try {
|
|
487
|
+
const h = harness();
|
|
488
|
+
// 0-token generic "request failed" (no transient marker) → poisoned.
|
|
489
|
+
await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
|
|
490
|
+
assert.equal(h.sendUserMessages.length, 1, "poisoned: exactly one advise message");
|
|
491
|
+
assert.ok(h.sendUserMessages[0].includes("/clear") || h.sendUserMessages[0].includes("/new"), "advise mentions /clear or /new");
|
|
492
|
+
assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "poisoned_context event logged");
|
|
493
|
+
assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "poisoned: zero retry nudges (no error_retry event)");
|
|
494
|
+
// Second poisoned turn: advise throttled to one per session.
|
|
495
|
+
await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
|
|
496
|
+
assert.equal(h.sendUserMessages.length, 1, "poisoned: advise throttled (one per session)");
|
|
497
|
+
assert.ok(eventTypes(h.stateDir).filter((t) => t === "poisoned_context").length >= 2, "poisoned_context logged each turn");
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
if (prevAuto === undefined)
|
|
501
|
+
delete process.env.MEGACOMPACT_AUTO;
|
|
502
|
+
else
|
|
503
|
+
process.env.MEGACOMPACT_AUTO = prevAuto;
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
test("R6(c): transient burst retries with backoff gating — second immediate nudge suppressed while one pending", async () => {
|
|
507
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
508
|
+
const prevSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
|
|
509
|
+
const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
510
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
511
|
+
process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999"; // don't let session cap bind
|
|
512
|
+
process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999"; // don't let repeat upgrade bind
|
|
513
|
+
try {
|
|
514
|
+
const h = harness();
|
|
515
|
+
// Turn 1: transient → nudge 1 fires (pending=true, backoff=1ms).
|
|
516
|
+
await s38TurnEnd(h, "error", "internal server error 0");
|
|
517
|
+
assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
|
|
518
|
+
// Turn 2: immediate (no turn_start) → suppressed by retryNudgePending.
|
|
519
|
+
await s38TurnEnd(h, "error", "internal server error 1");
|
|
520
|
+
assert.equal(h.sendUserMessages.length, 1, "second immediate nudge suppressed (retryNudgePending)");
|
|
521
|
+
// turn_start consumes the pending nudge (resets pending + count).
|
|
522
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
|
|
523
|
+
await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
|
|
524
|
+
// Turn 3: transient → nudge 2 fires (pending cleared, backoff elapsed).
|
|
525
|
+
await s38TurnEnd(h, "error", "internal server error 2");
|
|
526
|
+
assert.equal(h.sendUserMessages.length, 2, "after turn_start + backoff: nudge fires");
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
if (prevBackoff === undefined)
|
|
530
|
+
delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
531
|
+
else
|
|
532
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
533
|
+
if (prevSession === undefined)
|
|
534
|
+
delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
|
|
535
|
+
else
|
|
536
|
+
process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = prevSession;
|
|
537
|
+
if (prevRepeat === undefined)
|
|
538
|
+
delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
|
|
539
|
+
else
|
|
540
|
+
process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
test("R6(d): user abort (stopReason aborted) never nudges, even across repeated aborts", async () => {
|
|
544
|
+
const h = harness();
|
|
545
|
+
await s38TurnEnd(h, "aborted", "Operation aborted");
|
|
546
|
+
assert.equal(h.sendUserMessages.length, 0, "aborted: no nudge");
|
|
547
|
+
await s38TurnEnd(h, "aborted", "Aborted after 3 retry attempts");
|
|
548
|
+
assert.equal(h.sendUserMessages.length, 0, "aborted: still no nudge after repeated aborts");
|
|
549
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_cancelled"), "cancelled event logged");
|
|
550
|
+
});
|
|
551
|
+
test("R6(e): success resets retry-nudge-pending state", async () => {
|
|
552
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
553
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
554
|
+
try {
|
|
555
|
+
const h = harness();
|
|
556
|
+
// Turn 1: transient → nudge fires, pending=true.
|
|
557
|
+
await s38TurnEnd(h, "error", "internal server error 0");
|
|
558
|
+
assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
|
|
559
|
+
// Turn 2: immediate transient → suppressed by pending (no turn_start).
|
|
560
|
+
await s38TurnEnd(h, "error", "internal server error 1");
|
|
561
|
+
assert.equal(h.sendUserMessages.length, 1, "second immediate suppressed by pending");
|
|
562
|
+
// Turn 3: success (stop) → resets pending (R4), no nudge.
|
|
563
|
+
await s38TurnEnd(h, "stop");
|
|
564
|
+
assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending");
|
|
565
|
+
await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
|
|
566
|
+
// Turn 4: transient → nudge fires again (pending was reset by success).
|
|
567
|
+
await s38TurnEnd(h, "error", "internal server error 2");
|
|
568
|
+
assert.equal(h.sendUserMessages.length, 2, "after success reset pending: transient nudge fires");
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
if (prevBackoff === undefined)
|
|
572
|
+
delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
573
|
+
else
|
|
574
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
test("R3: repeated identical transient error text upgrades to poisoned-context at threshold", async () => {
|
|
578
|
+
// The stateful repeat signal: 3 consecutive identical transient errors
|
|
579
|
+
// (default threshold) upgrade to poisoned. Uses "5xx server error" (5xx
|
|
580
|
+
// marker → transient) so the classifier returns transient, then the repeat
|
|
581
|
+
// tracker upgrades it. auto=false to skip the compact attempt.
|
|
582
|
+
const prevAuto = process.env.MEGACOMPACT_AUTO;
|
|
583
|
+
const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
584
|
+
process.env.MEGACOMPACT_AUTO = "false";
|
|
585
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
|
|
586
|
+
try {
|
|
587
|
+
const h = harness();
|
|
588
|
+
// Turns 1-2: transient (repeatCount 1, 2) → nudges fire.
|
|
589
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
590
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
|
|
591
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
592
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
593
|
+
await h.fire("turn_start", { type: "turn_start", turnIndex: 3 }, h.ctx());
|
|
594
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
595
|
+
// Turn 3: repeatCount=3 ≥ threshold → upgraded to poisoned. No nudge, advise fires.
|
|
596
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
597
|
+
assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "repeat threshold reached: poisoned_context event logged");
|
|
598
|
+
assert.ok(h.sendUserMessages.some((m) => m.includes("/clear") || m.includes("/new")), "repeat threshold: advise message fired");
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
if (prevAuto === undefined)
|
|
602
|
+
delete process.env.MEGACOMPACT_AUTO;
|
|
603
|
+
else
|
|
604
|
+
process.env.MEGACOMPACT_AUTO = prevAuto;
|
|
605
|
+
if (prevBackoff === undefined)
|
|
606
|
+
delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
|
|
607
|
+
else
|
|
608
|
+
process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
|
|
609
|
+
}
|
|
610
|
+
});
|
|
369
611
|
test("cleanup", async () => {
|
|
370
612
|
// PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
|
|
371
613
|
try {
|
|
@@ -373,4 +615,11 @@ test("cleanup", async () => {
|
|
|
373
615
|
}
|
|
374
616
|
catch { /* ignore */ }
|
|
375
617
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
618
|
+
// Force-exit: each harness() creates a MegaRuntime with an fs.watch
|
|
619
|
+
// game-state watcher that is never disposed (no session_shutdown in tests).
|
|
620
|
+
// Those handles keep the event loop alive indefinitely after all tests
|
|
621
|
+
// complete, so `node --test` (without --test-force-exit) would hang. The
|
|
622
|
+
// streaming reporter has already printed every test result by this point;
|
|
623
|
+
// process.exit(0) just forces the exit the watchers are preventing.
|
|
624
|
+
process.exit(0);
|
|
376
625
|
});
|
|
@@ -49,6 +49,21 @@ export default function (pi) {
|
|
|
49
49
|
console.warn('[mega-compact] MEGACOMPACT_MAX_CONSECUTIVE_ERRORS must be >= 1; using default 10');
|
|
50
50
|
config.maxConsecutiveErrors = 10;
|
|
51
51
|
}
|
|
52
|
+
// R2: session-global cap validation — 0 disables (valid), negative is invalid.
|
|
53
|
+
if (config.errorRetrySessionMax < 0) {
|
|
54
|
+
console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_SESSION_MAX must be >= 0; using default 3');
|
|
55
|
+
config.errorRetrySessionMax = 3;
|
|
56
|
+
}
|
|
57
|
+
// R1: backoff base must be >= 0 (0 means no gating, useful for tests).
|
|
58
|
+
if (config.errorRetryBackoffMs < 0) {
|
|
59
|
+
console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_BACKOFF_MS must be >= 0; using default 5000');
|
|
60
|
+
config.errorRetryBackoffMs = 5000;
|
|
61
|
+
}
|
|
62
|
+
// R3: repeat threshold must be >= 1.
|
|
63
|
+
if (config.poisonedContextRepeatThreshold < 1) {
|
|
64
|
+
console.warn('[mega-compact] MEGACOMPACT_POISONED_REPEAT_THRESHOLD must be >= 1; using default 3');
|
|
65
|
+
config.poisonedContextRepeatThreshold = 3;
|
|
66
|
+
}
|
|
52
67
|
const runtime = new MegaRuntime(config);
|
|
53
68
|
registerEventHandlers(pi, runtime, config);
|
|
54
69
|
registerCommands(pi, runtime, config);
|
|
@@ -145,6 +145,9 @@ export function loadConfig() {
|
|
|
145
145
|
raceGuardStrict: envBool("MEGACOMPACT_RACE_GUARD_STRICT", true),
|
|
146
146
|
maxConsecutiveErrors: envFlag("MEGACOMPACT_MAX_CONSECUTIVE_ERRORS", 10),
|
|
147
147
|
errorRetryHardStop: envBool("MEGACOMPACT_ERROR_RETRY_HARD_STOP", false),
|
|
148
|
+
errorRetryBackoffMs: envFlag("MEGACOMPACT_ERROR_RETRY_BACKOFF_MS", 5000),
|
|
149
|
+
errorRetrySessionMax: envFlag("MEGACOMPACT_ERROR_RETRY_SESSION_MAX", 3),
|
|
150
|
+
poisonedContextRepeatThreshold: envFlag("MEGACOMPACT_POISONED_REPEAT_THRESHOLD", 3),
|
|
148
151
|
autoPctTrigger,
|
|
149
152
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
150
153
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|