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 +32 -0
- package/README.md +3 -0
- package/docs/DESIGN-long-running-supervision.md +5 -0
- package/docs/INDEX.md +4 -3
- package/extensions/goal-commands.ts +49 -24
- package/extensions/goal-loop-auditor-process.ts +109 -3
- package/extensions/goal-loop-core.ts +623 -53
- package/extensions/goal-loop-display.ts +24 -4
- package/extensions/goal-loop-shield.ts +358 -40
- package/extensions/goal-loop-stats.ts +35 -20
- package/extensions/goal-loop.ts +4 -10
- package/extensions/goal-state.ts +2 -20
- package/extensions/loops/goal-activation.ts +38 -6
- package/extensions/loops/goal-auditor-hooks.ts +11 -7
- package/extensions/loops/goal-list-queue.ts +3 -4
- package/extensions/loops/goal-orchestrator.ts +65 -26
- package/extensions/loops/goal-runtime-globals.ts +562 -221
- package/extensions/loops/goal-session.ts +5 -3
- package/extensions/loops/goal-tools.ts +30 -11
- package/extensions/loops/goal-ui.ts +57 -0
- package/package.json +2 -2
- package/scripts/goal-auditor-worker.mjs +49 -0
- package/scripts/release-pack-smoke.mjs +97 -0
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.
|
|
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,
|
|
28
|
-
the full
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2053
|
-
|
|
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
|
|
2370
|
-
|
|
2371
|
-
|
|
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
|
|
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. */
|