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/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 { checkTrojanSource } from "./bidi-guard.mjs";
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
- // Check Trojan Source
335
- if (!segment.file || !segment.file.endsWith('.md')) {
336
- const tsRes = checkTrojanSource(addedText, options);
337
- if (!tsRes.ok) {
338
- // Find line number where it happened if possible
339
- const bidiRegex = /[\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u061C\u200E\u200F]/;
340
- let lineNo = null;
341
- for (const l of segment.lines) {
342
- if (bidiRegex.test(l.text)) {
343
- lineNo = l.no;
344
- break;
345
- }
346
- }
347
- for (const v of tsRes.violations) {
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: "TROJAN_SOURCE_DETECTED",
362
+ type: v.type,
351
363
  file: segment.file,
352
- line: lineNo,
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 { checkTrojanSource } from "./bidi-guard.mjs";
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";
@@ -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: process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
102
- allowTestChanges: process.env.JULES_ALLOW_TEST_CHANGES || opts.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
- try {
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
- const lockFile = join(lockDir, `${taskId}.json`);
649
+ return withVfsMutex(join(lockDir, ".acquire.mutex"), () => {
650
+ const lockFile = join(lockDir, `${taskId}.json`);
638
651
 
639
- if (existsSync(lockFile)) {
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
- locks.push(Object.freeze(JSON.parse(content)));
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