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
package/src/semaphore.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
TASK_DIR,
|
|
6
|
+
SLOTS_DIR,
|
|
7
|
+
MAX_CONCURRENT_TASKS,
|
|
8
|
+
SLOT_HEARTBEAT_MS,
|
|
9
|
+
SLOT_WEDGED_MS,
|
|
10
|
+
SLOT_POLL_MS,
|
|
11
|
+
IS_WINDOWS,
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
import { wslDistro } from "./wsl_env.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checks whether a given PID is currently alive on the host or across the
|
|
17
|
+
* WSL/Windows boundary.
|
|
18
|
+
* @param {number} pid - Target process ID.
|
|
19
|
+
* @param {string} [platform=process.platform] - OS platform ('win32' | 'linux').
|
|
20
|
+
* @returns {boolean} true if the PID is alive (or the probe timed out, which
|
|
21
|
+
* is treated as alive to avoid falsely declaring a live worker orphaned).
|
|
22
|
+
*/
|
|
23
|
+
export function pidAlive(pid, platform = process.platform) {
|
|
24
|
+
if (!pid) return false;
|
|
25
|
+
if (pid === process.pid && platform === process.platform) return true;
|
|
26
|
+
|
|
27
|
+
// 1. Same-platform probe
|
|
28
|
+
if (platform === process.platform) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0); // signal 0 = liveness probe, no signal sent
|
|
31
|
+
return true;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return err.code === "EPERM"; // EPERM = exists, just owned by another user
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Cross-platform probe: Windows host probing a WSL/Linux PID
|
|
38
|
+
if (IS_WINDOWS && platform === "linux") {
|
|
39
|
+
try {
|
|
40
|
+
execFileSync("wsl.exe", ["-d", wslDistro(), "--", "kill", "-0", String(pid)], {
|
|
41
|
+
timeout: 10000,
|
|
42
|
+
stdio: "ignore",
|
|
43
|
+
});
|
|
44
|
+
return true;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Conservative: a probe timeout (ETIMEDOUT) indicates heavy WSL/host load,
|
|
47
|
+
// NOT process death. Never falsely declare a live worker orphaned on timeout.
|
|
48
|
+
if (err && (err.code === "ETIMEDOUT" || err.signal === "SIGTERM")) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. Cross-platform probe: WSL/Linux guest probing a Windows host PID
|
|
54
|
+
if (!IS_WINDOWS && platform === "win32") {
|
|
55
|
+
try {
|
|
56
|
+
execFileSync("powershell.exe", ["-NoProfile", "-Command", `Get-Process -Id ${pid}`], {
|
|
57
|
+
timeout: 10000,
|
|
58
|
+
stdio: "ignore",
|
|
59
|
+
});
|
|
60
|
+
return true;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// Conservative: a probe timeout (ETIMEDOUT) indicates heavy host load,
|
|
63
|
+
// NOT process death. Never falsely declare a live worker orphaned on timeout.
|
|
64
|
+
if (err && (err.code === "ETIMEDOUT" || err.signal === "SIGTERM")) return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fallback if platform is unknown: probe local first
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.code === "EPERM") return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (IS_WINDOWS) {
|
|
78
|
+
try {
|
|
79
|
+
execFileSync("wsl.exe", ["-d", wslDistro(), "--", "kill", "-0", String(pid)], {
|
|
80
|
+
timeout: 3000,
|
|
81
|
+
stdio: "ignore",
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
} catch {}
|
|
85
|
+
} else {
|
|
86
|
+
try {
|
|
87
|
+
execFileSync("powershell.exe", ["-NoProfile", "-Command", `Get-Process -Id ${pid}`], {
|
|
88
|
+
timeout: 3000,
|
|
89
|
+
stdio: "ignore",
|
|
90
|
+
});
|
|
91
|
+
return true;
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function slotFilePath(i) {
|
|
99
|
+
return path.join(SLOTS_DIR, `slot_${i}.json`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readLease(file) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function leaseReclaimable(lease) {
|
|
111
|
+
if (!lease) return true; // unreadable = crashed mid-write
|
|
112
|
+
// If the claiming process is dead, reclaim the slot immediately
|
|
113
|
+
if (!pidAlive(lease.pid, lease.platform)) return true;
|
|
114
|
+
// If the lease is tied to a task that has reached a terminal state (done: true),
|
|
115
|
+
// it is an orphaned zombie lease — reclaim it immediately regardless of owner liveness.
|
|
116
|
+
if (lease.taskId) {
|
|
117
|
+
try {
|
|
118
|
+
const taskFile = path.join(TASK_DIR, `${lease.taskId}.json`);
|
|
119
|
+
if (fs.existsSync(taskFile)) {
|
|
120
|
+
const diskTask = JSON.parse(fs.readFileSync(taskFile, "utf8"));
|
|
121
|
+
if (diskTask && diskTask.done) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
// LIVE PROCESS INVARIANT: A live process's lease running an active task is NEVER reclaimable!
|
|
128
|
+
// Prevents dual-generation collisions on the MAX_SEQS=1 engine during long tasks.
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Acquires a global cross-process task slot lease.
|
|
134
|
+
* Resolves with {file, refresh} once a slot is held, or null if the task was
|
|
135
|
+
* cancelled while waiting.
|
|
136
|
+
*
|
|
137
|
+
* Single-Tenant Multi-Slot Invariant:
|
|
138
|
+
* Up to MAX_CONCURRENT_TASKS (default 1) are permitted machine-wide, but ALL
|
|
139
|
+
* concurrently active slots MUST belong to the SAME tenant (process.pid).
|
|
140
|
+
* If an alien tenant (lease.pid !== process.pid where lease is live) holds any slot,
|
|
141
|
+
* this process is blocked and queues at $0 until the alien tenant releases all slots.
|
|
142
|
+
*/
|
|
143
|
+
export async function acquireTaskSlot(taskEntry) {
|
|
144
|
+
try {
|
|
145
|
+
fs.mkdirSync(SLOTS_DIR, { recursive: true });
|
|
146
|
+
} catch {}
|
|
147
|
+
|
|
148
|
+
let lastHeartbeatUpdate = Date.now();
|
|
149
|
+
|
|
150
|
+
for (;;) {
|
|
151
|
+
if (taskEntry?.done) return null;
|
|
152
|
+
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
// Keep queued task heartbeat fresh so observers/reaper don't treat it as dead while waiting
|
|
155
|
+
if (now - lastHeartbeatUpdate > 15_000) {
|
|
156
|
+
if (taskEntry) {
|
|
157
|
+
taskEntry.lastHeartbeatAt = now;
|
|
158
|
+
}
|
|
159
|
+
lastHeartbeatUpdate = now;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Tenant affinity invariant: check if an alien tenant holds ANY active lease
|
|
163
|
+
let alienTenantActive = false;
|
|
164
|
+
for (let i = 0; i < MAX_CONCURRENT_TASKS; i++) {
|
|
165
|
+
const file = slotFilePath(i);
|
|
166
|
+
const lease = readLease(file);
|
|
167
|
+
if (lease && !leaseReclaimable(lease) && lease.pid !== process.pid) {
|
|
168
|
+
alienTenantActive = true;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!alienTenantActive) {
|
|
174
|
+
for (let i = 0; i < MAX_CONCURRENT_TASKS; i++) {
|
|
175
|
+
const file = slotFilePath(i);
|
|
176
|
+
const claim = {
|
|
177
|
+
pid: process.pid,
|
|
178
|
+
platform: process.platform,
|
|
179
|
+
taskId: taskEntry?.id ?? null,
|
|
180
|
+
cwd: taskEntry?.cwd ?? null,
|
|
181
|
+
at: Date.now(),
|
|
182
|
+
hb: Date.now(),
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
const fd = fs.openSync(file, "wx"); // atomic claim - only one process wins
|
|
186
|
+
fs.writeSync(fd, JSON.stringify(claim));
|
|
187
|
+
fs.closeSync(fd);
|
|
188
|
+
const refresh = setInterval(() => {
|
|
189
|
+
try {
|
|
190
|
+
if (taskEntry && taskEntry.done) {
|
|
191
|
+
clearInterval(refresh);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const cur = readLease(file);
|
|
195
|
+
// Stop refreshing if lease was deleted, stolen by another process, or assigned to a different task
|
|
196
|
+
if (!cur || cur.pid !== process.pid || (cur.taskId && taskEntry?.id && cur.taskId !== taskEntry.id)) {
|
|
197
|
+
clearInterval(refresh);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const tmp = `${file}.tmp_${Date.now()}_${process.pid}`;
|
|
201
|
+
fs.writeFileSync(
|
|
202
|
+
tmp,
|
|
203
|
+
JSON.stringify({ ...(cur ?? claim), pid: process.pid, platform: process.platform, hb: Date.now() }),
|
|
204
|
+
"utf8"
|
|
205
|
+
);
|
|
206
|
+
fs.renameSync(tmp, file);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
process.stderr.write(`[Semaphore] Heartbeat write failed for ${file}: ${err.message}\n`);
|
|
209
|
+
}
|
|
210
|
+
}, SLOT_HEARTBEAT_MS);
|
|
211
|
+
refresh.unref();
|
|
212
|
+
return { file, refresh };
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (err.code !== "EEXIST") continue; // transient fs error: try next slot
|
|
215
|
+
const lease = readLease(file);
|
|
216
|
+
if (!leaseReclaimable(lease)) continue;
|
|
217
|
+
// Reclaim: atomic rename
|
|
218
|
+
const dead = `${file}.dead_${Date.now()}_${process.pid}`;
|
|
219
|
+
try {
|
|
220
|
+
fs.renameSync(file, dead);
|
|
221
|
+
fs.rmSync(dead, { force: true });
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await new Promise((r) => setTimeout(r, SLOT_POLL_MS));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Releases a held task slot lease. Idempotent: a double-release or a release
|
|
233
|
+
* after natural completion is a no-op. The atomic-heartbeat design (O_EXCL
|
|
234
|
+
* claim, heartbeat refresh, ownership-guarded unlink) is preserved.
|
|
235
|
+
*
|
|
236
|
+
* @param {{file: string, refresh: NodeJS.Timeout, released?: boolean}} slot -
|
|
237
|
+
* The slot handle returned by {@link acquireTaskSlot}.
|
|
238
|
+
* @returns {{released: boolean, reason?: string}}
|
|
239
|
+
* - {released:true} A lease we owned (or a dead/unreadable one) was freed.
|
|
240
|
+
* - {released:false, reason:"no_slot"} No handle passed in.
|
|
241
|
+
* - {released:false, reason:"already_released"} This handle was already
|
|
242
|
+
* released (idempotent no-op).
|
|
243
|
+
* - {released:false, reason:"not_ours"} Another live instance owns
|
|
244
|
+
* the lease; it is not deleted.
|
|
245
|
+
* - {released:false, reason:"release_failed"} The unlink threw; the handle
|
|
246
|
+
* is not marked released so a later call may retry.
|
|
247
|
+
*/
|
|
248
|
+
export function releaseTaskSlot(slot) {
|
|
249
|
+
if (!slot) return { released: false, reason: "no_slot" };
|
|
250
|
+
if (slot.released) return { released: false, reason: "already_released" };
|
|
251
|
+
clearInterval(slot.refresh);
|
|
252
|
+
try {
|
|
253
|
+
const cur = readLease(slot.file);
|
|
254
|
+
if (!cur || cur.pid === process.pid || !pidAlive(cur.pid)) {
|
|
255
|
+
fs.rmSync(slot.file, { force: true });
|
|
256
|
+
slot.released = true;
|
|
257
|
+
return { released: true };
|
|
258
|
+
}
|
|
259
|
+
// Another live instance owns this lease; it is not deleted.
|
|
260
|
+
slot.released = true;
|
|
261
|
+
return { released: false, reason: "not_ours" };
|
|
262
|
+
} catch {
|
|
263
|
+
// Do NOT mark released on failure so a later call can retry the unlink.
|
|
264
|
+
return { released: false, reason: "release_failed" };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Returns active (non-reclaimable) leases across all instances for status reporting.
|
|
270
|
+
*/
|
|
271
|
+
export function listTaskSlots() {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (let i = 0; i < MAX_CONCURRENT_TASKS; i++) {
|
|
274
|
+
const file = slotFilePath(i);
|
|
275
|
+
const lease = readLease(file);
|
|
276
|
+
if (lease && !leaseReclaimable(lease)) out.push(lease);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Returns deterministic slot status distinguishing same-session vs alien-session usage.
|
|
283
|
+
* Used for anti-panic status telemetry and orchestrator wait advisories.
|
|
284
|
+
*/
|
|
285
|
+
export function getSlotStatus(currentPid = process.pid) {
|
|
286
|
+
const slots = [];
|
|
287
|
+
const alienHolders = [];
|
|
288
|
+
const sameHolders = [];
|
|
289
|
+
for (let i = 0; i < MAX_CONCURRENT_TASKS; i++) {
|
|
290
|
+
const file = slotFilePath(i);
|
|
291
|
+
const lease = readLease(file);
|
|
292
|
+
if (lease && !leaseReclaimable(lease)) {
|
|
293
|
+
const entry = { slot: i, ...lease };
|
|
294
|
+
slots.push(entry);
|
|
295
|
+
if (lease.pid === currentPid) {
|
|
296
|
+
sameHolders.push(entry);
|
|
297
|
+
} else {
|
|
298
|
+
alienHolders.push(entry);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
slots,
|
|
304
|
+
totalCapacity: MAX_CONCURRENT_TASKS,
|
|
305
|
+
alienHolders,
|
|
306
|
+
sameHolders,
|
|
307
|
+
isAlienActive: alienHolders.length > 0,
|
|
308
|
+
isSameActive: sameHolders.length > 0,
|
|
309
|
+
isFullyOccupied: slots.length >= MAX_CONCURRENT_TASKS,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Clears reclaimable slot lease locks in SLOTS_DIR.
|
|
315
|
+
*
|
|
316
|
+
* Only deletes leases whose owner is this process or whose owner pid is not
|
|
317
|
+
* alive. A lease that cannot be parsed (truncated mid-write) is treated as
|
|
318
|
+
* reclaimable, matching the readLease/leaseReclaimable convention.
|
|
319
|
+
*/
|
|
320
|
+
export function clearReclaimableTaskSlots() {
|
|
321
|
+
try {
|
|
322
|
+
if (fs.existsSync(SLOTS_DIR)) {
|
|
323
|
+
for (const f of fs.readdirSync(SLOTS_DIR)) {
|
|
324
|
+
if (!f.startsWith("slot_") || !f.endsWith(".json")) continue;
|
|
325
|
+
const lease = readLease(path.join(SLOTS_DIR, f));
|
|
326
|
+
if (lease && !leaseReclaimable(lease)) continue;
|
|
327
|
+
fs.rmSync(path.join(SLOTS_DIR, f), { force: true });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Runs a function within an acquired global task slot.
|
|
335
|
+
*/
|
|
336
|
+
export async function runQueued(fn, taskEntry, onStart) {
|
|
337
|
+
const slot = await acquireTaskSlot(taskEntry);
|
|
338
|
+
if (!slot) {
|
|
339
|
+
return (
|
|
340
|
+
taskEntry?.result ?? {
|
|
341
|
+
isError: true,
|
|
342
|
+
text: "Task cancelled before acquiring an execution slot.",
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
// Record the live slot handle on the task entry so a cancel path (tools.js
|
|
347
|
+
// `cancel`, the HTTP /task/:id/cancel, or cancelAllTasks) can release the
|
|
348
|
+
// slot. Cleared in the finally below once the slot is released.
|
|
349
|
+
if (taskEntry) taskEntry.slot = slot;
|
|
350
|
+
if (taskEntry?.done || taskEntry?.status === "cancelled") {
|
|
351
|
+
releaseTaskSlot(slot);
|
|
352
|
+
if (taskEntry) taskEntry.slot = null;
|
|
353
|
+
return (
|
|
354
|
+
taskEntry?.result ?? {
|
|
355
|
+
isError: true,
|
|
356
|
+
text: "Task cancelled before execution.",
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (taskEntry && !taskEntry.done) {
|
|
361
|
+
taskEntry.status = "executing";
|
|
362
|
+
taskEntry.startedAt = Date.now();
|
|
363
|
+
if (typeof onStart === "function") {
|
|
364
|
+
onStart(taskEntry);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
return await fn();
|
|
369
|
+
} finally {
|
|
370
|
+
releaseTaskSlot(slot);
|
|
371
|
+
if (taskEntry) taskEntry.slot = null;
|
|
372
|
+
}
|
|
373
|
+
}
|