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.
Files changed (42) hide show
  1. package/README.md +487 -0
  2. package/bin/castor.js +706 -0
  3. package/index.js +206 -0
  4. package/package.json +97 -0
  5. package/skills/canary-test-staging/SKILL.md +24 -0
  6. package/skills/evo-mutation-rollback/SKILL.md +29 -0
  7. package/skills/hypothesis-generation/SKILL.md +26 -0
  8. package/skills/traceback-condensing/SKILL.md +26 -0
  9. package/src/castor_runner.js +469 -0
  10. package/src/config.js +1204 -0
  11. package/src/env.js +10 -0
  12. package/src/evo_engine.js +214 -0
  13. package/src/harness/core/events.js +75 -0
  14. package/src/harness/core/kernel.js +209 -0
  15. package/src/harness/evo/evaluator.js +156 -0
  16. package/src/harness/evo/evo_operator.js +550 -0
  17. package/src/harness/evo/lineage_dag.js +383 -0
  18. package/src/harness/evo/trace_repair.js +173 -0
  19. package/src/harness/evo/watchdog.js +72 -0
  20. package/src/harness/loop_detector.js +135 -0
  21. package/src/harness/runner.js +1216 -0
  22. package/src/harness/services/ast_service.js +1813 -0
  23. package/src/harness/services/event_logger.js +275 -0
  24. package/src/harness/services/mcp_bridge.js +408 -0
  25. package/src/harness/services/provider_vllm.js +728 -0
  26. package/src/harness/services/sandbox_fs.js +1238 -0
  27. package/src/harness/services/searxng_lifecycle.js +254 -0
  28. package/src/harness/services/shell_executor.js +264 -0
  29. package/src/harness/services/shell_validator.js +506 -0
  30. package/src/harness/services/web_service.js +828 -0
  31. package/src/platform.js +344 -0
  32. package/src/repetition_detector.js +139 -0
  33. package/src/semaphore.js +373 -0
  34. package/src/server_lifecycle.js +781 -0
  35. package/src/skills.js +400 -0
  36. package/src/state_pruner.js +392 -0
  37. package/src/task_registry.js +1357 -0
  38. package/src/telemetry.js +638 -0
  39. package/src/tools.js +997 -0
  40. package/src/wsl_bridge.js +629 -0
  41. package/src/wsl_env.js +171 -0
  42. package/stream_proxy.js +453 -0
@@ -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
+ }