jules-orchestrator-kit 0.2.9 ā 0.5.1
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/.agent/rules/jules-protocol.md +33 -0
- package/.github/workflows/agent-scope-guard.yml +56 -0
- package/.github/workflows/publish.yml +0 -3
- package/JULES_RULES_TEMPLATE.md +52 -0
- package/README.md +208 -165
- package/bin/init.js +65 -6
- package/index.mjs +11 -0
- package/package.json +15 -5
- package/scripts/jules-cleanup.mjs +200 -0
- package/scripts/jules-create.mjs +47 -0
- package/scripts/jules-dispatch.mjs +142 -98
- package/scripts/jules-nightly.mjs +42 -28
- package/scripts/jules-queue-runner.mjs +100 -30
- package/scripts/jules-scan-todos.mjs +142 -0
- package/scripts/jules-self-audit.mjs +197 -44
- package/scripts/jules-status.mjs +57 -0
- package/scripts/jules-swarm.mjs +106 -31
- package/scripts/lock-manager.mjs +147 -0
- package/scripts/utils.mjs +94 -0
- package/.agent/history/2026-07-27-dispatch-test-swarm-task.md +0 -9
|
@@ -1,23 +1,28 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
1
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
import { resolveWorkspaceExecutionBoundary } from "./command-resolver.mjs";
|
|
8
|
+
import { log, logToHistory } from "./utils.mjs";
|
|
6
9
|
|
|
7
|
-
function
|
|
10
|
+
function runGitCommand(args, ignoreError = false) {
|
|
8
11
|
try {
|
|
9
|
-
return
|
|
12
|
+
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
10
13
|
} catch (error) {
|
|
11
14
|
if (ignoreError) return "";
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
log.error(`Git command failed: git ${args.join(" ")}`);
|
|
16
|
+
log.error(error.message);
|
|
14
17
|
process.exit(1);
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function matchGlob(filepath, globPattern) {
|
|
19
22
|
const cleanPath = filepath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
20
|
-
|
|
23
|
+
// Normalize consecutive glob wildcards (e.g., "**/**" -> "**")
|
|
24
|
+
const normalizedGlob = globPattern.replace(/\\/g, "/").replace(/(?:\*\*\/)+/g, "**/");
|
|
25
|
+
const segments = normalizedGlob.split("/");
|
|
21
26
|
const regexParts = [];
|
|
22
27
|
for (let i = 0; i < segments.length; i++) {
|
|
23
28
|
const seg = segments[i];
|
|
@@ -33,12 +38,14 @@ export function matchGlob(filepath, globPattern) {
|
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
let patternStr = regexParts.join("/");
|
|
36
|
-
patternStr = patternStr.replace(/(
|
|
37
|
-
if (
|
|
38
|
-
if (
|
|
39
|
-
if (
|
|
41
|
+
patternStr = patternStr.replace(/(?:^|\/)\*\*(?:\/|$)/g, (m) => {
|
|
42
|
+
if (m === "**") return ".*";
|
|
43
|
+
if (m === "/**/") return "(?:/|/.*/)";
|
|
44
|
+
if (m === "/**") return "(?:/.*)?";
|
|
45
|
+
if (m === "**/") return "(?:.*/)?";
|
|
40
46
|
return ".*";
|
|
41
47
|
});
|
|
48
|
+
patternStr = patternStr.replace(/\*\*/g, ".*");
|
|
42
49
|
return new RegExp(`^${patternStr}$`, "i").test(cleanPath);
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -141,45 +148,58 @@ export function logAuditMetrics(metrics) {
|
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
export function runSelfAudit() {
|
|
144
|
-
|
|
151
|
+
log.header("Running Jules PR Self-Audit Gatekeeper...");
|
|
145
152
|
|
|
146
153
|
const targetBranch = process.env.BASE_BRANCH || "main";
|
|
147
154
|
const SAFE_BRANCH = /^[a-zA-Z0-9._\/-]+$/;
|
|
148
155
|
if (!SAFE_BRANCH.test(targetBranch)) {
|
|
149
|
-
|
|
150
|
-
process.exit(
|
|
156
|
+
log.error(`FATAL: Invalid BASE_BRANCH "${targetBranch}". Must match ^[a-zA-Z0-9._\\/-]+$`);
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
log.info(`Target Branch: ${targetBranch}`);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
163
|
+
} catch {
|
|
164
|
+
log.error("FATAL: git is not installed or not in PATH.");
|
|
165
|
+
process.exit(2);
|
|
151
166
|
}
|
|
152
|
-
console.log(`šÆ Target Branch: ${targetBranch}`);
|
|
153
167
|
|
|
154
168
|
if (process.env.CI) {
|
|
155
|
-
|
|
156
|
-
|
|
169
|
+
log.info("āļø CI environment detected. Fetching merge-base history...");
|
|
170
|
+
runGitCommand(["fetch", "origin", targetBranch, "--depth=100"], true);
|
|
171
|
+
const tmpRef = runGitCommand(["rev-parse", "--verify", `origin/${targetBranch}`], true) ? `origin/${targetBranch}` : targetBranch;
|
|
172
|
+
const tmpMerge = runGitCommand(["merge-base", "HEAD", tmpRef], true);
|
|
173
|
+
if (!tmpMerge) {
|
|
174
|
+
log.warn("Shallow fetch failed to find merge-base, executing full unshallow...");
|
|
175
|
+
runGitCommand(["fetch", "origin", targetBranch, "--unshallow"], true);
|
|
176
|
+
}
|
|
157
177
|
}
|
|
158
178
|
|
|
159
|
-
const mainRef =
|
|
160
|
-
const mergeBase =
|
|
179
|
+
const mainRef = runGitCommand(["rev-parse", "--verify", `origin/${targetBranch}`], true) ? `origin/${targetBranch}` : targetBranch;
|
|
180
|
+
const mergeBase = runGitCommand(["merge-base", "HEAD", mainRef], true);
|
|
161
181
|
|
|
162
182
|
if (!mergeBase) {
|
|
163
|
-
|
|
164
|
-
process.exit(
|
|
183
|
+
log.error(`FATAL: Could not compute merge-base with ${mainRef}. Make sure git history is unshallowed.`);
|
|
184
|
+
process.exit(2);
|
|
165
185
|
}
|
|
166
186
|
|
|
167
|
-
|
|
187
|
+
log.info(`Merge-Base Hash: ${mergeBase}`);
|
|
168
188
|
|
|
169
|
-
const rawDiffFiles =
|
|
170
|
-
.split("\
|
|
171
|
-
.map((f) => f.trim())
|
|
189
|
+
const rawDiffFiles = runGitCommand(["diff", "-z", "--name-only", `${mergeBase}...HEAD`])
|
|
190
|
+
.split("\0")
|
|
191
|
+
.map((f) => f.trim().replace(/^["']|["']$/g, ""))
|
|
172
192
|
.filter(Boolean);
|
|
173
193
|
|
|
174
194
|
const isBloatFile = (file) => /(\.lock|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.png|\.jpg|\.jpeg|\.pdf|\.min\.js|\.map)$/i.test(file);
|
|
175
195
|
const changedCodeFiles = rawDiffFiles.filter((f) => !isBloatFile(f));
|
|
176
196
|
|
|
177
|
-
|
|
178
|
-
changedCodeFiles.forEach((file) =>
|
|
197
|
+
log.info(`Modified Code Files (${changedCodeFiles.length} of ${rawDiffFiles.length} total changes):`);
|
|
198
|
+
changedCodeFiles.forEach((file) => log.dim(` - ${file}`));
|
|
179
199
|
|
|
180
200
|
// Fail closed: Load security config exclusively from base branch (mainRef).
|
|
181
201
|
// Never fall back to working-tree config which an untrusted PR could craft.
|
|
182
|
-
const trustedConfig =
|
|
202
|
+
const trustedConfig = runGitCommand(["show", `${mainRef}:.agent/jules.yml`], true);
|
|
183
203
|
const forbiddenPatterns = loadForbiddenPatterns(trustedConfig);
|
|
184
204
|
const allowedPatterns = loadAllowedPatterns(trustedConfig);
|
|
185
205
|
|
|
@@ -191,16 +211,16 @@ export function runSelfAudit() {
|
|
|
191
211
|
});
|
|
192
212
|
|
|
193
213
|
if (violations.length > 0) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
violations.forEach((v) =>
|
|
197
|
-
process.exit(
|
|
214
|
+
log.error("RESTRICTED FILE VIOLATION DETECTED!");
|
|
215
|
+
log.error("Jules PR attempted to modify forbidden system files:");
|
|
216
|
+
violations.forEach((v) => log.error(` - ${v}`));
|
|
217
|
+
process.exit(3);
|
|
198
218
|
}
|
|
199
|
-
|
|
219
|
+
log.success("Restricted File Boundary Check: PASSED");
|
|
200
220
|
|
|
201
|
-
|
|
221
|
+
log.info("Resolving Dynamic Verification Suite...");
|
|
202
222
|
const resolvedCmds = resolveWorkspaceExecutionBoundary(changedCodeFiles, process.cwd());
|
|
203
|
-
|
|
223
|
+
log.info(`Discovered Execution Scope: ${resolvedCmds.source}`);
|
|
204
224
|
|
|
205
225
|
const startTime = Date.now();
|
|
206
226
|
let testPassed = true;
|
|
@@ -217,23 +237,23 @@ export function runSelfAudit() {
|
|
|
217
237
|
};
|
|
218
238
|
|
|
219
239
|
if (resolvedCmds.testCmd) {
|
|
220
|
-
|
|
240
|
+
log.step("ā¶", `Running Test Verification: ${resolvedCmds.testCmd}`);
|
|
221
241
|
if (!runVerification(resolvedCmds.testCmd)) {
|
|
222
242
|
testPassed = false;
|
|
223
|
-
|
|
243
|
+
log.error(`Test verification failed: ${resolvedCmds.testCmd}`);
|
|
224
244
|
}
|
|
225
245
|
}
|
|
226
246
|
|
|
227
247
|
if (testPassed && resolvedCmds.buildCmd) {
|
|
228
|
-
|
|
248
|
+
log.step("ā¶", `Running Build Verification: ${resolvedCmds.buildCmd}`);
|
|
229
249
|
if (!runVerification(resolvedCmds.buildCmd)) {
|
|
230
250
|
testPassed = false;
|
|
231
|
-
|
|
251
|
+
log.error(`Build verification failed: ${resolvedCmds.buildCmd}`);
|
|
232
252
|
}
|
|
233
253
|
}
|
|
234
254
|
|
|
235
255
|
if (!resolvedCmds.testCmd && !resolvedCmds.buildCmd) {
|
|
236
|
-
|
|
256
|
+
log.info("No build or test scripts found. Running git status check.");
|
|
237
257
|
runCommand("git status");
|
|
238
258
|
}
|
|
239
259
|
|
|
@@ -247,17 +267,150 @@ export function runSelfAudit() {
|
|
|
247
267
|
});
|
|
248
268
|
|
|
249
269
|
if (!testPassed) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
log.error("OODA SELF-HEALING FEEDBACK LOG:");
|
|
271
|
+
log.dim(failureLog.slice(-1500));
|
|
272
|
+
|
|
273
|
+
// OODA Circuit Breaker: Fingerprint failure trace using SHA-256
|
|
274
|
+
const normalizedError = failureLog
|
|
275
|
+
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/g, "TIMESTAMP")
|
|
276
|
+
.replace(/0x[0-9a-fA-F]+/g, "HEX")
|
|
277
|
+
.replace(/\d+/g, "NUM")
|
|
278
|
+
.trim();
|
|
279
|
+
const errorHash = crypto.createHash("sha256").update(normalizedError).digest("hex");
|
|
280
|
+
|
|
281
|
+
const circuitFile = path.resolve(process.cwd(), ".agent/history/ooda-circuit.json");
|
|
282
|
+
let prevCircuit = { hash: "", count: 0 };
|
|
283
|
+
if (fs.existsSync(circuitFile)) {
|
|
284
|
+
try {
|
|
285
|
+
prevCircuit = JSON.parse(fs.readFileSync(circuitFile, "utf-8"));
|
|
286
|
+
} catch (_) {}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (prevCircuit.hash === errorHash && prevCircuit.count >= 1) {
|
|
290
|
+
log.error("ā OODA CIRCUIT BREAKER TRIPPED: Consecutive identical failure detected.");
|
|
291
|
+
log.error("Agent generated code with identical error trace twice in a row. Aborting auto-repair.");
|
|
292
|
+
try {
|
|
293
|
+
fs.writeFileSync(circuitFile, JSON.stringify({ hash: "", count: 0 }));
|
|
294
|
+
} catch (_) {}
|
|
295
|
+
process.exit(4);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
fs.writeFileSync(
|
|
300
|
+
circuitFile,
|
|
301
|
+
JSON.stringify({
|
|
302
|
+
hash: errorHash,
|
|
303
|
+
count: prevCircuit.hash === errorHash ? prevCircuit.count + 1 : 1,
|
|
304
|
+
updatedAt: new Date().toISOString()
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
} catch (_) {}
|
|
308
|
+
|
|
309
|
+
if (process.env.CI && !process.env.ALLOW_AUTO_REPAIR) {
|
|
310
|
+
log.error("CI environment detected and ALLOW_AUTO_REPAIR is not set. Failing fast to save CI minutes.");
|
|
311
|
+
process.exit(4);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let oodaRetries = 0;
|
|
315
|
+
try {
|
|
316
|
+
const history = execSync("git log -n 5 --format=%B", { encoding: "utf-8" });
|
|
317
|
+
const matches = history.match(/OODA Auto-Repair/gi);
|
|
318
|
+
if (matches) {
|
|
319
|
+
oodaRetries = matches.length;
|
|
320
|
+
}
|
|
321
|
+
} catch (e) {
|
|
322
|
+
// Ignorera git-fel hƤr
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (oodaRetries < 3) {
|
|
326
|
+
log.info(`š ļø Initiating OODA Auto-Repair (Attempt ${oodaRetries + 1}/3)...`);
|
|
327
|
+
const prompt = `OODA Auto-Repair Attempt ${oodaRetries + 1}\n\nThe verification suite failed with the following errors after the previous patch:\n\n\`\`\`\n${failureLog.slice(-1500)}\n\`\`\`\n\nPlease fix the errors so the verification passes.`;
|
|
328
|
+
|
|
329
|
+
const dispatchScript = path.resolve(process.cwd(), "scripts/jules-dispatch.mjs");
|
|
330
|
+
try {
|
|
331
|
+
const env = {
|
|
332
|
+
...process.env,
|
|
333
|
+
BASE_BRANCH: process.env.GITHUB_HEAD_REF || targetBranch
|
|
334
|
+
};
|
|
335
|
+
execFileSync("node", [dispatchScript, "OODA Auto-Repair", prompt], {
|
|
336
|
+
stdio: "inherit",
|
|
337
|
+
env
|
|
338
|
+
});
|
|
339
|
+
} catch (dispatchErr) {
|
|
340
|
+
log.error(`Failed to trigger OODA repair dispatch: ${dispatchErr.message}`);
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
log.error("ā OODA Auto-Repair exhausted maximum retries (3). Giving up.");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
process.exit(4);
|
|
253
347
|
}
|
|
254
348
|
|
|
255
|
-
|
|
349
|
+
log.success("š JULES PR SELF-AUDIT PASSED SUCCESSFULLY!");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function runPreflightSandbox() {
|
|
353
|
+
log.header("Initializing Pre-Flight Sandbox (Steril Simulering)...");
|
|
354
|
+
|
|
355
|
+
const runId = crypto.randomBytes(4).toString("hex");
|
|
356
|
+
const sandboxDir = path.join(os.tmpdir(), `jules-preflight-${runId}`);
|
|
357
|
+
const tempArchive = path.join(os.tmpdir(), `jules-archive-${runId}.tar`);
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
log.step("[1/4]", `Skapar isolerad miljƶ i ${sandboxDir}...`);
|
|
361
|
+
fs.mkdirSync(sandboxDir, { recursive: true });
|
|
362
|
+
|
|
363
|
+
execSync(`git archive HEAD -o "${tempArchive}"`);
|
|
364
|
+
execSync(`tar -xf "${tempArchive}" -C "${sandboxDir}"`);
|
|
365
|
+
|
|
366
|
+
log.step("[2/4]", "Steriliserar miljƶvariabler...");
|
|
367
|
+
const sterileEnv = { ...process.env };
|
|
368
|
+
const stripKeys = ["DATABASE_URL", "NPM_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "AWS_ACCESS_KEY_ID", "STRIPE_TEST_KEY"];
|
|
369
|
+
for (const key of stripKeys) {
|
|
370
|
+
if (sterileEnv[key]) {
|
|
371
|
+
sterileEnv[key] = `mock-${key.toLowerCase()}`;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
log.step("[3/4]", "Lƶser beroenden i sandlƄdan...");
|
|
376
|
+
const resolvedCmds = resolveWorkspaceExecutionBoundary([], sandboxDir);
|
|
377
|
+
|
|
378
|
+
if (resolvedCmds.testCmd || resolvedCmds.buildCmd) {
|
|
379
|
+
log.step("[4/4]", `Kƶr verifiering: ${resolvedCmds.testCmd || resolvedCmds.buildCmd}`);
|
|
380
|
+
execSync(resolvedCmds.testCmd || resolvedCmds.buildCmd, {
|
|
381
|
+
cwd: sandboxDir,
|
|
382
|
+
env: sterileEnv,
|
|
383
|
+
stdio: "inherit"
|
|
384
|
+
});
|
|
385
|
+
log.success("Pre-Flight Passed. Zero epistemic drift detected.");
|
|
386
|
+
} else {
|
|
387
|
+
log.info("Inga test/bygg-kommandon hittades fƶr verifiering.");
|
|
388
|
+
log.success("Pre-Flight Passed (No-Op).");
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
log.error("\nPRE-FLIGHT MISSLYCKADES!");
|
|
392
|
+
log.error("Agenten skulle fƶrmodligen krascha (The 5-Minute Drop-off) i Web UI pƄ grund av saknade beroenden.");
|
|
393
|
+
log.error(err.message);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
} finally {
|
|
396
|
+
try {
|
|
397
|
+
fs.rmSync(sandboxDir, { recursive: true, force: true });
|
|
398
|
+
if (fs.existsSync(tempArchive)) {
|
|
399
|
+
fs.rmSync(tempArchive, { force: true });
|
|
400
|
+
}
|
|
401
|
+
} catch (e) {
|
|
402
|
+
// Ignorera fel vid cleanup
|
|
403
|
+
}
|
|
404
|
+
}
|
|
256
405
|
}
|
|
257
406
|
|
|
258
407
|
// Execute when invoked directly from CLI
|
|
259
408
|
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
|
|
260
|
-
|
|
409
|
+
if (process.argv.includes("--preflight")) {
|
|
410
|
+
runPreflightSandbox();
|
|
411
|
+
} else {
|
|
412
|
+
runSelfAudit();
|
|
413
|
+
}
|
|
261
414
|
}
|
|
262
415
|
|
|
263
416
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Jules Task Status Utility
|
|
4
|
+
* Parses the queue.jsonl file and renders a clean console.table
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { log } from "./utils.mjs";
|
|
10
|
+
|
|
11
|
+
const queueLogPath = path.resolve(process.cwd(), ".agent/jules-queue/queue.jsonl");
|
|
12
|
+
|
|
13
|
+
if (!fs.existsSync(queueLogPath)) {
|
|
14
|
+
log.info("Queue log is empty. No tasks have been dispatched yet.");
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const content = fs.readFileSync(queueLogPath, "utf-8");
|
|
20
|
+
const lines = content.split("\n").filter(Boolean);
|
|
21
|
+
|
|
22
|
+
if (lines.length === 0) {
|
|
23
|
+
log.info("Queue log is empty.");
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// We only want the latest status for each task file
|
|
28
|
+
const statusMap = new Map();
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
try {
|
|
32
|
+
const entry = JSON.parse(line);
|
|
33
|
+
if (entry.file) {
|
|
34
|
+
statusMap.set(entry.file, {
|
|
35
|
+
Task: entry.file.replace(/^TASK-\d+-/, "").replace(/\.md$/, ""),
|
|
36
|
+
Status: entry.status,
|
|
37
|
+
Timestamp: new Date(entry.timestamp).toLocaleString(),
|
|
38
|
+
Error: entry.error || "-"
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Ignore malformed lines
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (statusMap.size === 0) {
|
|
47
|
+
log.info("No parseable task events found in queue.jsonl");
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
log.header("Jules Queue Status");
|
|
52
|
+
console.table(Array.from(statusMap.values()));
|
|
53
|
+
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log.error(`Failed to read queue log: ${err.message}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
package/scripts/jules-swarm.mjs
CHANGED
|
@@ -2,26 +2,27 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { log, sleep } from "./utils.mjs";
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
|
|
8
9
|
const tasksFile = process.argv[2];
|
|
9
10
|
|
|
10
11
|
if (!tasksFile) {
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
log.error("Usage: node scripts/jules-swarm.mjs <path-to-tasks.json>");
|
|
13
|
+
log.error('Format of tasks.json: [ { "id": "t1", "title": "Task 1", "prompt": "Description 1", "scope": ["src/moduleA/**"] } ]');
|
|
13
14
|
process.exit(1);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const resolvedPath = path.resolve(process.cwd(), tasksFile);
|
|
17
18
|
if (!fs.existsSync(resolvedPath)) {
|
|
18
|
-
|
|
19
|
+
log.error(`Tasks file not found: ${resolvedPath}`);
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const tasks = JSON.parse(fs.readFileSync(resolvedPath, "utf-8"));
|
|
23
24
|
if (!Array.isArray(tasks)) {
|
|
24
|
-
|
|
25
|
+
log.error("tasks.json must contain a JSON array of task objects.");
|
|
25
26
|
process.exit(1);
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -36,48 +37,119 @@ const USE_WORKTREES = process.env.JULES_USE_WORKTREES === "true";
|
|
|
36
37
|
// Ensure root project path is preserved for history logs even when running in worktrees
|
|
37
38
|
process.env.JULES_PROJECT_ROOT = process.cwd();
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
log.info(`Launching Jules Swarm Orchestrator (${tasks.length} tasks, Concurrency: ${MAX_CONCURRENT}, Worktrees: ${USE_WORKTREES ? "ENABLED" : "DISABLED"})...`);
|
|
40
41
|
|
|
41
|
-
const
|
|
42
|
+
const activeWorktrees = new Set();
|
|
43
|
+
|
|
44
|
+
function cleanupAllWorktreesSync() {
|
|
45
|
+
for (const item of Array.from(activeWorktrees)) {
|
|
46
|
+
if (item.wtDir && fs.existsSync(item.wtDir)) {
|
|
47
|
+
try {
|
|
48
|
+
fs.chmodSync(item.wtDir, 0o755);
|
|
49
|
+
} catch (_) {}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
execFileSync("git", ["worktree", "remove", "--force", item.wtDir], { stdio: "ignore" });
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
if (item.branchName) {
|
|
55
|
+
try {
|
|
56
|
+
execFileSync("git", ["branch", "-D", item.branchName], { stdio: "ignore" });
|
|
57
|
+
} catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
activeWorktrees.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function reapOrphanedWorktrees() {
|
|
64
|
+
const worktreesBase = path.resolve(process.cwd(), ".agent/worktrees");
|
|
65
|
+
if (!fs.existsSync(worktreesBase)) return;
|
|
66
|
+
try {
|
|
67
|
+
const entries = fs.readdirSync(worktreesBase);
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const wtPath = path.join(worktreesBase, entry);
|
|
70
|
+
try {
|
|
71
|
+
execFileSync("git", ["worktree", "remove", "--force", wtPath], { stdio: "ignore" });
|
|
72
|
+
} catch (_) {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(wtPath, { recursive: true, force: true });
|
|
75
|
+
} catch (_) {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (_) {}
|
|
79
|
+
try {
|
|
80
|
+
execFileSync("git", ["worktree", "prune"], { stdio: "ignore" });
|
|
81
|
+
} catch (_) {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.on("SIGINT", () => {
|
|
85
|
+
log.dim("SIGINT received. Cleaning up active Git worktrees...");
|
|
86
|
+
cleanupAllWorktreesSync();
|
|
87
|
+
process.exit(130);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
process.on("SIGTERM", () => {
|
|
91
|
+
log.dim("SIGTERM received. Cleaning up active Git worktrees...");
|
|
92
|
+
cleanupAllWorktreesSync();
|
|
93
|
+
process.exit(143);
|
|
94
|
+
});
|
|
42
95
|
|
|
43
96
|
async function createWorktree(taskId) {
|
|
44
97
|
const slug = taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
45
98
|
const wtDir = path.resolve(process.cwd(), `.agent/worktrees/${slug}`);
|
|
46
99
|
const branchName = `jules/${slug}`;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return { wtDir, branchName };
|
|
53
|
-
} catch (err) {
|
|
100
|
+
let item = null;
|
|
101
|
+
await fs.promises.mkdir(path.dirname(wtDir), { recursive: true });
|
|
102
|
+
|
|
103
|
+
const maxRetries = 3;
|
|
104
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
54
105
|
try {
|
|
55
|
-
await execFileAsync("git", ["worktree", "add", "
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
106
|
+
await execFileAsync("git", ["worktree", "add", "-b", branchName, wtDir, "HEAD"]);
|
|
107
|
+
item = { wtDir, branchName };
|
|
108
|
+
break;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (attempt < maxRetries) {
|
|
111
|
+
const jitter = Math.floor(Math.random() * 200) + 100 * Math.pow(2, attempt);
|
|
112
|
+
await sleep(jitter);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await execFileAsync("git", ["worktree", "add", "--force", wtDir, "HEAD"]);
|
|
117
|
+
item = { wtDir, branchName: null };
|
|
118
|
+
} catch (fallbackErr) {
|
|
119
|
+
log.error(`Failed to create worktree for ${taskId}: ${fallbackErr.message}`);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
60
122
|
}
|
|
61
123
|
}
|
|
124
|
+
if (item) activeWorktrees.add(item);
|
|
125
|
+
return item;
|
|
62
126
|
}
|
|
63
127
|
|
|
64
128
|
async function removeWorktree(wtDir, branchName) {
|
|
65
129
|
if (!wtDir) return;
|
|
130
|
+
for (const item of Array.from(activeWorktrees)) {
|
|
131
|
+
if (item.wtDir === wtDir) {
|
|
132
|
+
activeWorktrees.delete(item);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
66
135
|
try {
|
|
67
136
|
await execFileAsync("git", ["worktree", "remove", "--force", wtDir]);
|
|
68
137
|
} catch (err) {
|
|
69
|
-
|
|
138
|
+
log.error(`Failed to remove worktree at ${wtDir}: ${err.message}`);
|
|
70
139
|
}
|
|
71
140
|
if (branchName) {
|
|
72
141
|
try {
|
|
73
142
|
await execFileAsync("git", ["branch", "-D", branchName]);
|
|
74
143
|
} catch (err) {
|
|
75
|
-
|
|
144
|
+
log.error(`Failed to delete swarm branch ${branchName}: ${err.message}`);
|
|
76
145
|
}
|
|
77
146
|
}
|
|
78
147
|
}
|
|
79
148
|
|
|
80
149
|
async function runSwarm() {
|
|
150
|
+
if (USE_WORKTREES) {
|
|
151
|
+
reapOrphanedWorktrees();
|
|
152
|
+
}
|
|
81
153
|
const results = [];
|
|
82
154
|
let hasFailure = false;
|
|
83
155
|
|
|
@@ -92,8 +164,7 @@ async function runSwarm() {
|
|
|
92
164
|
await sleep(STAGGER_MS * bIdx);
|
|
93
165
|
}
|
|
94
166
|
|
|
95
|
-
|
|
96
|
-
console.log(`[${taskNum}/${tasks.length}] Dispatching Swarm Task: ${task.title} (${taskId})`);
|
|
167
|
+
log.step("======", `[${taskNum}/${tasks.length}] Dispatching Swarm Task: ${task.title} (${taskId})`);
|
|
97
168
|
|
|
98
169
|
let wtInfo = null;
|
|
99
170
|
let execCwd = process.cwd();
|
|
@@ -102,7 +173,7 @@ async function runSwarm() {
|
|
|
102
173
|
wtInfo = await createWorktree(taskId);
|
|
103
174
|
if (wtInfo?.wtDir) {
|
|
104
175
|
execCwd = wtInfo.wtDir;
|
|
105
|
-
|
|
176
|
+
log.info(`Isolated Task ${taskId} in Git Worktree: ${wtInfo.wtDir}`);
|
|
106
177
|
}
|
|
107
178
|
}
|
|
108
179
|
|
|
@@ -117,12 +188,17 @@ async function runSwarm() {
|
|
|
117
188
|
const { stdout } = await execFileAsync("node", [dispatchScript, task.title, effectivePrompt], {
|
|
118
189
|
cwd: execCwd,
|
|
119
190
|
timeout: 15 * 60 * 1000,
|
|
120
|
-
env: {
|
|
191
|
+
env: {
|
|
192
|
+
...process.env,
|
|
193
|
+
JULES_PROJECT_ROOT: process.env.JULES_PROJECT_ROOT,
|
|
194
|
+
JULES_SLOT_INDEX: String(bIdx + 1),
|
|
195
|
+
JULES_SLOT_TOTAL: String(batch.length)
|
|
196
|
+
},
|
|
121
197
|
});
|
|
122
|
-
if (stdout)
|
|
198
|
+
if (stdout) log.dim(stdout.trim());
|
|
123
199
|
return { taskId, title: task.title, status: "SUCCESS" };
|
|
124
200
|
} catch (error) {
|
|
125
|
-
|
|
201
|
+
log.error(`Failed task [${task.title}]: ${error.message}`);
|
|
126
202
|
return { taskId, title: task.title, status: "FAILED", error: error.message };
|
|
127
203
|
} finally {
|
|
128
204
|
if (wtInfo?.wtDir) {
|
|
@@ -143,15 +219,14 @@ async function runSwarm() {
|
|
|
143
219
|
}
|
|
144
220
|
|
|
145
221
|
if (i + MAX_CONCURRENT < tasks.length) {
|
|
146
|
-
|
|
222
|
+
log.info(`Batch finished. Cooling down for 2s before next batch...`);
|
|
147
223
|
await sleep(2000);
|
|
148
224
|
}
|
|
149
225
|
}
|
|
150
226
|
|
|
151
|
-
|
|
152
|
-
console.log(`š Swarm Dispatch Summary (${results.length} tasks processed):`);
|
|
227
|
+
log.header(`Swarm Dispatch Summary (${results.length} tasks processed):`);
|
|
153
228
|
results.forEach((res) => {
|
|
154
|
-
|
|
229
|
+
log.info(`[${res.status}] ${res.title}`);
|
|
155
230
|
});
|
|
156
231
|
|
|
157
232
|
if (hasFailure) {
|
|
@@ -160,6 +235,6 @@ async function runSwarm() {
|
|
|
160
235
|
}
|
|
161
236
|
|
|
162
237
|
runSwarm().catch((err) => {
|
|
163
|
-
|
|
238
|
+
log.error(`Unhandled swarm rejection: ${err.message}`);
|
|
164
239
|
process.exit(1);
|
|
165
240
|
});
|