jules-orchestrator-kit 0.55.0 → 0.56.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 CHANGED
@@ -110,6 +110,7 @@ repository rather than from a template.
110
110
 
111
111
  | What differs | How it is resolved | Inspect / override |
112
112
  | :--- | :--- | :--- |
113
+ | **Which suites to run** | A monorepo change resolves to the sub-projects it touches (`verify.scope: affected`), widening back to the root command as soon as it reaches a shared file. Off by default, on for repositories `init` detects as monorepos. | `agentctl check --json` · `verify.scope` in `.agent/config.yml` |
113
114
  | **The stack** | `detectStack()` recognises 24+ ecosystems (Cargo, Go, Python/Django, Maven/Gradle, .NET, PHP/Laravel, Ruby, Elixir, Swift, Flutter/Dart, CMake, Bun, Deno, Node + Turbo/pnpm/Nx workspaces) and derives the setup, lint, test and build commands from the manifest it finds. | `agentctl doctor` · `verify:` in `.agent/config.yml` |
114
115
  | **The agent** | `provider:` selects Google Jules (hosted REST), the Claude Code CLI, the Codex CLI or the Gemini CLI. Readiness means a credential for the hosted one and a binary on `PATH` for the local ones — never both. | `agentctl providers` · `agentctl init --provider <name>` |
115
116
  | **How hard to verify** | `verify.profile` expands at load time into a stage pipeline that skips gates the runtime cannot support, and says which and why. | `agentctl profile` · `agentctl profile --set max` |
@@ -203,7 +204,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
203
204
  * **Fail-Closed Security & Secret Redaction:** Evaluates explicit Deny rules before Allow rules against canonicalized, case-folded paths. Redacts high-entropy keys and base64-encoded credentials (such as Kubernetes `Secret` manifests).
204
205
  * **Complexity & Cost Router:** Zero-dependency heuristic classifier (`src/router.mjs`) routing mechanical tasks to lightweight models while reserving primary models for complex refactors, with a `node --check` syntax-verification gate that transparently escalates a FAST-tier result to the primary provider if it left broken JS on disk.
205
206
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
206
- * **Verified Test Suite:** Tested with **842 unit tests across 112 suites passing in < 14.0s**.
207
+ * **Verified Test Suite:** Tested with **851 unit tests across 115 suites passing in < 14.0s**.
207
208
 
208
209
  <br/>
209
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents — Google Jules, Claude Code, Codex and Gemini CLI.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { cpus } from "node:os";
5
6
 
6
7
  const root = process.cwd();
7
8
  const testDir = join(root, "test");
@@ -24,10 +25,84 @@ const [major, minor] = process.versions.node.split(".").map(Number);
24
25
  const supportsTestTimeout = major > 20 || (major === 20 && minor >= 6);
25
26
  const timeoutArgs = supportsTestTimeout ? [`--test-timeout=${timeoutMs}`] : [];
26
27
 
27
- const res = spawnSync(process.execPath, ["--test", ...timeoutArgs, ...testFiles], {
28
+ // Node runs one test file per core by default. Several suites here spawn a
29
+ // verification command of their own — `npm test`, and in the monorepo fixtures
30
+ // an `npm test --workspaces` that fans out to one node per package — so the
31
+ // real peak is a multiple of the file count, not the file count. On a
32
+ // twelve-core laptop that saturates every core for the length of the run,
33
+ // which cooks the machine and makes the throughput assertions (1000 telemetry
34
+ // appends under 6s) fail for reasons that have nothing to do with the code.
35
+ //
36
+ // Half the cores keeps the suite comfortably parallel while leaving room for
37
+ // the children it spawns. JULES_TEST_CONCURRENCY overrides it — CI runners
38
+ // with two cores are already below this and are unaffected.
39
+ const cpuCount = Math.max(1, cpus().length);
40
+ const envConcurrency = Number(process.env.JULES_TEST_CONCURRENCY);
41
+ const concurrency = Number.isFinite(envConcurrency) && envConcurrency > 0
42
+ ? Math.floor(envConcurrency)
43
+ : Math.max(2, Math.floor(cpuCount / 2));
44
+ const concurrencyArgs = supportsTestTimeout ? [`--test-concurrency=${concurrency}`] : [];
45
+
46
+ // The runner leads its own process group, and nothing outlives it.
47
+ //
48
+ // `spawnSync` kills only the direct child when a run is interrupted, and this
49
+ // suite's children spawn children of their own — a verification command, an
50
+ // `npm` that fans out to a node per workspace, a git subprocess per fixture.
51
+ // Interrupting a run therefore orphaned everything below the first level, and
52
+ // those orphans kept running and kept spawning: a laptop accumulated enough of
53
+ // them across several interrupted runs to exhaust 32 GB of RAM and 24 GB of
54
+ // swap, and two CI runners logged pages of "Terminate orphan process" at the
55
+ // end of a job that had already failed.
56
+ //
57
+ // Detaching makes the child a process-group leader, so one signal to the
58
+ // negated pid reaches the whole tree.
59
+ const child = spawn(process.execPath, ["--test", ...timeoutArgs, ...concurrencyArgs, ...testFiles], {
28
60
  cwd: root,
29
61
  stdio: "inherit",
30
62
  env: process.env,
63
+ detached: process.platform !== "win32",
64
+ });
65
+
66
+ /**
67
+ * Kill the runner and everything it spawned.
68
+ *
69
+ * POSIX takes a signal to `-pid`, which addresses the whole process group.
70
+ * Windows has no such concept, so `taskkill /T` walks the tree instead.
71
+ */
72
+ function reap(signal = "SIGKILL") {
73
+ if (!child.pid) return;
74
+ try {
75
+ if (process.platform === "win32") {
76
+ spawnSync("taskkill", ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore" });
77
+ } else {
78
+ process.kill(-child.pid, signal);
79
+ }
80
+ } catch (_) {
81
+ // Already gone, or never had a group. Nothing left to reap either way.
82
+ }
83
+ }
84
+
85
+ // Ctrl-C, a CI cancellation and a harness teardown all arrive as signals; each
86
+ // must take the tree with it rather than detaching it from its parent.
87
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
88
+ process.on(sig, () => {
89
+ reap("SIGTERM");
90
+ // Give the group a moment to unwind, then make sure.
91
+ setTimeout(() => {
92
+ reap("SIGKILL");
93
+ process.exit(130);
94
+ }, 2000).unref();
95
+ });
96
+ }
97
+
98
+ child.on("exit", (code, signal) => {
99
+ // Stragglers outlive a clean exit too: a test that spawned a server and
100
+ // failed before its teardown leaves it running.
101
+ reap("SIGKILL");
102
+ process.exit(signal ? 1 : code ?? 1);
31
103
  });
32
104
 
33
- process.exit(res.status ?? 1);
105
+ child.on("error", (err) => {
106
+ console.error(`Test runner failed to start: ${err.message}`);
107
+ process.exit(1);
108
+ });
package/src/config.mjs CHANGED
@@ -522,6 +522,13 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
522
522
  teardown: rawTeardown ?? autoVerify.teardown ?? "",
523
523
  build: rawBuild ?? autoVerify.build,
524
524
  policy: parsed.verify?.policy ?? autoVerify.policy,
525
+ // "global" runs the repository's own verify commands; "affected" resolves
526
+ // the changed files to their sub-projects and runs only those suites. Opt-in
527
+ // on purpose: silently narrowing which tests run is the same class of defect
528
+ // as approving a change that ran none, and an existing repository's gate
529
+ // must not change meaning on an upgrade. `agentctl init` writes "affected"
530
+ // for a repository it detects as a monorepo.
531
+ scope: parsed.verify?.scope === "affected" ? "affected" : "global",
525
532
  // Whether the gate may approve a change that ran no verification at all.
526
533
  // True by default: "nothing to run" is not a pass, and a security tool that
527
534
  // says APPROVED after checking nothing is worse than no tool. A repository
package/src/engine.mjs CHANGED
@@ -20,6 +20,7 @@ import { resolveRolePrompt } from "./role-resolver.mjs";
20
20
 
21
21
  import { runAssertion } from "./assertions.mjs";
22
22
  import { buildDefaultStages } from "./profiles.mjs";
23
+ import { resolveWorkspaceBoundary } from "./stack-detector.mjs";
23
24
  import {
24
25
  computeDirectoryHash,
25
26
  generateEvidenceManifest,
@@ -201,6 +202,7 @@ export async function gate(opts = {}) {
201
202
  // Read from the base commit like every other trusted field: an
202
203
  // uncommitted `required: false` must not be able to switch the gate off.
203
204
  required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
205
+ scope: parsed.verify?.scope || config.verify.scope || "global",
204
206
  timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
205
207
  };
206
208
  }
@@ -319,9 +321,52 @@ export async function gate(opts = {}) {
319
321
  NODE_OPTIONS: guardNodeOptions,
320
322
  };
321
323
 
324
+ // Node's test runner talks to its children through NODE_TEST_CONTEXT and
325
+ // NODE_CHANNEL_FD. Inherited by a verification command that is itself a
326
+ // `node --test` run, the child switches into child-reporter mode and its
327
+ // failures stop reaching the exit code — the gate then sees exit 0 and
328
+ // approves a change whose tests failed. src/perf.mjs already strips these for
329
+ // the same reason; the gate, which is the one that decides, did not.
330
+ for (const key of Object.keys(testEnv)) {
331
+ if (key.startsWith("NODE_TEST_") || key.startsWith("NODE_CHANNEL_")) delete testEnv[key];
332
+ }
333
+
322
334
  let flakyVerdictResult = null;
323
335
  const verifyTimeout = trustedVerify.timeoutMs || 60000;
324
336
 
337
+ // Monorepo scoping: run the suites the change can actually break.
338
+ //
339
+ // `resolveWorkspaceBoundary()` shipped, was drawn in the architecture
340
+ // diagrams and documented as a headline feature — and was called by nothing.
341
+ // Every change in a monorepo ran the root suite, so a one-package edit was
342
+ // gated on every other package's tests. It resolves the changed files to
343
+ // their sub-projects and composes the per-project commands.
344
+ //
345
+ // It stays opt-in (`verify.scope: affected`) because narrowing what runs is
346
+ // only safe when someone asked for it, and it yields to the global command
347
+ // whenever a shared file is touched or no sub-project command is found — a
348
+ // narrower run that misses the breakage is worse than a slow one.
349
+ let boundary = null;
350
+ if (trustedVerify.scope === "affected" && !(Array.isArray(trustedVerify.stages) && trustedVerify.stages.length > 0)) {
351
+ try {
352
+ boundary = resolveWorkspaceBoundary(files, root);
353
+ } catch (_) {
354
+ boundary = null;
355
+ }
356
+ if (boundary && boundary.isMonorepo && !boundary.globalFallback && boundary.testCmd) {
357
+ trustedVerify = {
358
+ ...trustedVerify,
359
+ test: boundary.testCmd,
360
+ unit: boundary.testCmd,
361
+ build: boundary.buildCmd || trustedVerify.build,
362
+ };
363
+ appendTelemetry(root, "verify_scope_narrowed", {
364
+ projects: boundary.projects.map((p) => p.path),
365
+ testCmd: boundary.testCmd,
366
+ });
367
+ }
368
+ }
369
+
325
370
  // Build sequential execution pipeline (Setup -> Lint -> Test/Unit -> Fuzz -> Invariant -> E2E -> Build -> Server -> Teardown)
326
371
  const stagesToRun = [];
327
372
  if (Array.isArray(trustedVerify.stages) && trustedVerify.stages.length > 0) {
@@ -994,6 +1039,33 @@ export async function dispatch(task = {}, opts = {}) {
994
1039
 
995
1040
  const cleanTask = { ...task, prompt: envelopedPrompt };
996
1041
 
1042
+ // Snapshot the tree before anything can change it.
1043
+ //
1044
+ // `agentctl rollback` shipped, was documented, and could never work:
1045
+ // createCheckpoint() was defined and called from nowhere, so the checkpoint
1046
+ // directory was always empty and the command answered "No checkpoints found"
1047
+ // to everyone who reached for it — at exactly the moment they needed it. An
1048
+ // exec provider edits this working tree directly, and `patch --apply` writes
1049
+ // into it later, so this is the last moment the pre-agent state exists.
1050
+ //
1051
+ // Never fatal: a repository that cannot be snapshotted (no git, no disk) must
1052
+ // still be able to dispatch. A missing checkpoint costs a rollback; a throw
1053
+ // here costs the task.
1054
+ if (!opts.dryRun && opts.checkpoint !== false) {
1055
+ try {
1056
+ const { createCheckpoint } = await import("./ops/checkpoint.mjs");
1057
+ const checkpointId = String(task.id || task.taskId || `dispatch-${Date.now()}`).replace(/[^A-Za-z0-9_.-]/g, "-");
1058
+ const snapshot = createCheckpoint(checkpointId, { root });
1059
+ appendTelemetry(root, "checkpoint_created", {
1060
+ id: snapshot.id,
1061
+ headSha: snapshot.headSha,
1062
+ uncommittedFiles: snapshot.uncommittedFiles?.length || 0,
1063
+ });
1064
+ } catch (err) {
1065
+ console.warn(`⚠️ Could not create a pre-flight checkpoint (${err.message}). \`agentctl rollback\` will not be able to restore this dispatch.`);
1066
+ }
1067
+ }
1068
+
997
1069
  const runDispatch = () => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun });
998
1070
 
999
1071
  // An estimated ceiling may warn but must not block: refusing a dispatch the
package/src/provider.mjs CHANGED
@@ -673,10 +673,17 @@ export function createProvider(spec = "jules", config = {}) {
673
673
  },
674
674
 
675
675
  async getSession(sessionId, ctx = {}) {
676
- if (!sessionId || typeof sessionId !== "string") {
676
+ if (!ctx || typeof ctx !== "object") ctx = {};
677
+ // `listSources()` reuses this method purely as an authenticated GET, and
678
+ // passes `customUrl` with no session to fetch. The guard rejected the
679
+ // empty id before the url was even looked at, so listing a repository's
680
+ // connected sources threw a TypeError against the live API every time —
681
+ // a failure no dry-run or unit test could reach, because both stop
682
+ // before the request. A session id is required only when the url is
683
+ // going to be built out of one.
684
+ if (!ctx.customUrl && (!sessionId || typeof sessionId !== "string")) {
677
685
  throw new TypeError("getSession() requires a valid sessionId string");
678
686
  }
679
- if (!ctx || typeof ctx !== "object") ctx = {};
680
687
 
681
688
  const pool = ctx.tokenPool || config.tokenPool || TokenPool.fromEnv(config);
682
689
  const rawToken = pool.getNextToken() || process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
@@ -160,6 +160,18 @@ export async function applySessionPatch(sessionId, opts = {}) {
160
160
 
161
161
  // 2. If apply requested, execute git apply
162
162
  if (opts.apply) {
163
+ // Writing an agent's patch into the working tree is the other moment a
164
+ // rollback target has to exist. The hosted provider works server-side, so
165
+ // dispatch never touched this tree — this is the first time it changes.
166
+ if (opts.checkpoint !== false) {
167
+ try {
168
+ const { createCheckpoint } = await import("./ops/checkpoint.mjs");
169
+ createCheckpoint(`patch-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "-")}`, { root });
170
+ } catch (err) {
171
+ console.warn(`⚠️ Could not snapshot before applying the patch (${err.message}); \`agentctl rollback\` will not cover it.`);
172
+ }
173
+ }
174
+
163
175
  const applyRes = spawnSync("git", ["apply", "-"], {
164
176
  cwd: root,
165
177
  input: res.patch,
package/src/state.mjs CHANGED
@@ -560,6 +560,40 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
560
560
  }
561
561
  }
562
562
 
563
+ // The lock file is named after the task, so `existsSync(lockFile)` above only
564
+ // ever asked "is this same task already running?". The `files` argument — the
565
+ // whole point of the call — was stored as metadata and never compared against
566
+ // anything, so two agents could hold locks on the same file at the same time
567
+ // and each be told it had exclusive access. Check the paths, not just the id.
568
+ const requested = new Set(
569
+ (Array.isArray(files) ? files : [])
570
+ .filter((f) => typeof f === "string" && f)
571
+ .map((f) => normalizePath(f))
572
+ );
573
+ if (requested.size > 0) {
574
+ for (const held of lockStatus(root)) {
575
+ 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;
581
+
582
+ const overlap = (Array.isArray(held.files) ? held.files : [])
583
+ .map((f) => normalizePath(f))
584
+ .filter((f) => requested.has(f));
585
+ if (overlap.length > 0) {
586
+ return {
587
+ ok: false,
588
+ holder: held.agent,
589
+ taskId: held.taskId,
590
+ pid: held.pid,
591
+ conflictingFiles: overlap,
592
+ };
593
+ }
594
+ }
595
+ }
596
+
563
597
  const startTime = getProcessStartTime(process.pid);
564
598
  const concurrencyGroup = String(opts?.concurrencyGroup || opts?.concurrency_group || "").trim();
565
599
  const payload = {
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
4
4
  import { suggestProvider, detectAvailableProviders } from "./provider-readiness.mjs";
5
5
  import { detectDefaultBranch } from "./git.mjs";
6
+ import { resolveWorkspaceBoundary } from "./stack-detector.mjs";
6
7
  import { PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
7
8
  import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
8
9
  import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
@@ -180,6 +181,21 @@ export function planInit(root = process.cwd(), options = {}) {
180
181
  // whose git chose `master`, or whose team standardised on `develop`.
181
182
  const baseBranch = options.baseBranch || existingConfig.base_branch || detectDefaultBranch(root);
182
183
 
184
+ // A monorepo that runs every package's suite for a one-package change is the
185
+ // complaint the boundary resolver was written to answer, so a repository
186
+ // detected as one starts with it on. Existing repositories keep whatever they
187
+ // already stated; nobody's gate changes meaning because they upgraded.
188
+ const detectedMonorepo = (() => {
189
+ if (options.verifyScope) return options.verifyScope === "affected";
190
+ if (existingConfig.verify?.scope) return existingConfig.verify.scope === "affected";
191
+ try {
192
+ return Boolean(resolveWorkspaceBoundary([], root).isMonorepo);
193
+ } catch (_) {
194
+ return false;
195
+ }
196
+ })();
197
+ const verifyScope = detectedMonorepo ? "affected" : "global";
198
+
183
199
  const limitsBlock = isCustomLimits
184
200
  ? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
185
201
  : "";
@@ -195,6 +211,9 @@ ${limitsBlock}
195
211
  verify:
196
212
  # minimal | standard | max — see: agentctl profile --list
197
213
  profile: ${profile}
214
+ # global runs the repository's own commands; affected resolves changed files
215
+ # to their sub-projects and runs only those suites (monorepos)
216
+ scope: ${verifyScope}
198
217
  test: "${verify.test}"
199
218
  build: "${verify.build}"
200
219
  lint: "${verify.lint}"
@@ -236,6 +255,7 @@ allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`
236
255
  provider,
237
256
  profile,
238
257
  baseBranch,
258
+ verifyScope,
239
259
  verify,
240
260
  limits,
241
261
  presets: selectedPresets,