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.
- package/README.md +3 -3
- package/bin/agentctl.mjs +56 -10
- package/package.json +1 -1
- package/src/config.mjs +73 -1
- package/src/coverage.mjs +2 -6
- package/src/engine.mjs +3 -5
- package/src/evidence.mjs +29 -18
- package/src/git.mjs +56 -6
- package/src/mutation.mjs +2 -8
- package/src/security.mjs +754 -42
- package/src/stack-detector.mjs +40 -3
- package/src/state.mjs +72 -16
- package/src/test-paths.mjs +68 -0
package/src/stack-detector.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
659
|
+
pid: ownerPid,
|
|
606
660
|
processStartTime: startTime,
|
|
607
661
|
starttime: startTime,
|
|
662
|
+
leased,
|
|
608
663
|
nonce: randomUUID(),
|
|
609
664
|
hostname: hostname(),
|
|
610
|
-
acquiredAt:
|
|
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
|
+
}
|