pi-goal-list-loop-audit 0.36.0 → 0.36.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.36.1 — crash-safe persistence and packed-release verification (2026-08-31)
4
+
5
+ ### Fixed
6
+ Terminal archival now records a durable intent before publication and
7
+ reconciles interrupted archive/state commits on startup, preventing a
8
+ published archive from resurrecting an active goal or permanently fencing
9
+ the next archive attempt.
10
+
11
+ Destructive queue operations now fail closed when sidecar cleanup cannot be
12
+ proven successful. List clear/cancel/remove, group close, carryover, wipe,
13
+ and repair-source consumption preserve recoverable durable work instead of
14
+ mutating memory and allowing a failed deletion to reappear after restart.
15
+
16
+ The active ledger now rotates into immutable, ownership-fenced segments and
17
+ keeps a complete current-state snapshot in the active file. Startup recovery
18
+ handles the rename-before-rewrite interruption window while forensic history
19
+ remains available.
20
+
21
+ Bounded scanners and streaming reducers now back list depth, log tails,
22
+ switch logs, postaudit cadence, and multi-project statistics. Detached
23
+ auditor scratch now has a read-only health report plus age-bounded cleanup
24
+ limited to worker identities proven dead; ambiguous directories remain
25
+ untouched.
26
+
27
+ The runtime-global bridge now has one compile-checked registration registry
28
+ and typed high-risk lifecycle fields, reducing silent name and shape drift.
29
+
30
+ ### Changed
31
+ `release:check` now packs, installs, and imports the actual npm tarball in a
32
+ temporary directory, including the shipped goal entry point and auditor
33
+ launcher/worker paths. No registry publish is performed by the smoke.
34
+
3
35
  ## 0.36.0 — event-driven long-running supervision (2026-08-28)
4
36
 
5
37
  ### Added
package/README.md CHANGED
@@ -259,6 +259,9 @@ templates, themes, or context files, so its model must be usable in a plain pi
259
259
  session. It is independent verification, not an OS sandbox: the auditor's
260
260
  `bash` tool can still change files if a prompt or verifier tells it to. Keep
261
261
  verification commands bounded and treat repository permissions accordingly.
262
+ On Linux, both direct contract checks and the detached auditor enforce a
263
+ 256-process process-group ceiling to contain recursive helper/test launches;
264
+ cross-platform timeout and process-tree cleanup remain in place as well.
262
265
 
263
266
  ## Recommended pi extensions
264
267
 
@@ -22,6 +22,11 @@ GLLA automation is **event-driven and progress-aware**, not duration-guessed.
22
22
  Detached auditors have no unconditional wall-clock expiry: legacy wall
23
23
  metadata is ignored, while confirmed silence and an individual tool timeout
24
24
  remain the bounded safety mechanisms.
25
+ - Project verification commands run in an owned process group. On Linux, GLLA
26
+ also counts that group every 100ms and aborts it above 256 processes; this
27
+ catches recursive test/helper launches before a long wall timeout can become
28
+ a host-wide process or swap storm. The limit is a containment result, never
29
+ an automatic retry.
25
30
  - Timers remain useful for per-attempt backoff, watchdogs, and host safety. A
26
31
  timer is never evidence that work completed and is not the definition of a
27
32
  long-running process's lifetime. Detached-auditor first-event silence starts
package/docs/INDEX.md CHANGED
@@ -18,14 +18,15 @@ For shipped docs, the relevant entry points are:
18
18
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
19
19
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
20
20
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
21
- v0.35.14–v0.36.0 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.36.1 continue through the supervisor freeze (`/glla pause`),
22
22
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
23
23
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
24
24
  durable state-root selection, blank-until-resume auditor context, frozen
25
25
  subagent recovery, bounded repair/replan recovery, production RPC child
26
26
  stopping, mandatory hermetic auditor-extension validation, optional provider
27
- extensions, and bounded zero-stream retry containment see CHANGELOG.md for
28
- the full trail.
27
+ extensions, bounded zero-stream retry containment, crash-safe persistence,
28
+ and packed-artifact release verification — see CHANGELOG.md for the full
29
+ trail.
29
30
  - `../README.md` — what the plugin is, install, quickstart, and the
30
31
  architectural guarantee (drafting + confirm + detached auditor).
31
32
  - `../INSTALL.md` — manual install / symlink setup; the recommended
@@ -14,8 +14,8 @@ import { renderAgentsPanel, tailChildTranscript, TRANSCRIPT_HEADER_SCAN_MAX_BYTE
14
14
  import { state, replaceState } from "./goal-state.js";
15
15
  import {
16
16
  DEFAULT_TOKEN_LIMIT, Goal, ListItem, Status, appendLedger, archiveDir, archivedGoalPath, bumpGoalRevision, sanitizeProviderDisplayText,
17
- computeListDepth, clearQueueItemFiles, deleteQueueItemFile, extractVerificationContract, formatAuditLog, formatGoalAuditHistory, formatMainModelRecoveryStatus, queueItemSidecarCount,
18
- formatListDepth, goalArgsNeedDrafting, ledgerPath, newGoalId, nowIso, parseListImport, parseListItemDeclaration,
17
+ computeListDepthFromLedger, clearQueueItemFiles, deleteQueueItemFile, deleteQueueItemFileResult, extractVerificationContract, formatAuditLog, formatGoalAuditHistory, formatMainModelRecoveryStatus, queueItemSidecarCount,
18
+ formatListDepth, goalArgsNeedDrafting, ledgerPath, newGoalId, nowIso, parseListImport, parseListItemDeclaration, readLedgerTail,
19
19
  assignQueueOrder, compareQueueItems, readAuditLog, readQueueFromDisk, routeGoalArgs, routeListText, sanitizeDisplayText, sanitizeProviderAuditReport, statusLabel,
20
20
  visibleListPosition, visibleListPositions,
21
21
  writeQueueItemFile, type ModeCommand, type State, LIST_MUTATING_SUBCOMMANDS, SETTINGS_MUTATING_ACTIONS,
@@ -26,7 +26,7 @@ import type { AuditDisplayProgress } from "./goal-loop-display.js";
26
26
  import { fmtElapsed } from "./goal-loop-display.js";
27
27
  import { AUDIT_FINDINGS_REL, HELD_ON_RESTORE, LOOP_AUDIT_MARKER, listAuditCollectTarget, projectAuditTarget } from "./goal-loop-forever.js";
28
28
  import { buildLoopCompletionSummary, compactCompletionSummary, compactTerminalCompletionSummary } from "./completion-summary.js";
29
- import { ProjectRollup, discoverGllaProjects, filterPremature, formatRollupJson, formatRollupTable, parseLedgerEntries, rollupProject } from "./goal-loop-stats.js";
29
+ import { ProjectRollup, discoverGllaProjects, filterPremature, formatRollupJson, formatRollupTable, rollupProject } from "./goal-loop-stats.js";
30
30
  import { OVERRIDABLE_AGENT_TYPES, resolveEffectiveSubagentModel } from "./goal-loop-subagents.js";
31
31
  import { Settings, globalSettingsPath, loadSettings, projectSettingsPath, saveSettings, settingsProvenance } from "./goal-settings.js";
32
32
  import { resolveGllaStateDir } from "./glla-state-root.js";
@@ -36,7 +36,7 @@ import type { SettingsSectionId } from "./settings-menu.js";
36
36
  import { cmdLoop, clearLoopTimer, finishLoopGit, isLoopActive, scheduleLoopTick } from "./goal-loop.js";
37
37
  import { chooseObjectiveConflict, liveObjectives } from "./goal-objective-conflict.js";
38
38
  import { formatGllaVersion } from "./glla-version.js";
39
- import { cancelDetachedGoalCompletionAuditor } from "./goal-loop-auditor-process.js";
39
+ import { cancelDetachedGoalCompletionAuditor, cleanupDeadAuditJobs, inspectAuditJobHealth } from "./goal-loop-auditor-process.js";
40
40
  import { releaseAuditorSurface } from "./loops/goal-auditor-surface.js";
41
41
  import { inferStartFromSession, type StartContextInference } from "./start-context.js";
42
42
 
@@ -1233,13 +1233,7 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1233
1233
  if (sub === "depth") {
1234
1234
  // v0.25.3: long-running state at a glance — queue depth, oldest item
1235
1235
  // age, average item duration from archived list-policy goals.
1236
- let entries: Array<{ type: string; value?: any }> = [];
1237
- try {
1238
- entries = parseLedgerEntries(fs.readFileSync(ledgerPath(ctx.cwd), "utf-8"));
1239
- } catch {
1240
- /* no ledger yet */
1241
- }
1242
- const stats = computeListDepth(listQueue(), entries, Date.now());
1236
+ const stats = computeListDepthFromLedger(listQueue(), ctx.cwd, Date.now());
1243
1237
  ctx.ui.notify(`/list depth: ${formatListDepth(stats)}`, "info");
1244
1238
  return;
1245
1239
  }
@@ -1416,6 +1410,11 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1416
1410
  // stale-handle reload would resurrect the cleared items.
1417
1411
  const dropped = listQueue();
1418
1412
  const clearedSidecars = clearQueueItemFiles(ctx.cwd);
1413
+ if (clearedSidecars.failed.length > 0) {
1414
+ appendLedger(ctx.cwd, "list_clear_sidecar_cleanup_failed", { count: clearedSidecars.failed.length, sidecars: clearedSidecars.failed });
1415
+ ctx.ui.notify(`List clear refused — ${clearedSidecars.failed.length} queue sidecar(s) could not be removed. The in-memory list is unchanged; fix disk access and retry.`, "warning");
1416
+ return;
1417
+ }
1419
1418
  replaceState({ ...state, list: [] });
1420
1419
  persistState(ctx);
1421
1420
  appendLedger(ctx.cwd, "list_cleared", { count: dropped.length, sidecars: clearedSidecars.removed });
@@ -1453,6 +1452,12 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1453
1452
  // v0.35.0: clear the union of RAM and disk queue state. Orphaned
1454
1453
  // sidecars otherwise resurrected after a stale reload.
1455
1454
  const clearedSidecars = clearQueueItemFiles(ctx.cwd);
1455
+ if (clearedSidecars.failed.length > 0) {
1456
+ appendLedger(ctx.cwd, "list_cancel_sidecar_cleanup_failed", { count: clearedSidecars.failed.length, sidecars: clearedSidecars.failed });
1457
+ ctx.ui.notify(`List cancellation is incomplete — ${clearedSidecars.failed.length} queue sidecar(s) could not be removed. Waiting items remain recoverable; fix disk access and retry.`, "warning");
1458
+ if (activeIsListItem) ctx.abort();
1459
+ return;
1460
+ }
1456
1461
  replaceState({ ...state, list: [] });
1457
1462
  persistState(ctx);
1458
1463
  appendLedger(ctx.cwd, "list_cancelled", { abortedActive: activeIsListItem, dropped, sidecars: clearedSidecars.removed });
@@ -1536,7 +1541,12 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1536
1541
  // cannot resurrect the removed item. Without this, the new fallback
1537
1542
  // (cmdList → readQueueFromDisk) would show the removed item after
1538
1543
  // a stale-handle /list, contradicting the user's explicit remove.
1539
- deleteQueueItemFile(ctx.cwd, removed.id);
1544
+ const deleted = deleteQueueItemFileResult(ctx.cwd, removed.id);
1545
+ if (deleted.failed) {
1546
+ appendLedger(ctx.cwd, "list_remove_sidecar_delete_failed", { id: removed.id, path: deleted.path });
1547
+ ctx.ui.notify(`Remove refused — the durable sidecar for ${displaySlice(removed.objective, 80)} could not be removed. The item remains queued; fix disk access and retry.`, "warning");
1548
+ return;
1549
+ }
1540
1550
  replaceState({ ...state, list: queue.filter((_, i) => i !== n) });
1541
1551
  persistState(ctx);
1542
1552
  appendLedger(ctx.cwd, "list_removed", { id: removed.id, objective: removed.objective });
@@ -1894,13 +1904,12 @@ function cmdLog(args: string, ctx: ExtensionContext): void {
1894
1904
  const n = Math.min(Math.max(parseInt(nMatch?.[1] ?? "15", 10) || 15, 1), 100);
1895
1905
  let entries: Array<{ type: string; at?: string; value?: any }> = [];
1896
1906
  try {
1897
- entries = parseLedgerEntries(fs.readFileSync(ledgerPath(ctx.cwd), "utf-8"));
1907
+ entries = readLedgerTail(ctx.cwd, n, (entry) => all || !LOG_NOISE.has(entry.type));
1898
1908
  } catch {
1899
1909
  ctx.ui.notify("No ledger yet — .pi-glla/active.jsonl doesn't exist.", "info");
1900
1910
  return;
1901
1911
  }
1902
- const visible = all ? entries : entries.filter((e) => !LOG_NOISE.has(e.type));
1903
- const tail = visible.slice(-n);
1912
+ const tail = entries;
1904
1913
  if (tail.length === 0) {
1905
1914
  ctx.ui.notify("Ledger is empty (no non-noise events yet).", "info");
1906
1915
  return;
@@ -1925,13 +1934,12 @@ function cmdSwitchlog(args: string, ctx: ExtensionContext): void {
1925
1934
  const n = Math.min(Math.max(parseInt(nMatch?.[1] ?? "15", 10) || 15, 1), 100);
1926
1935
  let entries: Array<{ type: string; at?: string; value?: any }> = [];
1927
1936
  try {
1928
- entries = parseLedgerEntries(fs.readFileSync(ledgerPath(ctx.cwd), "utf-8"));
1937
+ entries = readLedgerTail(ctx.cwd, n, (entry) => entry.type === "model_switch" || entry.type === "forbidden_model_switch");
1929
1938
  } catch {
1930
1939
  ctx.ui.notify("No ledger yet — .pi-glla/active.jsonl doesn't exist.", "info");
1931
1940
  return;
1932
1941
  }
1933
- const switches = entries.filter((e) => e.type === "model_switch" || e.type === "forbidden_model_switch");
1934
- const tail = switches.slice(-n);
1942
+ const tail = entries;
1935
1943
  if (tail.length === 0) {
1936
1944
  ctx.ui.notify("No model switches recorded yet — /glla switchlog shows the model_switch / forbidden_model_switch trail.", "info");
1937
1945
  return;
@@ -2049,8 +2057,12 @@ async function cmdGllaWipe(ctx: ExtensionContext, entryChecked = false): Promise
2049
2057
  // sidecars otherwise resurrected after a stale reload.
2050
2058
  const clearedSidecars = clearQueueItemFiles(ctx.cwd);
2051
2059
  failedSidecars = clearedSidecars.failed;
2052
- replaceState({ ...state, list: [] });
2053
- appendLedger(ctx.cwd, "list_cleared", { via: "glla_wipe", count: n, sidecars: clearedSidecars.removed });
2060
+ if (failedSidecars.length === 0) {
2061
+ replaceState({ ...state, list: [] });
2062
+ appendLedger(ctx.cwd, "list_cleared", { via: "glla_wipe", count: n, sidecars: clearedSidecars.removed });
2063
+ } else {
2064
+ appendLedger(ctx.cwd, "glla_wipe_queue_cleanup_failed", { count: failedSidecars.length, sidecars: failedSidecars });
2065
+ }
2054
2066
  }
2055
2067
  if (loop) {
2056
2068
  clearLoopTimer();
@@ -2291,6 +2303,18 @@ async function cmdGllaCancel(ctx: ExtensionContext): Promise<void> {
2291
2303
  }
2292
2304
 
2293
2305
  function cmdAudits(args: string, ctx: ExtensionContext): void {
2306
+ if (/\bhealth\b/.test(args)) {
2307
+ const report = /\bcleanup\b/.test(args)
2308
+ ? cleanupDeadAuditJobs(ctx.cwd)
2309
+ : inspectAuditJobHealth(ctx.cwd);
2310
+ const action = /\bcleanup\b/.test(args) ? "cleanup" : "health";
2311
+ ctx.ui.notify(
2312
+ `glla audit-job ${action}: ${report.total} director${report.total === 1 ? "y" : "ies"} · ${report.live} live · ${report.dead} proven dead · ${report.ambiguous} ambiguous · ${report.bytes} bytes${report.cleanupCandidates > 0 ? ` · ${report.cleanupCandidates} old dead candidate(s)` : ""}.` +
2313
+ (report.ambiguous > 0 ? " Ambiguous locks were preserved." : ""),
2314
+ report.ambiguous > 0 ? "warning" : "info",
2315
+ );
2316
+ return;
2317
+ }
2294
2318
  const full = /\bfull\b/.test(args);
2295
2319
  const all = /\b(?:all|global|log)\b/.test(args);
2296
2320
  const nMatch = args.match(/\b(\d+)\b/);
@@ -2301,7 +2325,7 @@ function cmdAudits(args: string, ctx: ExtensionContext): void {
2301
2325
  ctx.ui.notify(`Latest audit on this goal — ${fromGoal.model} (${fromGoal.at})\n${sanitizeProviderAuditReport(fromGoal.report)}`, "info");
2302
2326
  return;
2303
2327
  }
2304
- const latest = readAuditLog(ctx.cwd).at(-1);
2328
+ const latest = readAuditLog(ctx.cwd, 1).at(-1);
2305
2329
  ctx.ui.notify(latest ? `Latest audit — ${latest.verdict} (${latest.model}, ${latest.at})\n${sanitizeProviderAuditReport(latest.report)}` : "No audits logged yet.", "info");
2306
2330
  return;
2307
2331
  }
@@ -2366,9 +2390,10 @@ export function cmdGllaBug(message: string, ctx: ExtensionContext): string {
2366
2390
  lines.push(`Captured at ${now.toISOString()} without mutating active.jsonl or goal md.`);
2367
2391
  lines.push("");
2368
2392
  try {
2369
- const ledgerPath = path.join(gllaDir, "active.jsonl");
2370
- const raw = fs.readFileSync(ledgerPath, "utf8").split("\n").filter(Boolean);
2371
- const tail = raw.slice(-20).join("\n").slice(0, 6000);
2393
+ const tail = readLedgerTail(ctx.cwd, 20)
2394
+ .map((entry) => JSON.stringify(entry))
2395
+ .join("\n")
2396
+ .slice(0, 6000);
2372
2397
  lines.push("### Recent active.jsonl tail (last 20 lines, read-only snapshot)");
2373
2398
  lines.push("```jsonl");
2374
2399
  lines.push(tail || "(empty)");
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs/promises";
11
- import { constants as fsConstants, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync } from "node:fs";
11
+ import { constants as fsConstants, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
12
12
  import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type ChildProcess, type SpawnOptions } from "node:child_process";
13
13
  import { createHash, randomUUID } from "node:crypto";
14
14
  import * as path from "node:path";
@@ -675,8 +675,8 @@ function processAlive(pid: number): boolean {
675
675
  try {
676
676
  process.kill(pid, 0);
677
677
  return true;
678
- } catch {
679
- return false;
678
+ } catch (err) {
679
+ return (err as NodeJS.ErrnoException).code === "EPERM";
680
680
  }
681
681
  }
682
682
 
@@ -707,6 +707,112 @@ function durableWorkerPids(cwd: string, logicalAttemptId: string): Array<{ pid:
707
707
  return out;
708
708
  }
709
709
 
710
+ export type AuditJobHealthStatus = "live" | "dead" | "ambiguous";
711
+
712
+ export interface AuditJobHealthEntry {
713
+ attemptId: string;
714
+ dir: string;
715
+ ageMs: number;
716
+ bytes: number;
717
+ status: AuditJobHealthStatus;
718
+ pid?: number;
719
+ reason?: string;
720
+ }
721
+
722
+ export interface AuditJobHealthReport {
723
+ root: string;
724
+ scannedAt: string;
725
+ total: number;
726
+ live: number;
727
+ dead: number;
728
+ ambiguous: number;
729
+ bytes: number;
730
+ cleanupCandidates: number;
731
+ entries: AuditJobHealthEntry[];
732
+ }
733
+
734
+ /** A deliberately conservative threshold: an auditor that is merely slow is
735
+ * not a stale directory. Explicit cleanup only considers older directories
736
+ * whose worker PID is proven dead and whose lock advertises role=worker. */
737
+ export const AUDIT_JOB_CLEANUP_MIN_AGE_MS = 15 * 60_000;
738
+
739
+ function auditJobDirectoryBytes(dir: string): number {
740
+ let bytes = 0;
741
+ try {
742
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
743
+ if (!entry.isFile()) continue;
744
+ try { bytes += statSync(path.join(dir, entry.name)).size; } catch { /* health is best effort per file */ }
745
+ }
746
+ } catch {
747
+ /* unreadable contents remain represented by the ambiguous lock status */
748
+ }
749
+ return bytes;
750
+ }
751
+
752
+ /** Read-only project-wide audit-job inventory. It never signals a process and
753
+ * never removes an ambiguous or unreadable directory. */
754
+ export function inspectAuditJobHealth(cwd: string, nowMs = Date.now()): AuditJobHealthReport {
755
+ const root = path.join(piGlaDir(cwd), "audit-jobs");
756
+ const entries: AuditJobHealthEntry[] = [];
757
+ let dirs: Array<{ name: string; isDirectory: () => boolean }> = [];
758
+ try {
759
+ dirs = readdirSync(root, { withFileTypes: true });
760
+ } catch (err) {
761
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
762
+ return { root, scannedAt: new Date(nowMs).toISOString(), total: 0, live: 0, dead: 0, ambiguous: 0, bytes: 0, cleanupCandidates: 0, entries };
763
+ }
764
+ return { root, scannedAt: new Date(nowMs).toISOString(), total: 0, live: 0, dead: 0, ambiguous: 1, bytes: 0, cleanupCandidates: 0, entries: [{ attemptId: "<audit-jobs-root>", dir: root, ageMs: 0, bytes: 0, status: "ambiguous", reason: "root unreadable" }] };
765
+ }
766
+ for (const entry of dirs) {
767
+ if (!entry.isDirectory()) continue;
768
+ const dir = path.join(root, entry.name);
769
+ let ageMs = 0;
770
+ try { ageMs = Math.max(0, nowMs - statSync(dir).mtimeMs); } catch { ageMs = 0; }
771
+ const bytes = auditJobDirectoryBytes(dir);
772
+ let status: AuditJobHealthStatus = "ambiguous";
773
+ let pid: number | undefined;
774
+ let reason: string | undefined = "missing or unreadable worker lock";
775
+ try {
776
+ const lock = JSON.parse(readFileSync(path.join(dir, "lock"), "utf8")) as Record<string, unknown>;
777
+ pid = typeof lock.pid === "number" ? lock.pid : Number(lock.pid);
778
+ if (lock.role === "worker" && Number.isInteger(pid) && pid > 1) {
779
+ if (workerProcessMatches(cwd, pid, dir)) {
780
+ status = "live";
781
+ reason = undefined;
782
+ } else if (!processAlive(pid)) {
783
+ status = "dead";
784
+ reason = "worker PID is not alive";
785
+ } else {
786
+ reason = "PID is alive but worker identity does not match this job";
787
+ }
788
+ } else {
789
+ reason = "lock is not a worker-owned identity";
790
+ }
791
+ } catch {
792
+ /* preserve ambiguous */
793
+ }
794
+ entries.push({ attemptId: entry.name, dir, ageMs, bytes, status, ...(pid !== undefined ? { pid } : {}), ...(reason ? { reason } : {}) });
795
+ }
796
+ const live = entries.filter((entry) => entry.status === "live").length;
797
+ const dead = entries.filter((entry) => entry.status === "dead").length;
798
+ const ambiguous = entries.filter((entry) => entry.status === "ambiguous").length;
799
+ const bytes = entries.reduce((sum, entry) => sum + entry.bytes, 0);
800
+ const cleanupCandidates = entries.filter((entry) => entry.status === "dead" && entry.ageMs >= AUDIT_JOB_CLEANUP_MIN_AGE_MS).length;
801
+ return { root, scannedAt: new Date(nowMs).toISOString(), total: entries.length, live, dead, ambiguous, bytes, cleanupCandidates, entries };
802
+ }
803
+
804
+ /** Explicit, age-bounded cleanup for only proven-dead worker identities.
805
+ * Ambiguous locks are intentionally left for operator inspection. */
806
+ export function cleanupDeadAuditJobs(cwd: string, maxAgeMs = AUDIT_JOB_CLEANUP_MIN_AGE_MS, nowMs = Date.now()): AuditJobHealthReport {
807
+ const report = inspectAuditJobHealth(cwd, nowMs);
808
+ for (const entry of report.entries) {
809
+ if (entry.status !== "dead" || entry.ageMs < maxAgeMs || entry.pid === undefined) continue;
810
+ if (processAlive(entry.pid) || workerProcessMatches(cwd, entry.pid, entry.dir)) continue;
811
+ try { rmSync(entry.dir, { recursive: true, force: true }); } catch { /* preserve the next health report */ }
812
+ }
813
+ return inspectAuditJobHealth(cwd, nowMs);
814
+ }
815
+
710
816
  /** Reap workers left behind when their owning pi host died. This is the
711
817
  * cross-process companion to activeChildren, which intentionally cannot
712
818
  * survive a host restart. */