jules-orchestrator-kit 0.74.0 → 0.75.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/CHANGELOG.md +27 -1
- package/JULES_RULES_TEMPLATE.md +3 -0
- package/README.md +4 -2
- package/bin/agentctl.mjs +395 -23
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +226 -23
- package/scripts/run-tests.mjs +5 -1
- package/src/bidi-guard.mjs +190 -15
- package/src/ci-templates.mjs +184 -21
- package/src/config.mjs +40 -1
- package/src/engine.mjs +342 -48
- package/src/evidence.mjs +22 -5
- package/src/git.mjs +15 -0
- package/src/migrate.mjs +493 -0
- package/src/ops/command-registry.mjs +93 -4
- package/src/ops/doctor-registry.mjs +184 -32
- package/src/ops/pr-harvest.mjs +22 -2
- package/src/ops/toolchain-diagnostics.mjs +253 -0
- package/src/ops/tui-menu.mjs +192 -0
- package/src/process-tree.mjs +146 -0
- package/src/prompt-guard.mjs +9 -0
- package/src/provider.mjs +14 -0
- package/src/scaffold.mjs +49 -1
- package/src/scope-guard.mjs +73 -5
- package/src/secret-scanner.mjs +14 -2
- package/src/security.mjs +41 -30
- package/src/self-audit.mjs +237 -3
- package/src/state.mjs +123 -13
- package/src/uninstall.mjs +227 -0
- package/src/wizard-init.mjs +11 -0
- package/src/wizard-task.mjs +17 -22
package/src/security.mjs
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
import { readFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { detectCrossPackageBoundaryViolations, detectEdgeRuntime } from "./stack-detector.mjs";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
checkUnicodeSecurity,
|
|
24
|
+
locateUnicodeFindingLine,
|
|
25
|
+
} from "./bidi-guard.mjs";
|
|
23
26
|
import {
|
|
24
27
|
hasHighConfidenceSecret,
|
|
25
28
|
hasLowConfidenceSecret,
|
|
@@ -164,6 +167,10 @@ function splitDiffByFile(diffText) {
|
|
|
164
167
|
current.lines.push({ text: line.slice(1), no: lineNo });
|
|
165
168
|
if (lineNo !== null) lineNo++;
|
|
166
169
|
} else if (lineNo !== null && !line.startsWith("-") && !line.startsWith("\\")) {
|
|
170
|
+
if (line.startsWith(" ")) {
|
|
171
|
+
if (!current) select(null);
|
|
172
|
+
current.lines.push({ text: line.slice(1), no: lineNo });
|
|
173
|
+
}
|
|
167
174
|
lineNo++; // A context line advances the post-image just as an added one does.
|
|
168
175
|
}
|
|
169
176
|
}
|
|
@@ -331,42 +338,35 @@ export function scanDiff(diffTextStr = "", options = {}) {
|
|
|
331
338
|
for (const segment of segments) {
|
|
332
339
|
const addedText = segment.lines.map((l) => l.text).join("\n");
|
|
333
340
|
|
|
334
|
-
|
|
335
|
-
if (
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
341
|
+
const hit = classifyAddedLines(addedText, segment.file);
|
|
342
|
+
if (hit) {
|
|
343
|
+
const line = segment.file ? locateFindingLine(segment.lines, hit.type, segment.file) : null;
|
|
344
|
+
const at = segment.file ? ` (${segment.file}${line ? `:${line}` : ""})` : "";
|
|
345
|
+
findings.push({
|
|
346
|
+
severity: hit.severity,
|
|
347
|
+
type: hit.type,
|
|
348
|
+
file: segment.file,
|
|
349
|
+
line,
|
|
350
|
+
description: `${hit.description}${at}`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Unicode security: BiDi Trojan Source, invisible obfuscation, mixed-script
|
|
355
|
+
// confusables. Markdown remains exempt (documentation may cite the controls).
|
|
356
|
+
if (!segment.file || !segment.file.endsWith(".md")) {
|
|
357
|
+
const uniRes = checkUnicodeSecurity(addedText, options);
|
|
358
|
+
if (!uniRes.ok) {
|
|
359
|
+
for (const v of uniRes.violations) {
|
|
348
360
|
findings.push({
|
|
349
361
|
severity: "CRITICAL",
|
|
350
|
-
type:
|
|
362
|
+
type: v.type,
|
|
351
363
|
file: segment.file,
|
|
352
|
-
line:
|
|
364
|
+
line: locateUnicodeFindingLine(segment.lines, v.type),
|
|
353
365
|
description: v.reason,
|
|
354
366
|
});
|
|
355
367
|
}
|
|
356
368
|
}
|
|
357
369
|
}
|
|
358
|
-
|
|
359
|
-
const hit = classifyAddedLines(addedText, segment.file);
|
|
360
|
-
if (!hit) continue;
|
|
361
|
-
const line = segment.file ? locateFindingLine(segment.lines, hit.type, segment.file) : null;
|
|
362
|
-
const at = segment.file ? ` (${segment.file}${line ? `:${line}` : ""})` : "";
|
|
363
|
-
findings.push({
|
|
364
|
-
severity: hit.severity,
|
|
365
|
-
type: hit.type,
|
|
366
|
-
file: segment.file,
|
|
367
|
-
line,
|
|
368
|
-
description: `${hit.description}${at}`,
|
|
369
|
-
});
|
|
370
370
|
}
|
|
371
371
|
|
|
372
372
|
// Scanning per file loses anything that only matches across a file boundary,
|
|
@@ -462,6 +462,9 @@ export {
|
|
|
462
462
|
ENV_TEMPLATE_BASENAMES,
|
|
463
463
|
isEnvTemplateException,
|
|
464
464
|
checkScope,
|
|
465
|
+
isProtectedWorkflowException,
|
|
466
|
+
UNWAIVABLE_DENY_PATTERNS,
|
|
467
|
+
isUnwaivableForbiddenPath,
|
|
465
468
|
} from "./scope-guard.mjs";
|
|
466
469
|
export {
|
|
467
470
|
HIGH_CONFIDENCE_PATTERNS,
|
|
@@ -475,4 +478,12 @@ export {
|
|
|
475
478
|
hasHighEntropyToken,
|
|
476
479
|
} from "./secret-scanner.mjs";
|
|
477
480
|
export { TAMPER_KINDS, TAMPER_KIND_NAMES, resolveAllowedTamperKinds, checkTestTampering } from "./test-tamper-guard.mjs";
|
|
478
|
-
export {
|
|
481
|
+
export {
|
|
482
|
+
checkTrojanSource,
|
|
483
|
+
checkUnicodeSecurity,
|
|
484
|
+
BIDI_CONTROL_REGEX,
|
|
485
|
+
INVISIBLE_OBFUSCATION_REGEX,
|
|
486
|
+
PLANE14_TAG_REGEX,
|
|
487
|
+
hasMixedScriptConfusable,
|
|
488
|
+
locateUnicodeFindingLine,
|
|
489
|
+
} from "./bidi-guard.mjs";
|
package/src/self-audit.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync } from "node:fs";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { gate } from "./engine.mjs";
|
|
12
12
|
import { loadConfig, parseYaml } from "./config.mjs";
|
|
13
|
-
import { matchesGlob } from "./security.mjs";
|
|
13
|
+
import { matchesGlob, TAMPER_KIND_NAMES } from "./security.mjs";
|
|
14
14
|
|
|
15
15
|
/** Convenience wrapper kept for the historical `matchGlob` name. */
|
|
16
16
|
export function matchGlob(filepath, globPattern) {
|
|
@@ -85,6 +85,202 @@ export function auditWorktrees(opts = {}) {
|
|
|
85
85
|
return { ok: true, worktreeDir, exists };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Parses the labels payload GitHub Actions exposes for a pull request.
|
|
90
|
+
* Accepts an array, raw `toJSON(...labels)` JSON string, or a plain comma-separated string.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|Array} raw
|
|
93
|
+
* @returns {string[]}
|
|
94
|
+
*/
|
|
95
|
+
export function parseLabels(raw = "") {
|
|
96
|
+
if (Array.isArray(raw)) {
|
|
97
|
+
return raw.map((l) => (typeof l === "string" ? l : l && l.name) || "").filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
const text = String(raw || "").trim();
|
|
100
|
+
if (!text) return [];
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(text);
|
|
103
|
+
if (Array.isArray(parsed)) {
|
|
104
|
+
return parsed.map((l) => (typeof l === "string" ? l : l && l.name) || "").filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
} catch (_) {}
|
|
107
|
+
return text.split(",").map((s) => s.trim()).filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolves tamper guard waivers from PR labels bound to the exact head SHA.
|
|
112
|
+
*
|
|
113
|
+
* Test expectation waivers MUST be explicitly bound to the reviewed commit SHA
|
|
114
|
+
* (e.g. `allow-e:<40-hex-SHA>` or legacy `allow-test-expectation:<SHA>`).
|
|
115
|
+
* Short label `allow-e:<40-hex-SHA>` is 48 characters, fitting GitHub's 50-character limit.
|
|
116
|
+
* Unbound labels (without SHA) or stale labels (SHA mismatch) are rejected.
|
|
117
|
+
*
|
|
118
|
+
* @param {string|string[]} rawLabels - Array of labels, JSON string, or comma-separated string
|
|
119
|
+
* @param {string} headSha - Full 40-character commit SHA of the current PR head
|
|
120
|
+
* @returns {{ allowedKinds: string[], rejected: Array<{ label: string, reason: string }> }}
|
|
121
|
+
*/
|
|
122
|
+
export function resolveTestWaiverFromLabels(rawLabels = "", headSha = "") {
|
|
123
|
+
const labels = parseLabels(rawLabels);
|
|
124
|
+
const allowedKinds = new Set();
|
|
125
|
+
const rejected = [];
|
|
126
|
+
const normalizedHead = String(headSha || "").trim().toLowerCase();
|
|
127
|
+
|
|
128
|
+
for (const label of labels) {
|
|
129
|
+
const lower = label.trim().toLowerCase();
|
|
130
|
+
|
|
131
|
+
if (!lower.startsWith("allow-test-") && !lower.startsWith("allow-e:") && lower !== "allow-e") {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const parts = lower.split(":");
|
|
136
|
+
let kind = null;
|
|
137
|
+
let targetSha = null;
|
|
138
|
+
|
|
139
|
+
if (parts[0] === "allow-e") {
|
|
140
|
+
kind = "expectation";
|
|
141
|
+
if (parts.length > 1) {
|
|
142
|
+
targetSha = parts.slice(1).join(":");
|
|
143
|
+
}
|
|
144
|
+
} else if (parts[0] === "allow-test-expectation") {
|
|
145
|
+
kind = "expectation";
|
|
146
|
+
if (parts.length > 1) {
|
|
147
|
+
targetSha = parts.slice(1).join(":");
|
|
148
|
+
}
|
|
149
|
+
} else if (parts[0] === "allow-test-change" || parts[0] === "allow-test-changes") {
|
|
150
|
+
if (parts.length === 2) {
|
|
151
|
+
if (TAMPER_KIND_NAMES.includes(parts[1])) {
|
|
152
|
+
kind = parts[1];
|
|
153
|
+
} else {
|
|
154
|
+
kind = "expectation";
|
|
155
|
+
targetSha = parts[1];
|
|
156
|
+
}
|
|
157
|
+
} else if (parts.length >= 3) {
|
|
158
|
+
kind = parts[1];
|
|
159
|
+
targetSha = parts.slice(2).join(":");
|
|
160
|
+
} else {
|
|
161
|
+
kind = "expectation";
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!targetSha) {
|
|
168
|
+
const canonicalFmt = parts[0] === "allow-e" || parts[0] === "allow-test-expectation"
|
|
169
|
+
? "allow-e:<40-hex-SHA>"
|
|
170
|
+
: `${parts[0]}:<40-hex-SHA>`;
|
|
171
|
+
rejected.push({
|
|
172
|
+
label,
|
|
173
|
+
reason: `Unbound waiver: label has no commit SHA. Must be formatted as "${canonicalFmt}".`,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!/^[0-9a-f]{40}$/i.test(targetSha)) {
|
|
179
|
+
rejected.push({
|
|
180
|
+
label,
|
|
181
|
+
reason: `Invalid commit SHA "${targetSha}": must be exact 40 hexadecimal characters. Prefix matching is not permitted.`,
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!normalizedHead || !/^[0-9a-f]{40}$/i.test(normalizedHead)) {
|
|
187
|
+
rejected.push({
|
|
188
|
+
label,
|
|
189
|
+
reason: `Cannot verify binding: HEAD_SHA "${headSha}" is missing or not a valid 40-character commit SHA.`,
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (targetSha.toLowerCase() !== normalizedHead) {
|
|
195
|
+
rejected.push({
|
|
196
|
+
label,
|
|
197
|
+
reason: `Stale waiver: label SHA "${targetSha}" does not match current HEAD SHA "${normalizedHead}".`,
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (TAMPER_KIND_NAMES.includes(kind)) {
|
|
203
|
+
allowedKinds.add(kind);
|
|
204
|
+
} else {
|
|
205
|
+
rejected.push({
|
|
206
|
+
label,
|
|
207
|
+
reason: `Unknown tamper kind "${kind}". Valid kinds: ${TAMPER_KIND_NAMES.join(", ")}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
allowedKinds: Array.from(allowedKinds),
|
|
214
|
+
rejected,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolves protected-path bypass waivers from PR labels bound to the exact head SHA.
|
|
220
|
+
*
|
|
221
|
+
* Protected path waivers MUST be explicitly bound to the reviewed commit SHA
|
|
222
|
+
* (e.g. `allow-p:<40-hex-SHA>` or legacy `allow-protected-paths:<40-hex-SHA>`).
|
|
223
|
+
* Short label `allow-p:<40-hex-SHA>` is 48 characters, fitting GitHub's 50-character limit.
|
|
224
|
+
* Unbound labels (without SHA), prefix matching, or stale labels (SHA mismatch) are rejected.
|
|
225
|
+
*
|
|
226
|
+
* @param {string|string[]} rawLabels - Array of labels, JSON string, or comma-separated string
|
|
227
|
+
* @param {string} headSha - Full 40-character commit SHA of the current PR head
|
|
228
|
+
* @returns {{ ok: boolean, boundSha?: string, rejected: Array<{ label: string, reason: string }> }}
|
|
229
|
+
*/
|
|
230
|
+
export function resolveProtectedWaiverFromLabels(rawLabels = "", headSha = "") {
|
|
231
|
+
const labels = parseLabels(rawLabels);
|
|
232
|
+
const normalizedHead = String(headSha || "").trim().toLowerCase();
|
|
233
|
+
const rejected = [];
|
|
234
|
+
let foundValid = false;
|
|
235
|
+
|
|
236
|
+
for (const label of labels) {
|
|
237
|
+
const lower = label.trim().toLowerCase();
|
|
238
|
+
if (!lower.startsWith("allow-protected-paths") && !lower.startsWith("allow-p:") && lower !== "allow-p") continue;
|
|
239
|
+
|
|
240
|
+
const parts = lower.split(":");
|
|
241
|
+
if (parts.length === 1) {
|
|
242
|
+
rejected.push({
|
|
243
|
+
label,
|
|
244
|
+
reason: 'Unbound waiver: label has no commit SHA. Must be formatted as "allow-p:<40-hex-SHA>".',
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const targetSha = parts.slice(1).join(":");
|
|
250
|
+
if (!/^[0-9a-f]{40}$/i.test(targetSha)) {
|
|
251
|
+
rejected.push({
|
|
252
|
+
label,
|
|
253
|
+
reason: `Invalid commit SHA "${targetSha}": must be exact 40 hexadecimal characters. Prefix matching is not permitted.`,
|
|
254
|
+
});
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!normalizedHead || !/^[0-9a-f]{40}$/i.test(normalizedHead)) {
|
|
259
|
+
rejected.push({
|
|
260
|
+
label,
|
|
261
|
+
reason: `Cannot verify binding: HEAD_SHA "${headSha}" is missing or not a valid 40-character commit SHA.`,
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (targetSha.toLowerCase() !== normalizedHead) {
|
|
267
|
+
rejected.push({
|
|
268
|
+
label,
|
|
269
|
+
reason: `Stale waiver: label SHA "${targetSha}" does not match current HEAD SHA "${normalizedHead}".`,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
foundValid = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (foundValid) {
|
|
278
|
+
return { ok: true, boundSha: normalizedHead, rejected };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { ok: false, rejected };
|
|
282
|
+
}
|
|
283
|
+
|
|
88
284
|
/**
|
|
89
285
|
* Audit gate rules (scope, payload governor, secret scanning, verification suite).
|
|
90
286
|
*/
|
|
@@ -93,13 +289,42 @@ export async function auditGates(opts = {}) {
|
|
|
93
289
|
const config = opts.config || loadConfig(root);
|
|
94
290
|
const base = opts.base || process.env.BASE_BRANCH || config.baseBranch || "main";
|
|
95
291
|
|
|
292
|
+
let allowProtected =
|
|
293
|
+
opts.allowProtected ??
|
|
294
|
+
(process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true" ||
|
|
295
|
+
process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "1");
|
|
296
|
+
|
|
297
|
+
const rawLabels = opts.prLabels || process.env.PR_LABELS;
|
|
298
|
+
const headSha = opts.headSha || process.env.HEAD_SHA;
|
|
299
|
+
|
|
300
|
+
if (!allowProtected && rawLabels) {
|
|
301
|
+
const waiver = resolveProtectedWaiverFromLabels(rawLabels, headSha);
|
|
302
|
+
if (waiver.ok) {
|
|
303
|
+
allowProtected = true;
|
|
304
|
+
}
|
|
305
|
+
for (const rej of waiver.rejected || []) {
|
|
306
|
+
console.warn(`::warning::[scope-waiver] ${rej.label}: ${rej.reason}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let allowTestChanges = opts.allowTestChanges || process.env.JULES_ALLOW_TEST_CHANGES;
|
|
311
|
+
if (!allowTestChanges && rawLabels) {
|
|
312
|
+
const waiver = resolveTestWaiverFromLabels(rawLabels, headSha);
|
|
313
|
+
if (waiver.allowedKinds.length > 0) {
|
|
314
|
+
allowTestChanges = waiver.allowedKinds.join(",");
|
|
315
|
+
}
|
|
316
|
+
for (const rej of waiver.rejected) {
|
|
317
|
+
console.warn(`::warning::[tamper-waiver] ${rej.label}: ${rej.reason}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
96
321
|
return await gate({
|
|
97
322
|
root,
|
|
98
323
|
config,
|
|
99
324
|
base,
|
|
100
325
|
fix: process.env.ALLOW_AUTO_REPAIR === "true",
|
|
101
|
-
allowProtected
|
|
102
|
-
allowTestChanges
|
|
326
|
+
allowProtected,
|
|
327
|
+
allowTestChanges,
|
|
103
328
|
});
|
|
104
329
|
}
|
|
105
330
|
|
|
@@ -118,6 +343,15 @@ export async function runSelfAudit(opts = {}) {
|
|
|
118
343
|
const gatesResult = await auditGates(optsWithContext);
|
|
119
344
|
|
|
120
345
|
if (!gatesResult.ok) {
|
|
346
|
+
// The CLI's exit-6 banner intentionally does not print diff contents.
|
|
347
|
+
// Surface safe finding coordinates so a legitimate change can be repaired
|
|
348
|
+
// without disabling the independent audit or guessing at the failure.
|
|
349
|
+
if (gatesResult.code === 6) {
|
|
350
|
+
const findings = gatesResult.phases?.find((phase) => phase.phase === "secrets")?.findings || [];
|
|
351
|
+
for (const finding of findings) {
|
|
352
|
+
console.error(`Self-audit finding: ${finding.type || "UNKNOWN"} at ${finding.file || "unattributed"}${finding.line ? `:${finding.line}` : ""}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
121
355
|
return gatesResult;
|
|
122
356
|
}
|
|
123
357
|
|
package/src/state.mjs
CHANGED
|
@@ -233,24 +233,36 @@ export function withVfsMutex(mutexDir, fn, opts = {}) {
|
|
|
233
233
|
const maxRetries = opts.maxRetries || 200;
|
|
234
234
|
const retryDelayMs = opts.retryDelayMs || 10;
|
|
235
235
|
|
|
236
|
+
const staleMs = opts.staleMs || 2000;
|
|
237
|
+
|
|
236
238
|
for (let i = 0; i < maxRetries; i++) {
|
|
239
|
+
let acquired = false;
|
|
237
240
|
try {
|
|
238
241
|
mkdirSync(mutexDir);
|
|
239
|
-
|
|
240
|
-
return fn();
|
|
241
|
-
} finally {
|
|
242
|
-
try {
|
|
243
|
-
rmdirSync(mutexDir);
|
|
244
|
-
} catch (_) {}
|
|
245
|
-
}
|
|
242
|
+
acquired = true;
|
|
246
243
|
} catch (err) {
|
|
247
244
|
if (err.code === "EEXIST") {
|
|
245
|
+
try {
|
|
246
|
+
if (Date.now() - statSync(mutexDir).mtimeMs > staleMs) {
|
|
247
|
+
rmdirSync(mutexDir);
|
|
248
|
+
}
|
|
249
|
+
} catch (_) {}
|
|
248
250
|
const deadline = Date.now() + retryDelayMs;
|
|
249
251
|
while (Date.now() < deadline) {}
|
|
250
252
|
continue;
|
|
251
253
|
}
|
|
252
254
|
throw err;
|
|
253
255
|
}
|
|
256
|
+
|
|
257
|
+
if (acquired) {
|
|
258
|
+
try {
|
|
259
|
+
return fn();
|
|
260
|
+
} finally {
|
|
261
|
+
try {
|
|
262
|
+
rmdirSync(mutexDir);
|
|
263
|
+
} catch (_) {}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
254
266
|
}
|
|
255
267
|
throw new MutexTimeoutError(`Failed to acquire VFS mutex lock at ${mutexDir} after ${maxRetries} retries`);
|
|
256
268
|
}
|
|
@@ -634,9 +646,10 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
634
646
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
|
|
635
647
|
const branch = (typeof rootOrOpts === "object" && rootOrOpts?.branch) || opts?.branch || process.env.JULES_BRANCH || process.env.BRANCH_NAME || "";
|
|
636
648
|
const lockDir = getLockDir(root);
|
|
637
|
-
|
|
649
|
+
return withVfsMutex(join(lockDir, ".acquire.mutex"), () => {
|
|
650
|
+
const lockFile = join(lockDir, `${taskId}.json`);
|
|
638
651
|
|
|
639
|
-
|
|
652
|
+
if (existsSync(lockFile)) {
|
|
640
653
|
try {
|
|
641
654
|
const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
|
|
642
655
|
if (isLockLive(existing)) {
|
|
@@ -667,12 +680,17 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
667
680
|
if (requested.size > 0) {
|
|
668
681
|
for (const held of lockStatus(root)) {
|
|
669
682
|
if (!held || held.taskId === taskId) continue;
|
|
670
|
-
if (!isLockLive(held)) continue;
|
|
671
683
|
|
|
672
684
|
const overlap = (Array.isArray(held.files) ? held.files : [])
|
|
673
685
|
.map((f) => normalizePath(f))
|
|
674
686
|
.filter((f) => requested.has(f));
|
|
675
687
|
if (overlap.length > 0) {
|
|
688
|
+
if (!isLockLive(held)) {
|
|
689
|
+
if (held.taskId) {
|
|
690
|
+
try { unlinkSync(join(lockDir, `${held.taskId}.json`)); } catch (_) {}
|
|
691
|
+
}
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
676
694
|
return {
|
|
677
695
|
ok: false,
|
|
678
696
|
holder: held.agent,
|
|
@@ -723,6 +741,8 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
723
741
|
const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
|
|
724
742
|
return { ok: false, holder: existing.agent, taskId };
|
|
725
743
|
} catch (_) {
|
|
744
|
+
// Corrupted or 0-byte existing file: unlink so the task does not stay wedged.
|
|
745
|
+
try { unlinkSync(lockFile); } catch (_) {}
|
|
726
746
|
return { ok: false, holder: "unknown", taskId };
|
|
727
747
|
}
|
|
728
748
|
}
|
|
@@ -733,6 +753,7 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
733
753
|
try { closeSync(fd); } catch (_) {}
|
|
734
754
|
}
|
|
735
755
|
}
|
|
756
|
+
});
|
|
736
757
|
}
|
|
737
758
|
|
|
738
759
|
export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
|
|
@@ -754,8 +775,9 @@ export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
|
|
|
754
775
|
return false;
|
|
755
776
|
}
|
|
756
777
|
|
|
757
|
-
export function lockStatus(rootOrOpts = resolveRoot()) {
|
|
758
|
-
const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
|
|
778
|
+
export function lockStatus(rootOrOpts = resolveRoot(), opts = {}) {
|
|
779
|
+
const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
|
|
780
|
+
const activeOnly = opts?.activeOnly === true;
|
|
759
781
|
const lockDir = getLockDir(root);
|
|
760
782
|
const locks = [];
|
|
761
783
|
try {
|
|
@@ -764,7 +786,10 @@ export function lockStatus(rootOrOpts = resolveRoot()) {
|
|
|
764
786
|
if (file.endsWith(".json")) {
|
|
765
787
|
try {
|
|
766
788
|
const content = readFileSync(join(lockDir, file), "utf-8");
|
|
767
|
-
|
|
789
|
+
const record = JSON.parse(content);
|
|
790
|
+
const live = isLockLive(record);
|
|
791
|
+
if (activeOnly && !live) continue;
|
|
792
|
+
locks.push(Object.freeze({ ...record, live }));
|
|
768
793
|
} catch (_) {}
|
|
769
794
|
}
|
|
770
795
|
}
|
|
@@ -788,6 +813,7 @@ export function isConcurrencyGroupLocked(groupName, rootOrOpts = resolveRoot(),
|
|
|
788
813
|
const locks = lockStatus(rootOrOpts);
|
|
789
814
|
for (const lock of locks) {
|
|
790
815
|
if (excludeTaskId && lock.taskId === excludeTaskId) continue;
|
|
816
|
+
if (!isLockLive(lock)) continue;
|
|
791
817
|
const lockGroup = String(lock.concurrencyGroup || lock.concurrency_group || "").trim().toLowerCase();
|
|
792
818
|
if (lockGroup === targetGroup) {
|
|
793
819
|
return true;
|
|
@@ -796,4 +822,88 @@ export function isConcurrencyGroupLocked(groupName, rootOrOpts = resolveRoot(),
|
|
|
796
822
|
return false;
|
|
797
823
|
}
|
|
798
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Scans .agent/state/locks/ for stale, expired, or corrupted lock records.
|
|
827
|
+
*
|
|
828
|
+
* Supported queue/process model:
|
|
829
|
+
* - Leased locks: reaped if Date.now() >= lockExpiry(record).
|
|
830
|
+
* - Process-bound locks: reaped if owner PID is dead (or recycled with different start time).
|
|
831
|
+
* - Corrupted files: 0-byte or unparseable JSON files reaped immediately.
|
|
832
|
+
* - Live locks: left completely untouched.
|
|
833
|
+
*
|
|
834
|
+
* @param {string|Object} [rootOrOpts=resolveRoot()]
|
|
835
|
+
* @param {Object} [opts]
|
|
836
|
+
* @param {boolean} [opts.dryRun=false] - When true, reports stale locks without deleting them.
|
|
837
|
+
* @returns {{ reapedCount: number, reaped: Array<{ file: string, taskId: string, agent: string, pid: number|null, reason: "corrupted"|"expired"|"dead-pid" }> }}
|
|
838
|
+
*/
|
|
839
|
+
export function reapStaleLocks(rootOrOpts = resolveRoot(), { dryRun = false } = {}) {
|
|
840
|
+
const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
|
|
841
|
+
const lockDir = getLockDir(root);
|
|
842
|
+
|
|
843
|
+
if (!existsSync(lockDir)) {
|
|
844
|
+
return { reapedCount: 0, reaped: [] };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const reaped = [];
|
|
848
|
+
let files = [];
|
|
849
|
+
try {
|
|
850
|
+
files = readdirSync(lockDir);
|
|
851
|
+
} catch (_) {
|
|
852
|
+
return { reapedCount: 0, reaped: [] };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
for (const file of files) {
|
|
856
|
+
if (!file.endsWith(".json")) continue;
|
|
857
|
+
const lockPath = join(lockDir, file);
|
|
858
|
+
let record = null;
|
|
859
|
+
let corrupted = false;
|
|
860
|
+
|
|
861
|
+
try {
|
|
862
|
+
const content = readFileSync(lockPath, "utf-8");
|
|
863
|
+
if (!content || !content.trim()) {
|
|
864
|
+
corrupted = true;
|
|
865
|
+
} else {
|
|
866
|
+
record = JSON.parse(content);
|
|
867
|
+
}
|
|
868
|
+
} catch (_) {
|
|
869
|
+
corrupted = true;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
let shouldReap = false;
|
|
873
|
+
let reason = "";
|
|
874
|
+
|
|
875
|
+
if (corrupted) {
|
|
876
|
+
shouldReap = true;
|
|
877
|
+
reason = "corrupted";
|
|
878
|
+
} else if (!isLockLive(record)) {
|
|
879
|
+
shouldReap = true;
|
|
880
|
+
if (record.leased === true) {
|
|
881
|
+
reason = "expired";
|
|
882
|
+
} else if (record.expiresAt && Date.now() >= lockExpiry(record)) {
|
|
883
|
+
reason = "expired";
|
|
884
|
+
} else {
|
|
885
|
+
reason = "dead-pid";
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (shouldReap) {
|
|
890
|
+
if (!dryRun) {
|
|
891
|
+
try {
|
|
892
|
+
unlinkSync(lockPath);
|
|
893
|
+
} catch (_) {}
|
|
894
|
+
}
|
|
895
|
+
reaped.push({
|
|
896
|
+
file,
|
|
897
|
+
taskId: record?.taskId || file.replace(/\.json$/, ""),
|
|
898
|
+
agent: record?.agent || "unknown",
|
|
899
|
+
pid: record?.pid ?? null,
|
|
900
|
+
reason,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return { reapedCount: reaped.length, reaped };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
|
|
799
909
|
|