jules-orchestrator-kit 0.57.0 → 0.59.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.
@@ -1,5 +1,42 @@
1
1
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
+ import { whichBinary } from "./provider-readiness.mjs";
4
+
5
+ /**
6
+ * The Python interpreter to invoke, by whatever name this machine has it under.
7
+ *
8
+ * `python3` does not exist on a default Windows install (the launcher is `py`,
9
+ * the Store package is `python`), so a hardcoded `python3` made every Python
10
+ * command in the kit unrunnable there.
11
+ */
12
+ function pythonBin(env = process.env) {
13
+ for (const name of ["python3", "python", "py"]) {
14
+ if (whichBinary(name, env)) return name;
15
+ }
16
+ return "python3";
17
+ }
18
+
19
+ /**
20
+ * The command that runs a pytest suite.
21
+ *
22
+ * Not `pytest`. Invoked as a bare console script, pytest does not put the
23
+ * working directory on `sys.path`; invoked as `-m`, Python does. So the most
24
+ * ordinary Python layout there is — a module at the root, its test under
25
+ * `tests/` importing it — collapsed at collection with `ModuleNotFoundError:
26
+ * No module named 'calc'`, and the gate reported exit 4 on a project whose
27
+ * suite passes perfectly when the developer types `python3 -m pytest`. A
28
+ * first-run rejection of correct code is the most expensive failure this tool
29
+ * can produce: it teaches the user the gate is wrong.
30
+ *
31
+ * Falls back to the bare console script only when no interpreter can be found
32
+ * to host the module.
33
+ */
34
+ export function pytestCmd(env = process.env) {
35
+ for (const name of ["python3", "python", "py"]) {
36
+ if (whichBinary(name, env)) return `${name} -m pytest`;
37
+ }
38
+ return "pytest";
39
+ }
3
40
 
4
41
  /**
5
42
  * Generate a zero-dependency smoke test file using node:test for untested JS/Generic repos.
@@ -186,7 +223,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
186
223
  }
187
224
  if (existsSync(join(projectRoot, "pyproject.toml")) || existsSync(join(projectRoot, "requirements.txt")) || existsSync(join(projectRoot, "setup.py"))) {
188
225
  const triggerFile = existsSync(join(projectRoot, "pyproject.toml")) ? "pyproject.toml" : existsSync(join(projectRoot, "requirements.txt")) ? "requirements.txt" : "setup.py";
189
- return { ...container, stack: "python", testCmd: "pytest", buildCmd: "python3 -m compileall -q .", triggerFile };
226
+ return { ...container, stack: "python", testCmd: pytestCmd(), buildCmd: `${pythonBin()} -m compileall -q .`, triggerFile };
190
227
  }
191
228
  if (existsSync(join(projectRoot, "mix.exs"))) {
192
229
  return { ...container, stack: "mix", testCmd: "mix test", buildCmd: "mix compile", triggerFile: "mix.exs" };
@@ -270,7 +307,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
270
307
  }
271
308
  const pyFile = rootFiles.find((f) => f.endsWith(".py"));
272
309
  if (pyFile) {
273
- return { ...container, stack: "python", testCmd: "pytest", buildCmd: "python3 -m compileall -q .", triggerFile: pyFile };
310
+ return { ...container, stack: "python", testCmd: pytestCmd(), buildCmd: `${pythonBin()} -m compileall -q .`, triggerFile: pyFile };
274
311
  }
275
312
  } catch (_) {}
276
313
 
@@ -699,7 +736,7 @@ export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
699
736
  if (detected.stack === "php" || detected.stack === "laravel" || detected.stack === "wordpress") {
700
737
  testCmd = 'php -l $(find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*")';
701
738
  } else if (detected.stack === "python") {
702
- testCmd = "python3 -m compileall -q -x '^\\./(\\.\\venv|venv|node_modules|\\.git)/' .";
739
+ testCmd = `${pythonBin()} -m compileall -q -x '^\\./(\\.\\venv|venv|node_modules|\\.git)/' .`;
703
740
  } else if (detected.stack === "dotnet") {
704
741
  testCmd = "dotnet build --no-incremental --nologo";
705
742
  } else if (detected.stack === "cargo") {
package/src/state.mjs CHANGED
@@ -530,6 +530,54 @@ export function isPidAlive(pid, expectedStartTime = null) {
530
530
  return true;
531
531
  }
532
532
 
533
+ export const LOCK_TTL_MS = 7200000;
534
+
535
+ /**
536
+ * The epoch-ms instant a lock record stops holding.
537
+ *
538
+ * Records written before leases existed carry only `acquiredAt`, so they keep
539
+ * the two-hour window they were reaped on before.
540
+ */
541
+ function lockExpiry(record) {
542
+ if (!record) return 0;
543
+ if (record.expiresAt) {
544
+ const t = new Date(record.expiresAt).getTime();
545
+ if (Number.isFinite(t)) return t;
546
+ }
547
+ if (record.acquiredAt) {
548
+ const t = new Date(record.acquiredAt).getTime();
549
+ if (Number.isFinite(t)) return t + LOCK_TTL_MS;
550
+ }
551
+ return Infinity;
552
+ }
553
+
554
+ /**
555
+ * Is this lock still held?
556
+ *
557
+ * There are two kinds of holder and only one of them has a process worth
558
+ * asking about.
559
+ *
560
+ * An in-process caller — the engine, the swarm — holds the lock for as long as
561
+ * it runs, so its pid is a real witness: if it died the lock is garbage, and
562
+ * reaping on a dead pid is what stops a crash from wedging the repo for the
563
+ * full TTL.
564
+ *
565
+ * `agentctl lock acquire` is the opposite. That process writes the file and
566
+ * exits *by design* — the agent it speaks for lives in some other process, on
567
+ * another machine, or has not started yet. Asking whether the CLI that wrote
568
+ * the record is alive therefore always answered "no", so the very next acquire
569
+ * reaped the lock and handed the same files to a second agent, telling both
570
+ * they had exclusive access. Two holders who each believe they are alone is
571
+ * worse than no lock at all. A record written that way marks itself `leased`,
572
+ * and then only its expiry decides.
573
+ */
574
+ export function isLockLive(record) {
575
+ if (!record) return false;
576
+ if (Date.now() >= lockExpiry(record)) return false;
577
+ if (record.leased === true) return true;
578
+ return isPidAlive(record.pid, record.processStartTime ?? record.starttime ?? null);
579
+ }
580
+
533
581
  function assertSafeTaskId(taskId) {
534
582
  if (typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(taskId) || basename(taskId) !== taskId) {
535
583
  throw new Error("Invalid task id for lock path");
@@ -546,15 +594,16 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
546
594
  if (existsSync(lockFile)) {
547
595
  try {
548
596
  const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
549
- const recordedStartTime = existing.processStartTime ?? existing.starttime ?? null;
550
- const isAlive = isPidAlive(existing.pid, recordedStartTime);
551
- const isExpired = existing.acquiredAt && Date.now() - new Date(existing.acquiredAt).getTime() > 7200000;
552
-
553
- if (!isAlive || isExpired) {
554
- try { unlinkSync(lockFile); } catch (_) {}
555
- } else {
556
- return { ok: false, holder: existing.agent, taskId, pid: existing.pid };
597
+ if (isLockLive(existing)) {
598
+ return {
599
+ ok: false,
600
+ holder: existing.agent,
601
+ taskId,
602
+ pid: existing.pid,
603
+ expiresAt: existing.expiresAt || null,
604
+ };
557
605
  }
606
+ try { unlinkSync(lockFile); } catch (_) {}
558
607
  } catch (_) {
559
608
  try { unlinkSync(lockFile); } catch (_) {}
560
609
  }
@@ -573,11 +622,7 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
573
622
  if (requested.size > 0) {
574
623
  for (const held of lockStatus(root)) {
575
624
  if (!held || held.taskId === taskId) continue;
576
- const recordedStart = held.processStartTime ?? held.starttime ?? null;
577
- const stillHeld =
578
- isPidAlive(held.pid, recordedStart) &&
579
- !(held.acquiredAt && Date.now() - new Date(held.acquiredAt).getTime() > 7200000);
580
- if (!stillHeld) continue;
625
+ if (!isLockLive(held)) continue;
581
626
 
582
627
  const overlap = (Array.isArray(held.files) ? held.files : [])
583
628
  .map((f) => normalizePath(f))
@@ -588,13 +633,22 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
588
633
  holder: held.agent,
589
634
  taskId: held.taskId,
590
635
  pid: held.pid,
636
+ expiresAt: held.expiresAt || null,
591
637
  conflictingFiles: overlap,
592
638
  };
593
639
  }
594
640
  }
595
641
  }
596
642
 
597
- const startTime = getProcessStartTime(process.pid);
643
+ // `leased` says the holder is not this process. A caller that will stay
644
+ // alive for the duration (the engine, the swarm) leaves it off and gets pid
645
+ // liveness; a one-shot CLI sets it and gets a plain time-bounded lease. See
646
+ // isLockLive.
647
+ const leased = opts?.lease === true;
648
+ const ownerPid = Number.isInteger(opts?.ownerPid) && opts.ownerPid > 0 ? opts.ownerPid : process.pid;
649
+ const ttlMs = Number.isFinite(opts?.ttlMs) && opts.ttlMs > 0 ? opts.ttlMs : LOCK_TTL_MS;
650
+ const startTime = getProcessStartTime(ownerPid);
651
+ const acquiredAt = new Date();
598
652
  const concurrencyGroup = String(opts?.concurrencyGroup || opts?.concurrency_group || "").trim();
599
653
  const payload = {
600
654
  agent: agentName,
@@ -602,12 +656,14 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
602
656
  branch,
603
657
  files,
604
658
  concurrencyGroup,
605
- pid: process.pid,
659
+ pid: ownerPid,
606
660
  processStartTime: startTime,
607
661
  starttime: startTime,
662
+ leased,
608
663
  nonce: randomUUID(),
609
664
  hostname: hostname(),
610
- acquiredAt: new Date().toISOString(),
665
+ acquiredAt: acquiredAt.toISOString(),
666
+ expiresAt: new Date(acquiredAt.getTime() + ttlMs).toISOString(),
611
667
  };
612
668
 
613
669
  let fd;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * One answer to "is this path a test file?".
3
+ *
4
+ * There were five, in five modules, and they disagreed with each other:
5
+ *
6
+ * - `security.mjs` matched the substring `/test/`, so a file in the
7
+ * repository's own root-level `tests/` directory had no leading slash and
8
+ * was not a test file. The entire tamper guard — skip injection, vacuous
9
+ * assertions, commented-out assertions, removal, weakening, expectation
10
+ * rewrites — was therefore switched off for the standard pytest layout
11
+ * (`tests/test_calc.py`), the standard Rust integration layout
12
+ * (`tests/integration.rs`) and every RSpec suite (`spec/`).
13
+ * - `mutation.mjs` had the same substring bug, in the opposite direction:
14
+ * those same files were not excluded, so the harness mutated operators
15
+ * inside the tests themselves and scored the result.
16
+ * - `engine.mjs` never looked for `_test.`, so `strictTestLock` did not
17
+ * consider a Go test file to be a test file.
18
+ * - `coverage.mjs` and `evidence.mjs` each had a third and fourth spelling.
19
+ *
20
+ * A predicate this load-bearing cannot have five definitions. This is the one
21
+ * they all use.
22
+ *
23
+ * The classification errs toward "yes". Four of the five callers get stricter
24
+ * when it does — the tamper guard applies, the integrity hash covers more, the
25
+ * lock triggers — and the fifth (mutation) has nothing to lose, because
26
+ * mutating an operator inside a test proves nothing about the code under test.
27
+ */
28
+
29
+ /**
30
+ * Directory names that mean "everything below here is a test".
31
+ *
32
+ * Matched as whole path segments, not substrings: `latest/` is not `test/`,
33
+ * and `myspec/` is not `spec/`.
34
+ */
35
+ const TEST_DIR_SEGMENTS = new Set(["test", "tests", "spec", "specs", "__tests__", "__test__"]);
36
+
37
+ /**
38
+ * True when `file` names a test file under any convention the kit supports.
39
+ *
40
+ * Covers, by directory: `test/`, `tests/`, `spec/`, `specs/`, `__tests__/` at
41
+ * any depth including the repository root. By filename: `foo.test.js`,
42
+ * `foo.spec.ts`, `foo_test.go`, `foo_spec.rb`, `test_calc.py` (pytest),
43
+ * `spec_helper.rb`, and Foundry's `Foo.t.sol` / `FooTest.sol`.
44
+ *
45
+ * @param {string} file - Repo-relative path, either separator.
46
+ * @returns {boolean}
47
+ */
48
+ export function isTestPath(file) {
49
+ if (!file || typeof file !== "string") return false;
50
+ const segments = file.replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean);
51
+ if (segments.length === 0) return false;
52
+
53
+ for (let i = 0; i < segments.length - 1; i++) {
54
+ if (TEST_DIR_SEGMENTS.has(segments[i])) return true;
55
+ }
56
+
57
+ const base = segments[segments.length - 1];
58
+ return (
59
+ base.includes(".test.") ||
60
+ base.includes(".spec.") ||
61
+ base.includes("_test.") ||
62
+ base.includes("_spec.") ||
63
+ base.startsWith("test_") ||
64
+ base.startsWith("spec_") ||
65
+ base.endsWith("test.sol") ||
66
+ base.endsWith(".t.sol")
67
+ );
68
+ }