pi-mega-compact 0.8.24 → 0.8.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -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/dashboard-snapshot.js +4 -0
- package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
- package/dist/extensions/mega-runtime/runtime.js +58 -5
- 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/raptor-inject-summaries.test.js +162 -0
- package/dist/src/recall.js +153 -24
- 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 +179 -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/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/dashboard-snapshot.ts +8 -0
- package/extensions/mega-runtime/helpers.ts +25 -1
- package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
- package/extensions/mega-runtime/runtime.ts +69 -23
- 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 +228 -0
- package/src/recall.test.ts +220 -4
- package/src/recall.ts +462 -265
- 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 +302 -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/extensions/mega-runtime/reset-runtime.ts +0 -80
|
@@ -14,10 +14,13 @@ import { cosineSimilarity } from "./embedder.js";
|
|
|
14
14
|
import { normalizeSessionId } from "./store.js";
|
|
15
15
|
import { mmrRerank } from "./dedup/mmr.js";
|
|
16
16
|
import { topK } from "./dedup/topk.js";
|
|
17
|
-
import { listCheckpoints, getCheckpoint, maxCheckpointTimestamp, } from "./store/sqlite.js";
|
|
17
|
+
import { listCheckpoints, getCheckpoint, maxCheckpointTimestamp, maxRaptorNodeBuiltAt, } from "./store/sqlite.js";
|
|
18
18
|
import { initVectorIndex, searchAsync as vectorIndexSearch, } from "./store/vectorIndex.js";
|
|
19
19
|
import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
|
|
20
20
|
import { stagedExpansion } from "./dedup/raptor/retrieval.js";
|
|
21
|
+
import { multilevelRetrieval } from "./dedup/raptor/multilevel.js";
|
|
22
|
+
import { Logger } from "./log.js";
|
|
23
|
+
const logger = new Logger();
|
|
21
24
|
// ---------------------------------------------------------------------------
|
|
22
25
|
// raptorSearchHits — internal helper (NOT exported)
|
|
23
26
|
// ---------------------------------------------------------------------------
|
|
@@ -25,8 +28,13 @@ import { stagedExpansion } from "./dedup/raptor/retrieval.js";
|
|
|
25
28
|
* Serve the RAPTOR tree for a query (Fix D): rehydrate the persisted tree and
|
|
26
29
|
* return its staged-expansion leaf hits as SearchHits. Returns [] when no tree
|
|
27
30
|
* exists (small sessions — flat search remains the path). Best-effort/non-fatal.
|
|
31
|
+
*
|
|
32
|
+
* `checkpoints` is the caller's already-loaded, dedup-status-filtered checkpoint
|
|
33
|
+
* list — pass it in so the flat-search path and the RAPTOR merge path share ONE
|
|
34
|
+
* `listCheckpoints` call per `vectorSearch` (eliminates a redundant full scan +
|
|
35
|
+
* N-row hydration at 500+ checkpoints — QA perf review).
|
|
28
36
|
*/
|
|
29
|
-
function raptorSearchHits(store, sid, query, k) {
|
|
37
|
+
function raptorSearchHits(store, sid, query, k, checkpoints) {
|
|
30
38
|
const t0 = Date.now();
|
|
31
39
|
try {
|
|
32
40
|
const stateDir = store.stateDir;
|
|
@@ -38,17 +46,93 @@ function raptorSearchHits(store, sid, query, k) {
|
|
|
38
46
|
// RAPTOR_SHADOW_MODE is anything other than "false".
|
|
39
47
|
if (isShadowMode())
|
|
40
48
|
return [];
|
|
41
|
-
|
|
49
|
+
// S25 gate (b): freshness + fallback guards with per-session cache.
|
|
50
|
+
// The cache avoids O(n·leaves) rehydrate on every search when the tree
|
|
51
|
+
// hasn't changed. Freshness is validated via maxRaptorNodeBuiltAt (cheap
|
|
52
|
+
// indexed MAX query) — if the persisted builtAt matches the cached builtAt,
|
|
53
|
+
// the tree hasn't been rebuilt and can be served from cache.
|
|
54
|
+
const cacheKey = `${stateDir}::${sid}`;
|
|
55
|
+
const cached = store.raptorCache.get(cacheKey);
|
|
56
|
+
const latestBuiltAt = maxRaptorNodeBuiltAt(sid, stateDir);
|
|
57
|
+
let tree;
|
|
58
|
+
if (cached && cached.builtAt === latestBuiltAt && latestBuiltAt > 0) {
|
|
59
|
+
tree = cached.tree;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
tree = rehydrateRaptorTree(sid, stateDir);
|
|
63
|
+
if (tree && tree.rootId && !tree.timedOut) {
|
|
64
|
+
store.raptorCache.set(cacheKey, { tree, builtAt: tree.builtAt ?? 0 });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
store.raptorCache.delete(cacheKey);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
42
70
|
if (!tree || !tree.rootId)
|
|
43
71
|
return [];
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// whose root is a budget-exhausted extractive fallback (level 99).
|
|
47
|
-
if (tree.timedOut)
|
|
48
|
-
return [];
|
|
72
|
+
// Freshness: skip persisted tree if the session has been compacted since
|
|
73
|
+
// the tree was built. The next runRaptor will rebuild.
|
|
49
74
|
const maxTs = maxCheckpointTimestamp(sid, stateDir);
|
|
50
75
|
if (tree.builtAt && tree.builtAt < maxTs)
|
|
51
76
|
return [];
|
|
77
|
+
// S25: skip timedOut extractive-fallback trees (level === 99).
|
|
78
|
+
if (tree.timedOut)
|
|
79
|
+
return [];
|
|
80
|
+
// Use the caller's already-loaded checkpoint list (no second scan).
|
|
81
|
+
const all = checkpoints;
|
|
82
|
+
const qv = embedder.embed(query);
|
|
83
|
+
const hits = [];
|
|
84
|
+
// S42B: multi-level retrieval — score all tree levels (leaves + internal
|
|
85
|
+
// clusters), expand cluster hits to their leaf descendants, dedup overlaps,
|
|
86
|
+
// and MMR-rerank. Default ON (RAPTOR_MULTILEVEL_ENABLED). When OFF, the
|
|
87
|
+
// leaf-only stagedExpansion path below is used (identical to pre-S42B).
|
|
88
|
+
if (cfg.RAPTOR_MULTILEVEL_ENABLED) {
|
|
89
|
+
const mlHits = multilevelRetrieval(query, tree, {
|
|
90
|
+
embedder,
|
|
91
|
+
levelWeights: cfg.RAPTOR_LEVEL_WEIGHTS,
|
|
92
|
+
leafExpansion: cfg.RAPTOR_LEAF_EXPANSION,
|
|
93
|
+
maxLeafExpansion: cfg.RAPTOR_MAX_LEAF_EXPANSION,
|
|
94
|
+
k,
|
|
95
|
+
mmrLambda: cfg.MMR_LAMBDA,
|
|
96
|
+
});
|
|
97
|
+
if (mlHits.length === 0)
|
|
98
|
+
return [];
|
|
99
|
+
for (const mh of mlHits) {
|
|
100
|
+
// Leaf hits: nodeId IS the checkpointId — hydrate from the stored list.
|
|
101
|
+
const cp = all.find((c) => c.checkpointId === mh.nodeId);
|
|
102
|
+
if (cp) {
|
|
103
|
+
hits.push({ checkpoint: cp, score: mh.score });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Orphaned leaf (checkpoint missing from the stored list, e.g. SemDeDup
|
|
107
|
+
// removed) — skip; synthesizing it would yield an empty cluster block.
|
|
108
|
+
if (mh.isLeaf)
|
|
109
|
+
continue;
|
|
110
|
+
// Cluster hit: synthesize a SearchHit so the recall block can surface
|
|
111
|
+
// the hierarchical summary (not a stored checkpoint). The minimal
|
|
112
|
+
// StoredCheckpoint carries the node's centroid embedding for any
|
|
113
|
+
// downstream cosine / MMR the caller does.
|
|
114
|
+
hits.push({
|
|
115
|
+
checkpoint: {
|
|
116
|
+
checkpointId: mh.nodeId,
|
|
117
|
+
sessionId: sid,
|
|
118
|
+
summary: mh.summary,
|
|
119
|
+
keyDecisions: [],
|
|
120
|
+
nextSteps: [],
|
|
121
|
+
filesModified: [],
|
|
122
|
+
tokenEstimate: 0,
|
|
123
|
+
regionHash: `raptor:${mh.nodeId}`,
|
|
124
|
+
embedding: mh.embedding,
|
|
125
|
+
timestamp: tree.builtAt ?? Date.now(),
|
|
126
|
+
},
|
|
127
|
+
score: mh.score,
|
|
128
|
+
raptorSummary: mh.summary,
|
|
129
|
+
raptorLevel: mh.level,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
record("RAPTOR", hits.length > 0 ? "new" : "mark_only", `ml=${mlHits.length}`, Date.now() - t0);
|
|
133
|
+
return hits;
|
|
134
|
+
}
|
|
135
|
+
// S42B: flag OFF — leaf-only stagedExpansion (pre-S42B production path).
|
|
52
136
|
const leafIds = stagedExpansion(query, tree, {
|
|
53
137
|
embedder,
|
|
54
138
|
k,
|
|
@@ -57,20 +141,24 @@ function raptorSearchHits(store, sid, query, k) {
|
|
|
57
141
|
});
|
|
58
142
|
if (leafIds.length === 0)
|
|
59
143
|
return [];
|
|
60
|
-
const all = listCheckpoints(sid, stateDir).filter((cp) => cp.dedupStatus !== "removed");
|
|
61
|
-
const qv = embedder.embed(query);
|
|
62
|
-
const hits = [];
|
|
63
144
|
for (const id of leafIds) {
|
|
64
145
|
const cp = all.find((c) => c.checkpointId === id);
|
|
65
146
|
if (cp)
|
|
66
|
-
hits.push({
|
|
147
|
+
hits.push({
|
|
148
|
+
checkpoint: cp,
|
|
149
|
+
score: cosineSimilarity(qv, cp.embedding),
|
|
150
|
+
});
|
|
67
151
|
}
|
|
68
152
|
// S25 monitoring: emit a raptor_serve decision so canary.ts can track
|
|
69
153
|
// p95 latency + the tier's live traffic (non-fatal, best-effort).
|
|
70
154
|
record("RAPTOR", hits.length > 0 ? "new" : "mark_only", `leaves=${leafIds.length}`, Date.now() - t0);
|
|
71
155
|
return hits;
|
|
72
156
|
}
|
|
73
|
-
catch {
|
|
157
|
+
catch (e) {
|
|
158
|
+
logger.warn("raptor_search_error", {
|
|
159
|
+
error: String(e),
|
|
160
|
+
sessionId: sid,
|
|
161
|
+
});
|
|
74
162
|
return [];
|
|
75
163
|
}
|
|
76
164
|
}
|
|
@@ -109,7 +197,7 @@ export function vectorSearch(store, sessionId, query, k = 3) {
|
|
|
109
197
|
// RAPTOR returns fewer, broader hits (O(log n) high-level nodes) than the
|
|
110
198
|
// O(n) flat leaves, tightening the block at read time.
|
|
111
199
|
if (cfg.RAPTOR_ENABLED) {
|
|
112
|
-
const rh = raptorSearchHits(store, sid, query, k);
|
|
200
|
+
const rh = raptorSearchHits(store, sid, query, k, checkpoints);
|
|
113
201
|
if (rh.length > 0) {
|
|
114
202
|
const merged = [...window];
|
|
115
203
|
for (const h of rh) {
|
|
@@ -176,7 +264,11 @@ export async function vectorSearchAsync(store, sessionId, query, k = 3, opts = {
|
|
|
176
264
|
const cp = getCheckpoint(h.sessionId, h.checkpointId, h.repoId);
|
|
177
265
|
if (cp && cp.dedupStatus !== "removed") {
|
|
178
266
|
const crossRepo = opts.crossRepo && selfRepo && h.repoId && h.repoId !== selfRepo;
|
|
179
|
-
hydrated.push({
|
|
267
|
+
hydrated.push({
|
|
268
|
+
checkpoint: cp,
|
|
269
|
+
score: h.score,
|
|
270
|
+
repoId: crossRepo ? h.repoId : undefined,
|
|
271
|
+
});
|
|
180
272
|
}
|
|
181
273
|
}
|
|
182
274
|
if (hydrated.length === 0)
|
package/dist/src/vectorStore.js
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
|
|
12
|
-
import { loadDedupConfig } from "./config/dedup.js";
|
|
12
|
+
import { loadDedupConfig, } from "./config/dedup.js";
|
|
13
13
|
import { logDecision } from "./monitoring.js";
|
|
14
14
|
import { getStateDir, normalizeSessionId, compressSmart } from "./store.js";
|
|
15
15
|
import { computeContentDigest } from "./dedup/digest.js";
|
|
16
|
-
import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "./dedup/l1-minhash.js";
|
|
16
|
+
import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES, } from "./dedup/l1-minhash.js";
|
|
17
17
|
import { lshBands } from "./dedup/l1-lsh.js";
|
|
18
18
|
import { isNearDuplicate } from "./dedup/l1-verify.js";
|
|
19
19
|
import { openBloom, saveBloom } from "./store/bloom.js";
|
|
@@ -47,6 +47,10 @@ export class VectorStore {
|
|
|
47
47
|
* global index keys on repoId so recall can span repos.
|
|
48
48
|
*/
|
|
49
49
|
repoId;
|
|
50
|
+
/** S25: per-session cached rehydrated RaptorTree. Keyed by sessionId.
|
|
51
|
+
* Freshness-validated via maxRaptorNodeBuiltAt on each lookup — a cheap
|
|
52
|
+
* indexed MAX query replaces the O(n·leaves) rehydrate on every search. */
|
|
53
|
+
raptorCache = new Map();
|
|
50
54
|
constructor(opts = {}) {
|
|
51
55
|
this.embedder = opts.embedder ?? defaultEmbedder();
|
|
52
56
|
this.stateDir = opts.stateDir ?? getStateDir();
|
|
@@ -125,7 +129,11 @@ export class VectorStore {
|
|
|
125
129
|
bumpDedupStats(true, this.stateDir);
|
|
126
130
|
// Deduped: whole original region discarded, nothing new stored.
|
|
127
131
|
addTokensSaved(origTokens, this.stateDir);
|
|
128
|
-
const r = {
|
|
132
|
+
const r = {
|
|
133
|
+
checkpoint: contentMatch,
|
|
134
|
+
deduped: true,
|
|
135
|
+
reason: "contentHash",
|
|
136
|
+
};
|
|
129
137
|
this.record("L0", "deduped", "contentHash", Date.now() - t0);
|
|
130
138
|
onTier?.({ tier: "L0", status: "deduped", detail: "contentHash" });
|
|
131
139
|
return r;
|
|
@@ -143,7 +151,11 @@ export class VectorStore {
|
|
|
143
151
|
bumpDedupStats(true, this.stateDir);
|
|
144
152
|
// Deduped: whole original region discarded, nothing new stored.
|
|
145
153
|
addTokensSaved(origTokens, this.stateDir);
|
|
146
|
-
const r = {
|
|
154
|
+
const r = {
|
|
155
|
+
checkpoint: regionMatch,
|
|
156
|
+
deduped: true,
|
|
157
|
+
reason: "regionHash",
|
|
158
|
+
};
|
|
147
159
|
this.record("L0", "deduped", "regionHash", Date.now() - t0);
|
|
148
160
|
onTier?.({ tier: "L0", status: "deduped", detail: "regionHash" });
|
|
149
161
|
return r;
|
|
@@ -167,7 +179,11 @@ export class VectorStore {
|
|
|
167
179
|
bumpDedupStats(true, this.stateDir);
|
|
168
180
|
// Deduped: whole original region discarded, nothing new stored.
|
|
169
181
|
addTokensSaved(origTokens, this.stateDir);
|
|
170
|
-
const r = {
|
|
182
|
+
const r = {
|
|
183
|
+
checkpoint: summaryMatch,
|
|
184
|
+
deduped: true,
|
|
185
|
+
reason: "summaryHash",
|
|
186
|
+
};
|
|
171
187
|
this.record("L0", "deduped", "summaryHash", Date.now() - t0);
|
|
172
188
|
onTier?.({ tier: "L0", status: "deduped", detail: "summaryHash" });
|
|
173
189
|
return r;
|
|
@@ -226,14 +242,26 @@ export class VectorStore {
|
|
|
226
242
|
bumpDedupStats(true, this.stateDir);
|
|
227
243
|
// Deduped: whole original region discarded, nothing new stored.
|
|
228
244
|
addTokensSaved(origTokens, this.stateDir);
|
|
229
|
-
const r = {
|
|
245
|
+
const r = {
|
|
246
|
+
checkpoint: nearest.checkpoint,
|
|
247
|
+
deduped: true,
|
|
248
|
+
reason: "contentSimilarity",
|
|
249
|
+
};
|
|
230
250
|
this.record("L2", "deduped", "contentSimilarity", Date.now() - t0);
|
|
231
|
-
onTier?.({
|
|
251
|
+
onTier?.({
|
|
252
|
+
tier: "L2",
|
|
253
|
+
status: "deduped",
|
|
254
|
+
detail: nearest.sim.toFixed(2),
|
|
255
|
+
});
|
|
232
256
|
return r;
|
|
233
257
|
}
|
|
234
258
|
markOnly = "L2";
|
|
235
259
|
}
|
|
236
|
-
onTier?.({
|
|
260
|
+
onTier?.({
|
|
261
|
+
tier: "L2",
|
|
262
|
+
status: "passed",
|
|
263
|
+
detail: `best ${nearest.sim.toFixed(2)}`,
|
|
264
|
+
});
|
|
237
265
|
}
|
|
238
266
|
// 4. Genuinely new — create checkpoint
|
|
239
267
|
const checkpointId = nextCheckpointId(sessionId, this.stateDir);
|
|
@@ -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"),
|