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,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Directory Streamlining & Retention (Castor Harness)
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - pruneOldSessions: evict old session directories based on age, count, and size gates
|
|
6
|
+
* - cleanStateDir: full state-dir cleanup (sessions + orphan .tmp_* files) with throttling
|
|
7
|
+
*
|
|
8
|
+
* Invariant: NEVER prune active/open sessions or sessions referenced by incomplete tasks.
|
|
9
|
+
*
|
|
10
|
+
* Run: node src/state_pruner.js (self-test mode, optional)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { QWEN_STATE_DIR, TASK_DIR } from "./config.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Defaults
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MAX_AGE_MS = 14 * 86_400_000; // 14 days
|
|
22
|
+
const DEFAULT_MAX_COUNT = 200;
|
|
23
|
+
const DEFAULT_MAX_SIZE_MB = 50;
|
|
24
|
+
const PRUNE_THROTTLE_MS = 24 * 3600_000; // 24 hours
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns true when the session's event stream carries a terminal event
|
|
32
|
+
* (session_end or session_error). A session without a terminal event is
|
|
33
|
+
* considered "active/open".
|
|
34
|
+
*/
|
|
35
|
+
function sessionHasTerminalEvent(sessionDir) {
|
|
36
|
+
const logFile = path.join(sessionDir, "events.jsonl");
|
|
37
|
+
if (!fs.existsSync(logFile)) return false;
|
|
38
|
+
let content;
|
|
39
|
+
try {
|
|
40
|
+
content = fs.readFileSync(logFile, "utf8");
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
for (const line of content.split("\n")) {
|
|
45
|
+
if (!line.trim()) continue;
|
|
46
|
+
let ev;
|
|
47
|
+
try {
|
|
48
|
+
ev = JSON.parse(line);
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (ev && (ev.type === "session_end" || ev.type === "session_error")) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the set of session IDs referenced by incomplete (not-done) tasks
|
|
61
|
+
* on disk.
|
|
62
|
+
*/
|
|
63
|
+
function getActiveTaskSessionIds() {
|
|
64
|
+
const ids = new Set();
|
|
65
|
+
if (!fs.existsSync(TASK_DIR)) return ids;
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = fs.readdirSync(TASK_DIR);
|
|
69
|
+
} catch {
|
|
70
|
+
return ids;
|
|
71
|
+
}
|
|
72
|
+
for (const f of entries) {
|
|
73
|
+
if (!f.endsWith(".json")) continue;
|
|
74
|
+
let task;
|
|
75
|
+
try {
|
|
76
|
+
task = JSON.parse(fs.readFileSync(path.join(TASK_DIR, f), "utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (task && !task.done && task.sessionId) {
|
|
81
|
+
ids.add(task.sessionId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns the total size in bytes of a directory (recursive).
|
|
89
|
+
*/
|
|
90
|
+
function dirSizeBytes(dir) {
|
|
91
|
+
let total = 0;
|
|
92
|
+
let entries;
|
|
93
|
+
try {
|
|
94
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const full = path.join(dir, entry.name);
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
total += dirSizeBytes(full);
|
|
102
|
+
} else if (entry.isFile()) {
|
|
103
|
+
try {
|
|
104
|
+
total += fs.statSync(full).size;
|
|
105
|
+
} catch {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return total;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the mtime of a session directory (uses events.jsonl mtime if
|
|
115
|
+
* available, else the directory mtime).
|
|
116
|
+
*/
|
|
117
|
+
function sessionMtime(sessionDir) {
|
|
118
|
+
const logFile = path.join(sessionDir, "events.jsonl");
|
|
119
|
+
try {
|
|
120
|
+
if (fs.existsSync(logFile)) {
|
|
121
|
+
return fs.statSync(logFile).mtimeMs;
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
try {
|
|
125
|
+
return fs.statSync(sessionDir).mtimeMs;
|
|
126
|
+
} catch {
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// pruneOldSessions
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Prunes old session directories from the state directory.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} [options]
|
|
139
|
+
* @param {string} [options.stateDir] Root state dir (default: QWEN_STATE_DIR)
|
|
140
|
+
* @param {number} [options.maxAgeMs] Max age in ms (default: 14 days)
|
|
141
|
+
* @param {number} [options.maxCount] Max number of sessions to keep (default: 200)
|
|
142
|
+
* @param {number} [options.maxSizeMb] Max total size in MB (default: 50)
|
|
143
|
+
* @param {boolean} [options.dryRun] If true, report without deleting
|
|
144
|
+
* @param {number} [options.now] Override current time (for tests)
|
|
145
|
+
* @returns {{ pruned: number, bytesReclaimed: number, protected: number, skipped: number }}
|
|
146
|
+
*/
|
|
147
|
+
export function pruneOldSessions(options = {}) {
|
|
148
|
+
const stateDir = options.stateDir || QWEN_STATE_DIR;
|
|
149
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
150
|
+
const maxCount = options.maxCount ?? DEFAULT_MAX_COUNT;
|
|
151
|
+
const maxSizeMb = options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB;
|
|
152
|
+
const dryRun = Boolean(options.dryRun);
|
|
153
|
+
const now = options.now ?? Date.now();
|
|
154
|
+
|
|
155
|
+
const sessionsDir = path.join(stateDir, "sessions");
|
|
156
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
157
|
+
return { pruned: 0, bytesReclaimed: 0, protected: 0, skipped: 0 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Collect session directories with metadata.
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
|
164
|
+
} catch {
|
|
165
|
+
return { pruned: 0, bytesReclaimed: 0, protected: 0, skipped: 0 };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sessions = [];
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
if (!entry.isDirectory()) continue;
|
|
171
|
+
const sessionDir = path.join(sessionsDir, entry.name);
|
|
172
|
+
const mtime = sessionMtime(sessionDir);
|
|
173
|
+
const size = dirSizeBytes(sessionDir);
|
|
174
|
+
const hasTerminal = sessionHasTerminalEvent(sessionDir);
|
|
175
|
+
sessions.push({
|
|
176
|
+
id: entry.name,
|
|
177
|
+
dir: sessionDir,
|
|
178
|
+
mtime,
|
|
179
|
+
size,
|
|
180
|
+
hasTerminal,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Get active task session IDs (invariant: never prune these).
|
|
185
|
+
const activeTaskSessionIds = getActiveTaskSessionIds();
|
|
186
|
+
|
|
187
|
+
// Classify: protected vs candidates.
|
|
188
|
+
const protectedSessions = [];
|
|
189
|
+
const candidates = [];
|
|
190
|
+
for (const s of sessions) {
|
|
191
|
+
// Invariant: never prune active/open sessions.
|
|
192
|
+
if (!s.hasTerminal) {
|
|
193
|
+
protectedSessions.push(s);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// Invariant: never prune sessions referenced by incomplete tasks.
|
|
197
|
+
if (activeTaskSessionIds.has(s.id)) {
|
|
198
|
+
protectedSessions.push(s);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
candidates.push(s);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Sort candidates by mtime ascending (oldest first).
|
|
205
|
+
candidates.sort((a, b) => a.mtime - b.mtime);
|
|
206
|
+
|
|
207
|
+
// Determine which candidates to prune.
|
|
208
|
+
const toPrune = new Set();
|
|
209
|
+
const maxSizeBytes = maxSizeMb * 1024 * 1024;
|
|
210
|
+
|
|
211
|
+
// Gate 1: age — prune sessions older than maxAgeMs.
|
|
212
|
+
for (const s of candidates) {
|
|
213
|
+
if (now - s.mtime > maxAgeMs) {
|
|
214
|
+
toPrune.add(s.id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Gate 2: count — if total sessions (protected + candidates) exceeds maxCount,
|
|
219
|
+
// evict oldest candidates until we're at or below maxCount.
|
|
220
|
+
const totalSessions = sessions.length;
|
|
221
|
+
if (totalSessions > maxCount) {
|
|
222
|
+
let excess = totalSessions - maxCount;
|
|
223
|
+
for (const s of candidates) {
|
|
224
|
+
if (excess <= 0) break;
|
|
225
|
+
if (!toPrune.has(s.id)) {
|
|
226
|
+
toPrune.add(s.id);
|
|
227
|
+
excess--;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Gate 3: size — if total size of all sessions exceeds maxSizeMb,
|
|
233
|
+
// evict oldest candidates until total is at or below the limit.
|
|
234
|
+
let totalSize = 0;
|
|
235
|
+
for (const s of sessions) {
|
|
236
|
+
totalSize += s.size;
|
|
237
|
+
}
|
|
238
|
+
if (totalSize > maxSizeBytes) {
|
|
239
|
+
let remaining = totalSize;
|
|
240
|
+
for (const s of candidates) {
|
|
241
|
+
if (remaining <= maxSizeBytes) break;
|
|
242
|
+
if (!toPrune.has(s.id)) {
|
|
243
|
+
toPrune.add(s.id);
|
|
244
|
+
remaining -= s.size;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Execute (or simulate) the prune.
|
|
250
|
+
let pruned = 0;
|
|
251
|
+
let bytesReclaimed = 0;
|
|
252
|
+
for (const s of candidates) {
|
|
253
|
+
if (!toPrune.has(s.id)) continue;
|
|
254
|
+
if (dryRun) {
|
|
255
|
+
pruned++;
|
|
256
|
+
bytesReclaimed += s.size;
|
|
257
|
+
} else {
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(s.dir, { recursive: true, force: true });
|
|
260
|
+
pruned++;
|
|
261
|
+
bytesReclaimed += s.size;
|
|
262
|
+
} catch {
|
|
263
|
+
// Best-effort: skip on failure.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
pruned,
|
|
270
|
+
bytesReclaimed,
|
|
271
|
+
protected: protectedSessions.length,
|
|
272
|
+
skipped: candidates.length - pruned,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// cleanStateDir
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Full state-directory cleanup: prunes old sessions and removes orphan
|
|
282
|
+
* .tmp_* files in the tasks directory.
|
|
283
|
+
*
|
|
284
|
+
* @param {object} [options]
|
|
285
|
+
* @param {string} [options.stateDir] Root state dir (default: QWEN_STATE_DIR)
|
|
286
|
+
* @param {number} [options.maxAgeMs] Max age in ms (default: 14 days)
|
|
287
|
+
* @param {number} [options.maxCount] Max number of sessions (default: 200)
|
|
288
|
+
* @param {number} [options.maxSizeMb] Max total size in MB (default: 50)
|
|
289
|
+
* @param {boolean} [options.dryRun] If true, report without deleting
|
|
290
|
+
* @param {boolean} [options.throttled] If true, skip if last prune was < 24h ago
|
|
291
|
+
* @param {number} [options.now] Override current time (for tests)
|
|
292
|
+
* @returns {{
|
|
293
|
+
* sessionsPruned: number,
|
|
294
|
+
* bytesReclaimed: number,
|
|
295
|
+
* tmpFilesCleaned: number,
|
|
296
|
+
* tmpBytesReclaimed: number,
|
|
297
|
+
* skipped: boolean,
|
|
298
|
+
* protected: number,
|
|
299
|
+
* }}
|
|
300
|
+
*/
|
|
301
|
+
export function cleanStateDir(options = {}) {
|
|
302
|
+
const stateDir = options.stateDir || QWEN_STATE_DIR;
|
|
303
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
304
|
+
const maxCount = options.maxCount ?? DEFAULT_MAX_COUNT;
|
|
305
|
+
const maxSizeMb = options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB;
|
|
306
|
+
const dryRun = Boolean(options.dryRun);
|
|
307
|
+
const throttled = Boolean(options.throttled);
|
|
308
|
+
const now = options.now ?? Date.now();
|
|
309
|
+
|
|
310
|
+
// Throttle: check .last_prune timestamp.
|
|
311
|
+
if (throttled) {
|
|
312
|
+
const lastPruneFile = path.join(stateDir, ".last_prune");
|
|
313
|
+
try {
|
|
314
|
+
if (fs.existsSync(lastPruneFile)) {
|
|
315
|
+
const lastPrune = parseInt(
|
|
316
|
+
fs.readFileSync(lastPruneFile, "utf8").trim(),
|
|
317
|
+
10
|
|
318
|
+
);
|
|
319
|
+
if (Number.isFinite(lastPrune) && now - lastPrune < PRUNE_THROTTLE_MS) {
|
|
320
|
+
return {
|
|
321
|
+
sessionsPruned: 0,
|
|
322
|
+
bytesReclaimed: 0,
|
|
323
|
+
tmpFilesCleaned: 0,
|
|
324
|
+
tmpBytesReclaimed: 0,
|
|
325
|
+
skipped: true,
|
|
326
|
+
protected: 0,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// Corrupt or unreadable .last_prune: proceed with cleanup.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Prune sessions.
|
|
336
|
+
const sessionResult = pruneOldSessions({
|
|
337
|
+
stateDir,
|
|
338
|
+
maxAgeMs,
|
|
339
|
+
maxCount,
|
|
340
|
+
maxSizeMb,
|
|
341
|
+
dryRun,
|
|
342
|
+
now,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Clean orphan .tmp_* files in the tasks directory.
|
|
346
|
+
let tmpFilesCleaned = 0;
|
|
347
|
+
let tmpBytesReclaimed = 0;
|
|
348
|
+
if (fs.existsSync(TASK_DIR)) {
|
|
349
|
+
let entries;
|
|
350
|
+
try {
|
|
351
|
+
entries = fs.readdirSync(TASK_DIR);
|
|
352
|
+
} catch {
|
|
353
|
+
entries = [];
|
|
354
|
+
}
|
|
355
|
+
for (const f of entries) {
|
|
356
|
+
if (!f.includes(".tmp_")) continue;
|
|
357
|
+
const tmpPath = path.join(TASK_DIR, f);
|
|
358
|
+
try {
|
|
359
|
+
const st = fs.statSync(tmpPath);
|
|
360
|
+
if (dryRun) {
|
|
361
|
+
tmpFilesCleaned++;
|
|
362
|
+
tmpBytesReclaimed += st.size;
|
|
363
|
+
} else {
|
|
364
|
+
fs.unlinkSync(tmpPath);
|
|
365
|
+
tmpFilesCleaned++;
|
|
366
|
+
tmpBytesReclaimed += st.size;
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// Best-effort.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Write .last_prune timestamp (only when not dryRun).
|
|
375
|
+
if (!dryRun) {
|
|
376
|
+
try {
|
|
377
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
378
|
+
fs.writeFileSync(path.join(stateDir, ".last_prune"), String(now), "utf8");
|
|
379
|
+
} catch {
|
|
380
|
+
// Best-effort.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
sessionsPruned: sessionResult.pruned,
|
|
386
|
+
bytesReclaimed: sessionResult.bytesReclaimed,
|
|
387
|
+
tmpFilesCleaned,
|
|
388
|
+
tmpBytesReclaimed,
|
|
389
|
+
skipped: false,
|
|
390
|
+
protected: sessionResult.protected,
|
|
391
|
+
};
|
|
392
|
+
}
|