jules-orchestrator-kit 0.5.2 → 0.6.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.
@@ -5,7 +5,7 @@ import crypto from "node:crypto";
5
5
  import os from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { resolveProjectCommands } from "./command-resolver.mjs";
8
- import { log, logToHistory } from "./utils.mjs";
8
+ import { log, logToHistory, redactSecrets, appendLedger, checkDailyBudget, reserveDailyBudget } from "./utils.mjs";
9
9
 
10
10
  const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
11
11
 
@@ -45,40 +45,14 @@ if (isMainModule) {
45
45
  loadEnv();
46
46
  }
47
47
 
48
- // 0.2 Pre-Flight Secret Redaction Gate (RegEx + Env Denylist)
49
- export function redactSecrets(text) {
50
- if (!text) return "";
51
- let sanitized = text;
52
-
53
- // Redact active env secrets from denylist keys (*_KEY, *_SECRET, *_TOKEN, etc.)
54
- for (const [envKey, envVal] of Object.entries(process.env)) {
55
- if (
56
- envVal &&
57
- envVal.length >= 20 &&
58
- /KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH/i.test(envKey)
59
- ) {
60
- if (sanitized.includes(envVal)) {
61
- sanitized = sanitized.split(envVal).join("[REDACTED_ENV_SECRET]");
62
- }
63
- }
48
+ function parseRetryAfter(res) {
49
+ const raw = res.headers.get("Retry-After");
50
+ let waitSec = Number.parseInt(raw ?? "", 10);
51
+ if (!Number.isFinite(waitSec)) {
52
+ const asDate = raw ? Date.parse(raw) : NaN;
53
+ waitSec = Number.isFinite(asDate) ? Math.ceil((asDate - Date.now()) / 1000) : 5;
64
54
  }
65
-
66
- const patterns = [
67
- /gh[pusr]_[a-zA-Z0-9]{36}/g,
68
- /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g,
69
- /AKIA[0-9A-Z]{16}/g,
70
- /AIzaSy[a-zA-Z0-9_\-]{33}/g,
71
- /ya29\.[a-zA-Z0-9_\-]{20,}/g,
72
- /Bearer\s+[a-zA-Z0-9\-\._~+\/]+=*/g,
73
- /sk-(?:ant-api03-|proj-|svcacct-)[a-zA-Z0-9\-\_]{32,}/g,
74
- /xox[baprs]-[a-zA-Z0-9\-]{10,}/g,
75
- /eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}/g,
76
- /-----BEGIN (?:RSA|OPENSSH|EC|PRIVATE) KEY-----[\s\S]*?-----END (?:RSA|OPENSSH|EC|PRIVATE) KEY-----/g,
77
- ];
78
- for (const pat of patterns) {
79
- sanitized = sanitized.replace(pat, "[REDACTED_BY_SECURITY_GATE]");
80
- }
81
- return sanitized;
55
+ return Math.min(Math.max(waitSec, 0), 60);
82
56
  }
83
57
 
84
58
  const ASTRO_TRIGGER_RE = /\b(?:astro|components|pages|src\/.*\.astro)\b/i;
@@ -129,6 +103,7 @@ let fullPrompt = "";
129
103
  let historyFile = "";
130
104
  let tmpPayloadFile = "";
131
105
  let tmpDir = "";
106
+ let taskKey = "";
132
107
 
133
108
  const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
134
109
  export function getAlphaRange(index, total) {
@@ -214,10 +189,13 @@ if (isMainModule) {
214
189
  log.info(`Dispatching task to Google Jules: "${taskTitle}"...`);
215
190
  log.success(`Logged dispatch history to: ${path.relative(process.cwd(), historyFile)}`);
216
191
 
217
- const payloadHash = crypto.createHash("sha256").update(taskTitle + Date.now()).digest("hex").slice(0, 12);
192
+ const hashInput = [repo || "repoless", taskTitle, rawPrompt, process.env.BASE_BRANCH || "main", dateStr].join("\u0000");
193
+ taskKey = crypto.createHash("sha256").update(hashInput).digest("hex").slice(0, 12);
194
+
218
195
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "jules-payload-"));
219
- tmpPayloadFile = path.join(tmpDir, `payload_${payloadHash}.json`);
196
+ tmpPayloadFile = path.join(tmpDir, `payload_${taskKey}.json`);
220
197
  fs.writeFileSync(tmpPayloadFile, JSON.stringify({ repo, title: taskTitle, prompt: fullPrompt }), { mode: 0o600 });
198
+
221
199
  }
222
200
 
223
201
  function cleanupTmp() {
@@ -239,6 +217,14 @@ async function executeDispatch() {
239
217
  return;
240
218
  }
241
219
 
220
+ const budgetLimit = process.env.JULES_DAILY_BUDGET ? parseInt(process.env.JULES_DAILY_BUDGET, 10) : 300;
221
+ const budgetCheck = reserveDailyBudget(budgetLimit, taskKey);
222
+
223
+ if (!budgetCheck.ok) {
224
+ log.warn(`Daily budget exhausted or locked (${budgetCheck.used}/${budgetCheck.budget} sessions). Aborting dispatch.`);
225
+ process.exit(7); // Budget exhausted exit code
226
+ }
227
+
242
228
  if (apiKey && (repo || isRepoless)) {
243
229
  const payload = {
244
230
  title: taskTitle,
@@ -275,6 +261,15 @@ async function executeDispatch() {
275
261
  signal: AbortSignal.timeout(30000)
276
262
  });
277
263
 
264
+ if (res.status === 429) {
265
+ const waitSec = parseRetryAfter(res);
266
+ if (attempts < maxAttempts) {
267
+ log.warn(`⚠️ REST API Rate Limit Exceeded (HTTP 429). Retrying after ${waitSec}s...`);
268
+ await new Promise((r) => setTimeout(r, waitSec * 1000));
269
+ continue;
270
+ }
271
+ }
272
+
278
273
  if (res.ok || res.status === 429 || res.status === 400 || res.status === 401) {
279
274
  break;
280
275
  }
@@ -303,8 +298,8 @@ async function executeDispatch() {
303
298
  }
304
299
 
305
300
  if (res.status === 429) {
306
- const retryAfter = res.headers.get("Retry-After");
307
- const err = new Error(`❌ Jules REST API Rate Limit Exceeded (HTTP 429). Retry after: ${retryAfter || 'unknown'}`);
301
+ const waitSec = parseRetryAfter(res);
302
+ const err = new Error(`❌ Jules REST API Rate Limit Exceeded (HTTP 429). Retry after: ${waitSec}s`);
308
303
  err.code = 1;
309
304
  throw err;
310
305
  }
@@ -330,6 +325,7 @@ async function executeDispatch() {
330
325
  sessionId = sId || data.name || data.id || sessionId;
331
326
  log.success(`REST API Dispatch response: Session ${sessionId} created successfully.`);
332
327
  fs.appendFileSync(historyFile, `\n## Session\n- ID: \`${sessionId}\`\n`);
328
+ appendLedger("sessions", { event: "session_dispatched", task_key: taskKey, session_id: sessionId, status: "dispatched", task_title: taskTitle, mode: "api" });
333
329
  } catch (jsonErr) {
334
330
  log.warn("⚠️ Failed to parse REST API JSON response:", jsonErr.message);
335
331
  }
@@ -361,6 +357,7 @@ function dispatchViaCli(targetRepo, payloadFile, title) {
361
357
  log.step("💻", `Executing: jules ${args.join(" ")} (prompt passed via stdin)...`);
362
358
  execFileSync("jules", args, { input: promptToSend, stdio: ["pipe", "inherit", "inherit"] });
363
359
  log.success(`Successfully dispatched task "${title}" to Jules CLI.`);
360
+ appendLedger("sessions", { event: "session_dispatched", task_key: taskKey, session_id: "CLI", status: "dispatched", task_title: title, mode: "cli" });
364
361
  } catch (error) {
365
362
  const err = new Error(`Failed to dispatch task to Jules CLI: ${error.message}`);
366
363
  err.code = 1;
@@ -371,6 +368,6 @@ function dispatchViaCli(targetRepo, payloadFile, title) {
371
368
  if (isMainModule) {
372
369
  executeDispatch().catch((err) => {
373
370
  log.error(`❌ Fatal unhandled rejection in dispatch: ${err.message}`);
374
- process.exit(1);
371
+ process.exit(err.code || 1);
375
372
  });
376
373
  }
@@ -13,6 +13,7 @@ const dispatchScript = path.resolve(__dirname, "jules-dispatch.mjs");
13
13
 
14
14
  const queueDir = path.resolve(process.cwd(), ".agent/jules-queue");
15
15
  const completedDir = path.resolve(process.cwd(), ".agent/jules-queue/completed");
16
+ const failedDir = path.resolve(process.cwd(), ".agent/jules-queue/failed");
16
17
  const queueLogFile = path.join(queueDir, "queue.jsonl");
17
18
 
18
19
  const processingDir = path.resolve(process.cwd(), ".agent/jules-queue/.processing");
@@ -23,8 +24,22 @@ if (!fs.existsSync(queueDir)) {
23
24
  }
24
25
 
25
26
  ensureDir(completedDir);
27
+ ensureDir(failedDir);
26
28
  ensureDir(processingDir);
27
29
 
30
+ // Recover any tasks left in .processing from a previous run or crash
31
+ const STALE_MS = 15 * 60 * 1000;
32
+ const processingFiles = fs.readdirSync(processingDir).filter(f => f.endsWith(".md"));
33
+ for (const f of processingFiles) {
34
+ const procPath = path.join(processingDir, f);
35
+ try {
36
+ if (Date.now() - fs.statSync(procPath).mtimeMs < STALE_MS) continue;
37
+ const backToQueue = path.join(queueDir, f);
38
+ fs.renameSync(procPath, backToQueue);
39
+ log.info(`Recovered stale task ${f} from .processing/`);
40
+ } catch (_) {}
41
+ }
42
+
28
43
  function logQueueState(file, status, error = null) {
29
44
  const entry = JSON.stringify({
30
45
  timestamp: new Date().toISOString(),
@@ -97,14 +112,21 @@ async function runQueue() {
97
112
  // Atomic claim: move from queueDir to processingDir before dispatching
98
113
  try {
99
114
  safeMoveSync(filePath, processingPath);
115
+ const now = new Date();
116
+ fs.utimesSync(processingPath, now, now);
100
117
  } catch (claimErr) {
101
118
  // Another runner already claimed this task file or file missing
102
119
  return;
103
120
  }
104
121
 
105
- const title = file.replace(/\.md$/, "").replace(/-/g, " ");
122
+ let title = file.replace(/\.md$/, "").replace(/-/g, " ");
106
123
 
107
124
  const content = fs.readFileSync(processingPath, "utf-8").trim();
125
+ const h1Match = content.match(/^#\s+(.+)$/m);
126
+ if (h1Match && h1Match[1].trim()) {
127
+ title = h1Match[1].trim();
128
+ }
129
+
108
130
  if (!content || content.length < 10) {
109
131
  log.warn(`Task file ${file} is empty or invalid. Skipping dispatch.`);
110
132
  const destPath = path.join(completedDir, file);
@@ -117,7 +139,7 @@ async function runQueue() {
117
139
  logQueueState(file, "RUNNING");
118
140
 
119
141
  try {
120
- await execFileAsync("node", [dispatchScript, title, processingPath]);
142
+ await execFileAsync("node", [dispatchScript, title, processingPath], { timeout: 10 * 60 * 1000 });
121
143
 
122
144
  const destPath = path.join(completedDir, file);
123
145
  await safeMoveAsync(processingPath, destPath);
@@ -125,7 +147,30 @@ async function runQueue() {
125
147
  log.success(`Task dispatched successfully (Moved ${file} to completed/)`);
126
148
  } catch (error) {
127
149
  logQueueState(file, "FAILED", error);
128
- log.error(`Failed to dispatch task [${title}]: ${error.message}`);
150
+
151
+ let attempts = 0;
152
+ const attemptMatch = content.match(/^attempts:\s*(\d+)/m);
153
+ if (attemptMatch) attempts = parseInt(attemptMatch[1], 10);
154
+
155
+ if (attempts >= 2) {
156
+ const destPath = path.join(failedDir, file);
157
+ try { await safeMoveAsync(processingPath, destPath); } catch (_) {}
158
+ log.error(`Task [${title}] failed 3 times. Moved to failed/. Error: ${error.message}`);
159
+ } else {
160
+ let newContent = content;
161
+ if (attemptMatch) {
162
+ newContent = content.replace(/^attempts:\s*\d+/m, `attempts: ${attempts + 1}`);
163
+ } else if (content.startsWith("---\n")) {
164
+ newContent = content.replace(/^---\n/, `---\nattempts: 1\n`);
165
+ } else {
166
+ newContent = `---\nattempts: 1\n---\n\n` + content;
167
+ }
168
+ try {
169
+ fs.writeFileSync(processingPath, newContent);
170
+ await safeMoveAsync(processingPath, filePath);
171
+ } catch (_) {}
172
+ log.warn(`Task [${title}] failed. Re-queued (Attempt ${attempts + 1}). Error: ${error.message}`);
173
+ }
129
174
  }
130
175
  })
131
176
  );
@@ -8,6 +8,7 @@
8
8
 
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
11
12
  import { log, ensureDir } from "./utils.mjs";
12
13
 
13
14
  const IGNORE_DIRS = new Set([
@@ -22,9 +23,8 @@ const IGNORE_DIRS = new Set([
22
23
  "coverage",
23
24
  ".next",
24
25
  ".turbo",
25
- "tmp_repos",
26
- "tmp_repos2",
27
- "tmp_repos3"
26
+ "test",
27
+ "scripts/jules-scan-todos.mjs"
28
28
  ]);
29
29
 
30
30
  const IGNORE_EXTS = new Set([
@@ -61,15 +61,15 @@ export function scanCodebaseForTodos(rootDir = process.cwd(), options = {}) {
61
61
  walk(fullPath);
62
62
  } else if (entry.isFile()) {
63
63
  const ext = path.extname(entry.name).toLowerCase();
64
- if (IGNORE_EXTS.has(ext)) continue;
64
+ if (IGNORE_EXTS.has(ext) || IGNORE_DIRS.has(relPath)) continue;
65
65
 
66
66
  try {
67
67
  const content = fs.readFileSync(fullPath, "utf-8");
68
68
  const lines = content.split("\n");
69
69
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
70
70
  const line = lines[lineIdx];
71
- // Ignore false positives from regex definitions, test assertions, or scanner string literals
72
- if (/TAG_REGEX|assert\.|tasks\.some|log\.info\("Zero TODO|\/FIXME comments/i.test(line)) {
71
+ // Ignore false positives from regex definitions or scanner string literals
72
+ if (/TAG_REGEX|tasks\.some|log\.info\("Zero TODO|\/FIXME comments/i.test(line)) {
73
73
  continue;
74
74
  }
75
75
  const match = line.match(TAG_REGEX);
@@ -137,6 +137,6 @@ export function runScanner() {
137
137
  log.info(`Run \`npx jules-swarm ${path.relative(process.cwd(), outputPath)}\` to execute all tasks in parallel.`);
138
138
  }
139
139
 
140
- if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.url.slice(7))) {
140
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
141
141
  runScanner();
142
142
  }
@@ -5,12 +5,13 @@ import crypto from "node:crypto";
5
5
  import os from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { resolveWorkspaceExecutionBoundary } from "./command-resolver.mjs";
8
- import { log, logToHistory } from "./utils.mjs";
8
+ import { log, logToHistory, redactSecrets } from "./utils.mjs";
9
9
 
10
10
  function runGitCommand(args, ignoreError = false) {
11
11
  try {
12
12
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
13
13
  } catch (error) {
14
+ if (ignoreError) return "";
14
15
  const err = new Error(`Git command failed: git ${args.join(" ")}\n${error.message}`);
15
16
  err.code = 1;
16
17
  throw err;
@@ -32,7 +33,7 @@ export function matchGlob(filepath, globPattern) {
32
33
  seg
33
34
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
34
35
  .replace(/\*/g, "[^/]*")
35
- .replace(/\?/g, ".")
36
+ .replace(/\?/g, "[^/]")
36
37
  );
37
38
  }
38
39
  }
@@ -49,6 +50,32 @@ export function matchGlob(filepath, globPattern) {
49
50
  }
50
51
 
51
52
 
53
+ export const COMMAND_DEFINING_FILES = [
54
+ "package.json",
55
+ "package-lock.json",
56
+ "pnpm-lock.yaml",
57
+ "pnpm-workspace.yaml",
58
+ "yarn.lock",
59
+ "bun.lockb",
60
+ "turbo.json",
61
+ "nx.json",
62
+ "lerna.json",
63
+ "rush.json",
64
+ "Cargo.toml",
65
+ "Cargo.lock",
66
+ "go.mod",
67
+ "go.sum",
68
+ "pyproject.toml",
69
+ "setup.py",
70
+ "requirements.txt",
71
+ "Makefile",
72
+ "build.gradle",
73
+ "build.gradle.kts",
74
+ "pom.xml",
75
+ ".agent/jules.yml",
76
+ "jules.config.json"
77
+ ];
78
+
52
79
  /**
53
80
  * Lightweight Zero-Dependency YAML Pattern Extractor
54
81
  * Extracts forbidden_paths and allow_paths arrays from jules.yml.
@@ -193,6 +220,18 @@ export function runSelfAudit() {
193
220
  .map((f) => f.trim().replace(/^["']|["']$/g, ""))
194
221
  .filter(Boolean);
195
222
 
223
+ const commandFileChanges = rawDiffFiles.filter((file) =>
224
+ COMMAND_DEFINING_FILES.some((cmdFile) => file === cmdFile || file.endsWith(`/${cmdFile}`))
225
+ );
226
+
227
+ if (commandFileChanges.length > 0 && process.env.JULES_ALLOW_COMMAND_FILE_CHANGES !== "true" && process.env.JULES_ALLOW_COMMAND_FILE_CHANGES !== "1") {
228
+ const err = new Error(
229
+ "COMMAND FILE CHANGE DETECTED: " + commandFileChanges.join(", ") + ". Refusing to execute verification scripts from untrusted branch."
230
+ );
231
+ err.code = 3;
232
+ throw err;
233
+ }
234
+
196
235
  const isBloatFile = (file) => /(\.lock|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.png|\.jpg|\.jpeg|\.pdf|\.min\.js|\.map)$/i.test(file);
197
236
  const changedCodeFiles = rawDiffFiles.filter((f) => !isBloatFile(f));
198
237
 
@@ -207,20 +246,72 @@ export function runSelfAudit() {
207
246
 
208
247
  const violations = rawDiffFiles.filter((file) => {
209
248
  const isForbidden = forbiddenPatterns.some((pattern) => matchGlob(file, pattern));
210
- if (!isForbidden) return false;
211
- const isAllowed = allowedPatterns.some((pattern) => matchGlob(file, pattern));
212
- return !isAllowed;
249
+ if (isForbidden) return true; // Immutable forbidden path — CANNOT be overridden by allow_paths
250
+ if (allowedPatterns.length > 0) {
251
+ const isAllowed = allowedPatterns.some((pattern) => matchGlob(file, pattern));
252
+ return !isAllowed;
253
+ }
254
+ return false;
213
255
  });
214
256
 
215
257
  if (violations.length > 0) {
216
- const err = new Error("RESTRICTED FILE VIOLATION DETECTED!");
258
+ const err = new Error("RESTRICTED FILE VIOLATION DETECTED: " + violations.join(", "));
217
259
  err.code = 3;
218
260
  throw err;
219
261
  }
220
262
  log.success("Restricted File Boundary Check: PASSED");
221
263
 
222
- log.info("Resolving Dynamic Verification Suite...");
223
- const resolvedCmds = resolveWorkspaceExecutionBoundary(changedCodeFiles, process.cwd());
264
+ log.info("Analyzing Diff Payload Size & Content...");
265
+ const diffPayload = runGitCommand(["diff", `${mergeBase}...HEAD`], true);
266
+ const diffBytes = Buffer.byteLength(diffPayload, "utf8");
267
+ const maxDiffBytes = 75 * 1024; // 75 KB
268
+
269
+ if (diffBytes > maxDiffBytes) {
270
+ const err = new Error(`DIFF PAYLOAD TOO LARGE: ${diffBytes} bytes exceeds ${maxDiffBytes} bytes. Split the task.`);
271
+ err.code = 5;
272
+ throw err;
273
+ }
274
+
275
+ const redactedDiff = redactSecrets(diffPayload);
276
+ if (redactedDiff !== diffPayload) {
277
+ const err = new Error("SECRET LEAK PREVENTED: Secret-like content detected in diff. Aborting.");
278
+ err.code = 6;
279
+ throw err;
280
+ }
281
+ log.success(`Diff Inspection Check: PASSED (${diffBytes} bytes, No Secrets Detected)`);
282
+
283
+ log.info("Resolving Dynamic Verification Suite from Trusted Base Branch...");
284
+
285
+ const runId = crypto.randomBytes(4).toString("hex");
286
+ const trustedDir = path.join(os.tmpdir(), `jules-trusted-tree-${runId}`);
287
+ const tempArchive = path.join(os.tmpdir(), `jules-trusted-archive-${runId}.tar`);
288
+
289
+ let resolutionRoot;
290
+ try {
291
+ fs.mkdirSync(trustedDir, { recursive: true });
292
+ execFileSync("git", ["archive", mainRef, "-o", tempArchive]);
293
+ execFileSync("tar", ["-xf", tempArchive, "-C", trustedDir]);
294
+ resolutionRoot = trustedDir;
295
+ } catch (err) {
296
+ const error = new Error("FATAL: Failed to extract trusted base branch for command resolution. Refusing to fall back to working tree.\n" + err.message);
297
+ error.code = 2;
298
+ throw error;
299
+ }
300
+
301
+ let resolvedCmds;
302
+ try {
303
+ resolvedCmds = resolveWorkspaceExecutionBoundary(changedCodeFiles, resolutionRoot);
304
+ } finally {
305
+ try {
306
+ if (trustedDir && fs.existsSync(trustedDir)) {
307
+ fs.rmSync(trustedDir, { recursive: true, force: true });
308
+ }
309
+ if (tempArchive && fs.existsSync(tempArchive)) {
310
+ fs.rmSync(tempArchive, { force: true });
311
+ }
312
+ } catch (e) {}
313
+ }
314
+
224
315
  log.info(`Discovered Execution Scope: ${resolvedCmds.source}`);
225
316
 
226
317
  const startTime = Date.now();
@@ -278,18 +369,29 @@ export function runSelfAudit() {
278
369
  }
279
370
 
280
371
  let oodaRetries = 0;
372
+ const oodaStateFile = path.resolve(process.cwd(), ".agent/state/ooda.json");
281
373
  try {
282
- const history = execSync("git log -n 5 --format=%B", { encoding: "utf-8" });
283
- const matches = history.match(/OODA Auto-Repair/gi);
284
- if (matches) {
285
- oodaRetries = matches.length;
374
+ if (fs.existsSync(oodaStateFile)) {
375
+ const oodaData = JSON.parse(fs.readFileSync(oodaStateFile, "utf-8"));
376
+ oodaRetries = oodaData.attempts || 0;
377
+ } else {
378
+ const history = execSync("git log -n 5 --format=%B", { encoding: "utf-8" });
379
+ const matches = history.match(/OODA Auto-Repair/gi);
380
+ if (matches) {
381
+ oodaRetries = matches.length;
382
+ }
286
383
  }
287
384
  } catch (e) {
288
- // Ignorera git-fel här
385
+ // Ignorera fel här
289
386
  }
290
387
 
291
388
  if (oodaRetries < 3) {
292
389
  log.info(`🛠️ Initiating OODA Auto-Repair (Attempt ${oodaRetries + 1}/3)...`);
390
+ try {
391
+ const stateDir = path.resolve(process.cwd(), ".agent/state");
392
+ if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });
393
+ fs.writeFileSync(oodaStateFile, JSON.stringify({ attempts: oodaRetries + 1, lastAttempt: new Date().toISOString() }), "utf-8");
394
+ } catch (e) {}
293
395
  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.`;
294
396
 
295
397
  const dispatchScript = path.resolve(process.cwd(), "scripts/jules-dispatch.mjs");
@@ -339,14 +441,21 @@ export function runPreflightSandbox() {
339
441
  }
340
442
  }
341
443
 
342
- log.step("[3/5]", "Installing dependencies in sandbox (npm ci)...");
343
- const packageJsonPath = path.join(sandboxDir, "package.json");
344
- if (fs.existsSync(packageJsonPath)) {
444
+ log.step("[3/5]", "Resolving package manager and installing dependencies...");
445
+ let installCmd = "";
446
+ if (fs.existsSync(path.join(sandboxDir, "pnpm-lock.yaml"))) installCmd = "pnpm install --ignore-scripts";
447
+ else if (fs.existsSync(path.join(sandboxDir, "bun.lockb"))) installCmd = "bun install --ignore-scripts";
448
+ else if (fs.existsSync(path.join(sandboxDir, "yarn.lock"))) installCmd = "yarn install --ignore-scripts";
449
+ else if (fs.existsSync(path.join(sandboxDir, "package-lock.json"))) installCmd = "npm ci --ignore-scripts";
450
+
451
+ if (installCmd) {
345
452
  try {
346
- execSync("npm ci --ignore-scripts", { cwd: sandboxDir, stdio: "ignore" });
453
+ execSync(installCmd, { cwd: sandboxDir, stdio: "pipe", encoding: "utf-8" });
347
454
  } catch (e) {
348
- log.warn("npm ci failed or wasn't needed. Proceeding...");
455
+ log.warn(`${installCmd} failed or wasn't needed. Output: ${e.stdout}\nError: ${e.stderr}\nProceeding...`);
349
456
  }
457
+ } else {
458
+ log.info("Skipping dependency installation (no lockfile detected).");
350
459
  }
351
460
 
352
461
  log.step("[4/5]", "Resolving execution boundary in sandbox...");
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, execFileSync } 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";
@@ -74,6 +74,9 @@ function reapOrphanedWorktrees() {
74
74
  fs.rmSync(wtPath, { recursive: true, force: true });
75
75
  } catch (_) {}
76
76
  }
77
+ try {
78
+ execFileSync("git", ["branch", "-D", `jules/${entry}`], { stdio: "ignore" });
79
+ } catch (_) {}
77
80
  }
78
81
  } catch (_) {}
79
82
  try {
@@ -31,7 +31,37 @@ async function readManifest() {
31
31
 
32
32
  async function writeManifest(manifest) {
33
33
  await fs.mkdir(path.dirname(MANIFEST_PATH), { recursive: true });
34
- await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2), 'utf-8');
34
+ const tmp = MANIFEST_PATH + `.tmp-${process.pid}`;
35
+ await fs.writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf-8');
36
+ await fs.rename(tmp, MANIFEST_PATH);
37
+ }
38
+
39
+ async function withManifestLock(fn) {
40
+ const lockPath = MANIFEST_PATH + ".lock";
41
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
42
+ for (let i = 0; i < 50; i++) {
43
+ try {
44
+ const stats = await fs.stat(lockPath);
45
+ if (Date.now() - stats.mtimeMs > 5 * 60 * 1000) {
46
+ await fs.rm(lockPath, { force: true });
47
+ }
48
+ } catch (_) {}
49
+
50
+ let handle;
51
+ try {
52
+ handle = await fs.open(lockPath, "wx");
53
+ } catch (err) {
54
+ if (err.code !== "EEXIST") throw err;
55
+ await new Promise(r => setTimeout(r, 50 + Math.random() * 50));
56
+ continue;
57
+ }
58
+ try { return await fn(); }
59
+ finally {
60
+ try { await handle.close(); } catch (_) {}
61
+ await fs.rm(lockPath, { force: true });
62
+ }
63
+ }
64
+ throw new Error("Could not acquire manifest lock");
35
65
  }
36
66
 
37
67
  function cleanExpiredLocks(manifest) {
@@ -47,7 +77,8 @@ function cleanExpiredLocks(manifest) {
47
77
  }
48
78
 
49
79
  async function acquireLocks(agentName, taskId, files) {
50
- const manifest = await readManifest();
80
+ await withManifestLock(async () => {
81
+ const manifest = await readManifest();
51
82
  cleanExpiredLocks(manifest);
52
83
 
53
84
  const now = Date.now();
@@ -65,13 +96,13 @@ async function acquireLocks(agentName, taskId, files) {
65
96
  }
66
97
  }
67
98
 
68
- if (conflicts.length > 0) {
69
- console.error(`ERROR: Cannot acquire locks for task ${taskId}. Conflicts detected:`);
70
- for (const conflict of conflicts) {
71
- console.error(` - ${conflict.file} is locked by ${conflict.lockedBy} (Task: ${conflict.task})`);
99
+ if (conflicts.length > 0) {
100
+ let msg = `Cannot acquire locks for task ${taskId}. Conflicts detected:\n`;
101
+ for (const conflict of conflicts) {
102
+ msg += ` - ${conflict.file} is locked by ${conflict.lockedBy} (Task: ${conflict.task})\n`;
103
+ }
104
+ throw new Error(msg.trim());
72
105
  }
73
- process.exit(1);
74
- }
75
106
 
76
107
  for (const file of files) {
77
108
  const normalizedFile = path.normalize(file);
@@ -83,12 +114,14 @@ async function acquireLocks(agentName, taskId, files) {
83
114
  };
84
115
  }
85
116
 
86
- await writeManifest(manifest);
87
- console.log(`SUCCESS: Acquired locks for task ${taskId} on ${files.length} files.`);
117
+ await writeManifest(manifest);
118
+ console.log(`SUCCESS: Acquired locks for task ${taskId} on ${files.length} files.`);
119
+ });
88
120
  }
89
121
 
90
122
  async function releaseLocks(taskId) {
91
- const manifest = await readManifest();
123
+ await withManifestLock(async () => {
124
+ const manifest = await readManifest();
92
125
  let releasedCount = 0;
93
126
 
94
127
  for (const [file, lock] of Object.entries(manifest)) {
@@ -98,12 +131,13 @@ async function releaseLocks(taskId) {
98
131
  }
99
132
  }
100
133
 
101
- if (releasedCount > 0) {
102
- await writeManifest(manifest);
103
- console.log(`SUCCESS: Released ${releasedCount} locks for task ${taskId}.`);
104
- } else {
105
- console.log(`INFO: No locks found for task ${taskId}.`);
106
- }
134
+ if (releasedCount > 0) {
135
+ await writeManifest(manifest);
136
+ console.log(`SUCCESS: Released ${releasedCount} locks for task ${taskId}.`);
137
+ } else {
138
+ console.log(`INFO: No locks found for task ${taskId}.`);
139
+ }
140
+ });
107
141
  }
108
142
 
109
143
  async function main() {