mcp-castor 2026.3.0
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 +487 -0
- package/bin/castor.js +706 -0
- package/index.js +206 -0
- package/package.json +97 -0
- package/skills/canary-test-staging/SKILL.md +24 -0
- package/skills/evo-mutation-rollback/SKILL.md +29 -0
- package/skills/hypothesis-generation/SKILL.md +26 -0
- package/skills/traceback-condensing/SKILL.md +26 -0
- package/src/castor_runner.js +469 -0
- package/src/config.js +1204 -0
- package/src/env.js +10 -0
- package/src/evo_engine.js +214 -0
- package/src/harness/core/events.js +75 -0
- package/src/harness/core/kernel.js +209 -0
- package/src/harness/evo/evaluator.js +156 -0
- package/src/harness/evo/evo_operator.js +550 -0
- package/src/harness/evo/lineage_dag.js +383 -0
- package/src/harness/evo/trace_repair.js +173 -0
- package/src/harness/evo/watchdog.js +72 -0
- package/src/harness/loop_detector.js +135 -0
- package/src/harness/runner.js +1216 -0
- package/src/harness/services/ast_service.js +1813 -0
- package/src/harness/services/event_logger.js +275 -0
- package/src/harness/services/mcp_bridge.js +408 -0
- package/src/harness/services/provider_vllm.js +728 -0
- package/src/harness/services/sandbox_fs.js +1238 -0
- package/src/harness/services/searxng_lifecycle.js +254 -0
- package/src/harness/services/shell_executor.js +264 -0
- package/src/harness/services/shell_validator.js +506 -0
- package/src/harness/services/web_service.js +828 -0
- package/src/platform.js +344 -0
- package/src/repetition_detector.js +139 -0
- package/src/semaphore.js +373 -0
- package/src/server_lifecycle.js +781 -0
- package/src/skills.js +400 -0
- package/src/state_pruner.js +392 -0
- package/src/task_registry.js +1357 -0
- package/src/telemetry.js +638 -0
- package/src/tools.js +997 -0
- package/src/wsl_bridge.js +629 -0
- package/src/wsl_env.js +171 -0
- package/stream_proxy.js +453 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lineage DAG Service (Evo Candidate Graph).
|
|
3
|
+
*
|
|
4
|
+
* Tracks the tree of evolutionary variations, mutation hypotheses, test
|
|
5
|
+
* outcomes, and fitness scores in `.evo/lineage.json`.
|
|
6
|
+
*
|
|
7
|
+
* - `LineageDag` is a per-workspace singleton: `forWorkspace(root)` returns one
|
|
8
|
+
* shared instance per canonical workspace root, and `persist()` is a
|
|
9
|
+
* read-modify-write merge (re-read on-disk, union by node id, atomic
|
|
10
|
+
* tmp+rename) so concurrent writers' nodes all survive.
|
|
11
|
+
* - The file's `version` field is gated on load: a file carrying an unknown or
|
|
12
|
+
* future version throws `LineageVersionError` (naming both versions) instead
|
|
13
|
+
* of being parsed.
|
|
14
|
+
* - The legacy `candidates` array is migrated to nodes; unknown/missing
|
|
15
|
+
* statuses map to "neutral".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { canonicalizePath } from "../../wsl_bridge.js";
|
|
21
|
+
|
|
22
|
+
// The lineage file format version this build writes and understands.
|
|
23
|
+
export const LINEAGE_VERSION = "2026.1";
|
|
24
|
+
// Every version this build can safely parse. A file carrying any other
|
|
25
|
+
// version (unknown or future) is refused on load (fail-fast, no silent
|
|
26
|
+
// migration).
|
|
27
|
+
export const KNOWN_LINEAGE_VERSIONS = new Set([LINEAGE_VERSION]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Thrown when a lineage.json carries a version this build does not know.
|
|
31
|
+
* The message names BOTH the file's version and the current version so the
|
|
32
|
+
* mismatch is unambiguous.
|
|
33
|
+
*/
|
|
34
|
+
export class LineageVersionError extends Error {
|
|
35
|
+
constructor(fileVersion, currentVersion, file) {
|
|
36
|
+
super(
|
|
37
|
+
`LineageVersionError: ${file} declares version '${fileVersion}' but this build ` +
|
|
38
|
+
`only knows ${JSON.stringify([...KNOWN_LINEAGE_VERSIONS])} (current '${currentVersion}'). ` +
|
|
39
|
+
`Refusing to parse an unknown/future lineage format.`
|
|
40
|
+
);
|
|
41
|
+
this.name = "LineageVersionError";
|
|
42
|
+
this.fileVersion = fileVersion;
|
|
43
|
+
this.currentVersion = currentVersion;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Maps a (possibly legacy) status string to the canonical lowercase status.
|
|
49
|
+
* Unknown/missing statuses map to "neutral".
|
|
50
|
+
*/
|
|
51
|
+
function mapLegacyStatus(status) {
|
|
52
|
+
const s = typeof status === "string" ? status.trim().toLowerCase() : "";
|
|
53
|
+
if (s === "accepted") return "accepted";
|
|
54
|
+
if (s === "rejected") return "rejected";
|
|
55
|
+
if (s === "pending") return "pending";
|
|
56
|
+
if (s === "neutral") return "neutral";
|
|
57
|
+
// Unknown / missing / legacy "NEUTRAL" / anything else: neutral.
|
|
58
|
+
return "neutral";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The "last-modified" time of a node, used for per-node recency in the
|
|
63
|
+
* read-modify-write merge. ISO-8601 timestamps sort lexicographically, so a
|
|
64
|
+
* plain string comparison is a correct recency comparison.
|
|
65
|
+
*/
|
|
66
|
+
function nodeTime(n) {
|
|
67
|
+
return (n && (n.updatedAt || n.timestamp)) || "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One LineageDag instance per canonical workspace root, per process.
|
|
71
|
+
const _instances = new Map();
|
|
72
|
+
|
|
73
|
+
export class LineageDag {
|
|
74
|
+
/**
|
|
75
|
+
* Returns the shared LineageDag for a workspace root, creating it on first
|
|
76
|
+
* use. The key is the canonical (realpath-resolved) root so that a
|
|
77
|
+
* junction/symlink form and its real form collapse to the same instance.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} root workspace root (any form; canonicalized internally)
|
|
80
|
+
* @returns {LineageDag}
|
|
81
|
+
*/
|
|
82
|
+
static forWorkspace(root) {
|
|
83
|
+
const key = canonicalizePath(root || process.cwd());
|
|
84
|
+
let inst = _instances.get(key);
|
|
85
|
+
if (!inst) {
|
|
86
|
+
inst = new LineageDag({ workspaceRoot: key });
|
|
87
|
+
_instances.set(key, inst);
|
|
88
|
+
}
|
|
89
|
+
return inst;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
// Canonicalize the workspace root through the OS symlink/junction layer
|
|
94
|
+
// so the .evo/ directory (lineage.json, snapshots) is anchored to the real
|
|
95
|
+
// path even when the process was launched through a junction.
|
|
96
|
+
this.workspaceRoot = canonicalizePath(options.workspaceRoot || process.cwd());
|
|
97
|
+
this.evoDir = path.join(this.workspaceRoot, ".evo");
|
|
98
|
+
this.dagFile = path.join(this.evoDir, "lineage.json");
|
|
99
|
+
this.nodes = new Map();
|
|
100
|
+
this.rootId = null;
|
|
101
|
+
this.currentHeadId = null;
|
|
102
|
+
|
|
103
|
+
this._load();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_ensureEvoDir() {
|
|
107
|
+
try {
|
|
108
|
+
fs.mkdirSync(this.evoDir, { recursive: true });
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reads and parses the on-disk lineage file, applying the version gate.
|
|
114
|
+
*
|
|
115
|
+
* @returns {object|null} parsed data, or null when the file is absent or a
|
|
116
|
+
* transient lock (EBUSY/EPERM) makes it unreadable this tick.
|
|
117
|
+
* @throws {LineageVersionError} when the file's version is unknown/future.
|
|
118
|
+
* @throws {SyntaxError} (via JSON.parse) when the file is corrupt — the
|
|
119
|
+
* caller quarantines it.
|
|
120
|
+
*/
|
|
121
|
+
_readDisk() {
|
|
122
|
+
if (!fs.existsSync(this.dagFile)) return null;
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = fs.readFileSync(this.dagFile, "utf8");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Transient Windows file lock (EBUSY/EPERM) while another writer holds
|
|
128
|
+
// the file: a real signal, not corruption. Return null so the caller
|
|
129
|
+
// proceeds from its in-memory copy (the next persist() retries the read).
|
|
130
|
+
if (err && (err.code === "EBUSY" || err.code === "EPERM")) return null;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
const data = JSON.parse(raw); // throws on corrupt JSON (caller quarantines)
|
|
134
|
+
// Version gate — refuse unknown/future formats before parsing nodes.
|
|
135
|
+
// A file with no version field is treated as the oldest known version.
|
|
136
|
+
const fileVersion = data && typeof data.version === "string" ? data.version : null;
|
|
137
|
+
if (fileVersion !== null && !KNOWN_LINEAGE_VERSIONS.has(fileVersion)) {
|
|
138
|
+
throw new LineageVersionError(fileVersion, LINEAGE_VERSION, this.dagFile);
|
|
139
|
+
}
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_load() {
|
|
144
|
+
this._ensureEvoDir();
|
|
145
|
+
let data = null;
|
|
146
|
+
try {
|
|
147
|
+
data = this._readDisk();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof LineageVersionError) throw err; // fail-fast, never quarantine a version mismatch
|
|
150
|
+
// Corrupt file: quarantine rather than silently overwriting it.
|
|
151
|
+
const corruptBackup = path.join(this.evoDir, `lineage.json.corrupt-${Date.now()}`);
|
|
152
|
+
console.error(`[LineageDag] Corrupt lineage.json detected. Quarantining to ${corruptBackup}:`, err.message);
|
|
153
|
+
try {
|
|
154
|
+
fs.copyFileSync(this.dagFile, corruptBackup);
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (data) {
|
|
159
|
+
this._applyDisk(data);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// No readable file: initialize the root baseline node.
|
|
164
|
+
const rootNode = {
|
|
165
|
+
id: "baseline",
|
|
166
|
+
parentId: null,
|
|
167
|
+
hypothesis: "Initial baseline configuration",
|
|
168
|
+
filesModified: [],
|
|
169
|
+
metrics: { fitness: 0, status: "accepted" },
|
|
170
|
+
timestamp: new Date().toISOString(),
|
|
171
|
+
};
|
|
172
|
+
this.rootId = "baseline";
|
|
173
|
+
this.currentHeadId = "baseline";
|
|
174
|
+
this.nodes.set("baseline", rootNode);
|
|
175
|
+
this.persist();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Merges on-disk state into the in-memory graph (union by node id,
|
|
180
|
+
* per-node recency by updatedAt/timestamp). Shared by _load()
|
|
181
|
+
* (initial/reload) and persist() (read-modify-write) so a reload or a
|
|
182
|
+
* concurrent writer's nodes are never lost.
|
|
183
|
+
*/
|
|
184
|
+
_applyDisk(data) {
|
|
185
|
+
if (Array.isArray(data.nodes)) {
|
|
186
|
+
for (const n of data.nodes) {
|
|
187
|
+
const existing = this.nodes.get(n.id);
|
|
188
|
+
if (!existing || nodeTime(n) > nodeTime(existing)) {
|
|
189
|
+
this.nodes.set(n.id, n);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} else if (Array.isArray(data.candidates)) {
|
|
193
|
+
// Backward compatibility migration: import legacy flat candidates.
|
|
194
|
+
// Unknown/missing statuses map to "neutral".
|
|
195
|
+
for (const c of data.candidates) {
|
|
196
|
+
const id = c.candidateId;
|
|
197
|
+
const mapped = {
|
|
198
|
+
id,
|
|
199
|
+
parentId: c.parentCommit || "baseline",
|
|
200
|
+
hypothesis: c.hypothesis || "",
|
|
201
|
+
filesModified: c.filesModified || [],
|
|
202
|
+
metrics: {
|
|
203
|
+
fitness: c.metricScore,
|
|
204
|
+
status: mapLegacyStatus(c.status),
|
|
205
|
+
commitSha: c.commitSha,
|
|
206
|
+
errorLog: c.errorLog,
|
|
207
|
+
},
|
|
208
|
+
timestamp: c.timestamp || new Date().toISOString(),
|
|
209
|
+
};
|
|
210
|
+
const existing = this.nodes.get(id);
|
|
211
|
+
if (!existing || nodeTime(mapped) > nodeTime(existing)) {
|
|
212
|
+
this.nodes.set(id, mapped);
|
|
213
|
+
}
|
|
214
|
+
// Legacy files carry no currentHeadId field; the accepted candidate
|
|
215
|
+
// becomes head.
|
|
216
|
+
if (mapped.metrics.status === "accepted") {
|
|
217
|
+
this.currentHeadId = id;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Head: adopt the on-disk head if it is newer than our (merged) head node,
|
|
223
|
+
// or if we have no head yet. Prevents a stale in-memory head from
|
|
224
|
+
// regressing a newer on-disk head (cross-process).
|
|
225
|
+
if (data.currentHeadId) {
|
|
226
|
+
const memHead = this.nodes.get(this.currentHeadId);
|
|
227
|
+
const diskHead = this.nodes.get(data.currentHeadId);
|
|
228
|
+
if (!memHead || (diskHead && nodeTime(diskHead) > nodeTime(memHead))) {
|
|
229
|
+
this.currentHeadId = data.currentHeadId;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!this.rootId && data.rootId) this.rootId = data.rootId;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read-modify-write. Re-reads the on-disk file and merges its nodes into the
|
|
237
|
+
* in-memory graph (union by node id; per-node recency by updatedAt) before
|
|
238
|
+
* writing, so a concurrent writer's nodes survive. The write is atomic
|
|
239
|
+
* (tmp + rename), the same idiom task_registry.saveTaskToDisk uses.
|
|
240
|
+
*/
|
|
241
|
+
persist() {
|
|
242
|
+
this._ensureEvoDir();
|
|
243
|
+
|
|
244
|
+
let disk = null;
|
|
245
|
+
try {
|
|
246
|
+
disk = this._readDisk();
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof LineageVersionError) throw err; // fail-fast
|
|
249
|
+
// Transient lock / corrupt: proceed from memory (do not lose our nodes).
|
|
250
|
+
}
|
|
251
|
+
if (disk) this._applyDisk(disk);
|
|
252
|
+
|
|
253
|
+
const data = {
|
|
254
|
+
version: LINEAGE_VERSION,
|
|
255
|
+
rootId: this.rootId,
|
|
256
|
+
currentHeadId: this.currentHeadId,
|
|
257
|
+
nodes: Array.from(this.nodes.values()),
|
|
258
|
+
updatedAt: new Date().toISOString(),
|
|
259
|
+
};
|
|
260
|
+
const tmpFile = path.join(this.evoDir, `lineage.tmp_${process.pid}_${Date.now()}`);
|
|
261
|
+
try {
|
|
262
|
+
fs.writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf8");
|
|
263
|
+
fs.renameSync(tmpFile, this.dagFile);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(`[LineageDag] Failed to persist lineage.json atomically:`, err.message);
|
|
266
|
+
try {
|
|
267
|
+
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
|
|
268
|
+
} catch {}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Adds a new mutation candidate node to the lineage DAG.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} candidate
|
|
276
|
+
* @param {string} [candidate.id]
|
|
277
|
+
* @param {string} [candidate.parentId]
|
|
278
|
+
* @param {string} candidate.hypothesis
|
|
279
|
+
* @param {Array<string>} [candidate.filesModified]
|
|
280
|
+
* @param {object} [candidate.metrics]
|
|
281
|
+
* @returns {object} The created candidate node
|
|
282
|
+
*/
|
|
283
|
+
addCandidate({ id, parentId, hypothesis, filesModified = [], skillsModified = [], metrics = {} }) {
|
|
284
|
+
const candidateId = id || `cand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
285
|
+
const effectiveParent = parentId || this.currentHeadId || this.rootId;
|
|
286
|
+
|
|
287
|
+
const node = {
|
|
288
|
+
id: candidateId,
|
|
289
|
+
parentId: effectiveParent,
|
|
290
|
+
hypothesis,
|
|
291
|
+
filesModified,
|
|
292
|
+
skillsModified,
|
|
293
|
+
metrics: {
|
|
294
|
+
fitness: null,
|
|
295
|
+
status: "pending",
|
|
296
|
+
...metrics,
|
|
297
|
+
},
|
|
298
|
+
timestamp: new Date().toISOString(),
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
this.nodes.set(candidateId, node);
|
|
302
|
+
this.persist();
|
|
303
|
+
return node;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Updates evaluation metrics and status for a candidate.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} id
|
|
310
|
+
* @param {object} metrics
|
|
311
|
+
* @param {'accepted'|'rejected'|'pending'|'neutral'} [status]
|
|
312
|
+
*/
|
|
313
|
+
updateMetrics(id, metrics, status) {
|
|
314
|
+
const node = this.nodes.get(id);
|
|
315
|
+
if (!node) {
|
|
316
|
+
throw new Error(`Candidate node '${id}' not found in Lineage DAG`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
node.metrics = {
|
|
320
|
+
...node.metrics,
|
|
321
|
+
...metrics,
|
|
322
|
+
};
|
|
323
|
+
if (status) {
|
|
324
|
+
node.metrics.status = status;
|
|
325
|
+
if (status === "accepted") {
|
|
326
|
+
this.currentHeadId = id;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
node.updatedAt = new Date().toISOString();
|
|
330
|
+
this.persist();
|
|
331
|
+
return node;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Returns the candidate node with the highest fitness score.
|
|
336
|
+
*/
|
|
337
|
+
getBestCandidate() {
|
|
338
|
+
let best = null;
|
|
339
|
+
for (const node of this.nodes.values()) {
|
|
340
|
+
if (node.metrics?.status === "accepted" && typeof node.metrics.fitness === "number") {
|
|
341
|
+
if (!best || node.metrics.fitness > best.metrics.fitness) {
|
|
342
|
+
best = node;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return best || this.nodes.get(this.rootId);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Returns complete lineage trace from root to a given node.
|
|
351
|
+
* @param {string} [nodeId]
|
|
352
|
+
*/
|
|
353
|
+
getAncestry(nodeId) {
|
|
354
|
+
const targetId = nodeId || this.currentHeadId;
|
|
355
|
+
const chain = [];
|
|
356
|
+
let cur = this.nodes.get(targetId);
|
|
357
|
+
|
|
358
|
+
while (cur) {
|
|
359
|
+
chain.unshift(cur);
|
|
360
|
+
cur = cur.parentId ? this.nodes.get(cur.parentId) : null;
|
|
361
|
+
}
|
|
362
|
+
return chain;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Summarizes the lineage graph for LLM context injection.
|
|
367
|
+
*/
|
|
368
|
+
toSummary() {
|
|
369
|
+
const all = Array.from(this.nodes.values());
|
|
370
|
+
const accepted = all.filter((n) => n.metrics?.status === "accepted");
|
|
371
|
+
const rejected = all.filter((n) => n.metrics?.status === "rejected");
|
|
372
|
+
const best = this.getBestCandidate();
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
totalCandidates: all.length,
|
|
376
|
+
acceptedCount: accepted.length,
|
|
377
|
+
rejectedCount: rejected.length,
|
|
378
|
+
currentHead: this.currentHeadId,
|
|
379
|
+
bestFitness: best?.metrics?.fitness ?? 0,
|
|
380
|
+
bestCandidateId: best?.id ?? "baseline",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traceback & Failure Condenser (Structural Repair Engine)
|
|
3
|
+
*
|
|
4
|
+
* Slices noisy test output and stack traces into high-signal, bounded (<=100 token)
|
|
5
|
+
* failure digests. Filters out third-party framework frames (site-packages, node_modules)
|
|
6
|
+
* to locate the exact repository line and symbol where failure occurred.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const THIRD_PARTY_PATTERNS = [
|
|
12
|
+
/\/node_modules\//i,
|
|
13
|
+
/\\node_modules\\/i,
|
|
14
|
+
/\/site-packages\//i,
|
|
15
|
+
/\\site-packages\\/i,
|
|
16
|
+
/\/dist-packages\//i,
|
|
17
|
+
/\\dist-packages\\/i,
|
|
18
|
+
/\/lib\/python\d+\.\d+\//i,
|
|
19
|
+
/internal\/modules\//i,
|
|
20
|
+
/<frozen /i,
|
|
21
|
+
/_pytest\//i,
|
|
22
|
+
/pluggy\//i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function isThirdPartyFrame(filePath) {
|
|
26
|
+
if (!filePath) return false;
|
|
27
|
+
return THIRD_PARTY_PATTERNS.some((pattern) => pattern.test(filePath));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parses raw test stderr/stdout and extracts a condensed, actionable failure digest.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @param {string} params.stdout
|
|
35
|
+
* @param {string} params.stderr
|
|
36
|
+
* @param {number} params.exitCode
|
|
37
|
+
* @param {boolean} [params.timedOut]
|
|
38
|
+
* @param {string} [params.workspaceRoot]
|
|
39
|
+
* @returns {{
|
|
40
|
+
* failureType: string,
|
|
41
|
+
* targetFile: string|null,
|
|
42
|
+
* targetLine: number|null,
|
|
43
|
+
* targetSymbol: string|null,
|
|
44
|
+
* assertion: string|null,
|
|
45
|
+
* summary: string
|
|
46
|
+
* }}
|
|
47
|
+
*/
|
|
48
|
+
export function condenseTraceback({
|
|
49
|
+
stdout = "",
|
|
50
|
+
stderr = "",
|
|
51
|
+
exitCode = 0,
|
|
52
|
+
timedOut = false,
|
|
53
|
+
workspaceRoot = "",
|
|
54
|
+
}) {
|
|
55
|
+
if (exitCode === 0 && !timedOut) {
|
|
56
|
+
return {
|
|
57
|
+
failureType: "none",
|
|
58
|
+
targetFile: null,
|
|
59
|
+
targetLine: null,
|
|
60
|
+
targetSymbol: null,
|
|
61
|
+
assertion: null,
|
|
62
|
+
summary: "All checks and tests passed successfully.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Case 1: Timeout / Hang
|
|
67
|
+
if (timedOut || exitCode === 124 || stderr.includes("timed out after")) {
|
|
68
|
+
return {
|
|
69
|
+
failureType: "timed_out",
|
|
70
|
+
targetFile: null,
|
|
71
|
+
targetLine: null,
|
|
72
|
+
targetSymbol: null,
|
|
73
|
+
assertion: null,
|
|
74
|
+
summary: "Execution timed out (process hung, infinite loop, or test exceeded deadline).",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const combined = `${stderr}\n${stdout}`;
|
|
79
|
+
const lines = combined.split("\n").map((l) => l.trimEnd());
|
|
80
|
+
|
|
81
|
+
let targetFile = null;
|
|
82
|
+
let targetLine = null;
|
|
83
|
+
let targetSymbol = null;
|
|
84
|
+
let assertion = null;
|
|
85
|
+
let failureType = "test_failure";
|
|
86
|
+
|
|
87
|
+
// Regex 1: Python Traceback frame: File "path/to/file.py", line 42, in func_name
|
|
88
|
+
const pyFrameRegex = /File\s+["']([^"']+)["'],\s+line\s+(\d+)(?:,\s+in\s+([a-zA-Z0-9_<>\.]+))?/i;
|
|
89
|
+
// Regex 2: Node.js / V8 frame: at funcName (path/to/file.js:42:10) or at path/to/file.js:42:10
|
|
90
|
+
const jsFrameRegex = /at\s+(?:([a-zA-Z0-9_$<>.]+)\s+\(([^()]+):(\d+):\d+\)|([^()]+):(\d+):\d+)/i;
|
|
91
|
+
// Regex 3: Python pytest inline error: > assert ... / E AssertionError: ...
|
|
92
|
+
const pyAssertionRegex = /^(?:E\s+|>\s+)(AssertionError.*|TypeError.*|ValueError.*|KeyError.*|ZeroDivisionError.*|AttributeError.*|SyntaxError.*|NameError.*)$/i;
|
|
93
|
+
// Regex 4: Jest / Mocha assertion: AssertionError: ... or Error: expect(received).toBe(expected)
|
|
94
|
+
const jsAssertionRegex = /^(AssertionError:.*|Error: expect\(.*\).*|ReferenceError:.*|TypeError:.*|SyntaxError:.*)/i;
|
|
95
|
+
|
|
96
|
+
const foundFrames = [];
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < lines.length; i++) {
|
|
99
|
+
const line = lines[i].trim();
|
|
100
|
+
|
|
101
|
+
// Check Python frame
|
|
102
|
+
const pyMatch = line.match(pyFrameRegex);
|
|
103
|
+
if (pyMatch) {
|
|
104
|
+
const fPath = pyMatch[1];
|
|
105
|
+
const fLine = parseInt(pyMatch[2], 10);
|
|
106
|
+
const fSym = pyMatch[3] || null;
|
|
107
|
+
foundFrames.push({ path: fPath, line: fLine, symbol: fSym });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check JS frame
|
|
112
|
+
const jsMatch = line.match(jsFrameRegex);
|
|
113
|
+
if (jsMatch) {
|
|
114
|
+
const fSym = jsMatch[1] || null;
|
|
115
|
+
const fPath = jsMatch[2] || jsMatch[4];
|
|
116
|
+
const fLine = parseInt(jsMatch[3] || jsMatch[5], 10);
|
|
117
|
+
foundFrames.push({ path: fPath, line: fLine, symbol: fSym });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check Assertion line
|
|
122
|
+
if (!assertion) {
|
|
123
|
+
const aPyMatch = line.match(pyAssertionRegex);
|
|
124
|
+
if (aPyMatch) {
|
|
125
|
+
assertion = aPyMatch[1].trim();
|
|
126
|
+
failureType = "assertion_failure";
|
|
127
|
+
} else {
|
|
128
|
+
const aJsMatch = line.match(jsAssertionRegex);
|
|
129
|
+
if (aJsMatch) {
|
|
130
|
+
assertion = aJsMatch[1].trim();
|
|
131
|
+
failureType = "assertion_failure";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Filter frames: pick the first frame that belongs to the workspace and is NOT third-party
|
|
138
|
+
for (const frame of foundFrames) {
|
|
139
|
+
if (!isThirdPartyFrame(frame.path)) {
|
|
140
|
+
targetFile = frame.path;
|
|
141
|
+
targetLine = frame.line;
|
|
142
|
+
targetSymbol = frame.symbol;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If all were third-party, take the last found frame as a fallback
|
|
148
|
+
if (!targetFile && foundFrames.length > 0) {
|
|
149
|
+
const last = foundFrames[foundFrames.length - 1];
|
|
150
|
+
targetFile = last.path;
|
|
151
|
+
targetLine = last.line;
|
|
152
|
+
targetSymbol = last.symbol;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build clean concise summary
|
|
156
|
+
let locStr = "";
|
|
157
|
+
if (targetFile) {
|
|
158
|
+
const base = path.basename(targetFile);
|
|
159
|
+
locStr = ` at ${base}:${targetLine || "?"}${targetSymbol ? ` (${targetSymbol})` : ""}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const errDetail = assertion ? `: ${assertion}` : "";
|
|
163
|
+
const summary = `[FailureDigest] ${failureType}${locStr}${errDetail}`.trim();
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
failureType,
|
|
167
|
+
targetFile,
|
|
168
|
+
targetLine,
|
|
169
|
+
targetSymbol,
|
|
170
|
+
assertion,
|
|
171
|
+
summary,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evolutionary Watchdog & Stagnation Circuit Breaker (Evo)
|
|
3
|
+
*
|
|
4
|
+
* Protects agent runs against:
|
|
5
|
+
* - Mutation stagnation (consecutive rejected candidates)
|
|
6
|
+
* - Token generation velocity decay (speculative decoding / KV pressure)
|
|
7
|
+
* - Infinite loops without fitness improvement
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class EvoWatchdog {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.maxConsecutiveRejections = options.maxConsecutiveRejections || 4;
|
|
13
|
+
this.consecutiveRejections = 0;
|
|
14
|
+
this.stagnationTripped = false;
|
|
15
|
+
this.tokenVelocityHistory = [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Records candidate evaluation outcome.
|
|
20
|
+
* @param {'accepted'|'rejected'} status
|
|
21
|
+
* @returns {{ tripped: boolean, message?: string }}
|
|
22
|
+
*/
|
|
23
|
+
recordCandidateOutcome(status) {
|
|
24
|
+
if (status === "accepted") {
|
|
25
|
+
this.consecutiveRejections = 0;
|
|
26
|
+
this.stagnationTripped = false;
|
|
27
|
+
return { tripped: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.consecutiveRejections++;
|
|
31
|
+
if (this.consecutiveRejections >= this.maxConsecutiveRejections) {
|
|
32
|
+
this.stagnationTripped = true;
|
|
33
|
+
return {
|
|
34
|
+
tripped: true,
|
|
35
|
+
consecutiveRejections: this.consecutiveRejections,
|
|
36
|
+
message: `[Evo Watchdog Circuit Breaker] Stagnation detected: ${this.consecutiveRejections} consecutive candidate mutations were rejected. Recommend varying mutation strategy, reverting to baseline, or inspecting error stack trace directly.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { tripped: false, consecutiveRejections: this.consecutiveRejections };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Records token generation velocity.
|
|
45
|
+
* @param {number} tokensPerSec
|
|
46
|
+
*/
|
|
47
|
+
recordVelocity(tokensPerSec) {
|
|
48
|
+
if (typeof tokensPerSec === "number" && tokensPerSec > 0) {
|
|
49
|
+
this.tokenVelocityHistory.push(tokensPerSec);
|
|
50
|
+
if (this.tokenVelocityHistory.length > 10) {
|
|
51
|
+
this.tokenVelocityHistory.shift();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks whether token generation velocity has degraded significantly.
|
|
58
|
+
*/
|
|
59
|
+
isVelocityDegraded() {
|
|
60
|
+
if (this.tokenVelocityHistory.length < 3) return false;
|
|
61
|
+
const first = this.tokenVelocityHistory[0];
|
|
62
|
+
const latest = this.tokenVelocityHistory[this.tokenVelocityHistory.length - 1];
|
|
63
|
+
// More than 50% decay indicates KV cache context exhaustion
|
|
64
|
+
return latest < first * 0.5;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
reset() {
|
|
68
|
+
this.consecutiveRejections = 0;
|
|
69
|
+
this.stagnationTripped = false;
|
|
70
|
+
this.tokenVelocityHistory = [];
|
|
71
|
+
}
|
|
72
|
+
}
|