session-orchestrator 5.3.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/no-follow-append.mjs — the ONE hardened JSONL append the
|
|
3
|
+
* reaper's two ledgers share: the B5 kill audit (`reaper-audit.mjs`) and the
|
|
4
|
+
* gate-process ledger (`../process-group.mjs`). It was inline in the audit
|
|
5
|
+
* writer; the gate ledger still used a plain `appendFileSync(path)` (#1489).
|
|
6
|
+
*
|
|
7
|
+
* Imports nothing but `node:fs`, so `process-group.mjs` takes it without
|
|
8
|
+
* growing its closure. The read-side twin is `readTailWindow(file, n,
|
|
9
|
+
* { noFollow: true })` in `../tail-window.mjs`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, closeSync, constants as fsConstants, fstatSync, openSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
/** The one refusal every hardened ledger writer prints — the appends here and
|
|
15
|
+
* the atomic prune rewrites (`replaceRegularFile`) in both ledger modules. */
|
|
16
|
+
export const NOT_A_REGULAR_FILE = 'not a regular file (a symlink is never written through) — left untouched';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `open(2)` flags of the append. `O_NOFOLLOW` makes the kernel refuse a
|
|
20
|
+
* symlink as the last path component (ELOOP) in the same call that opens the
|
|
21
|
+
* file, so there is no lstat-then-open window; `O_NONBLOCK` turns a planted FIFO
|
|
22
|
+
* with no reader into ENXIO instead of blocking in `open` (a plain append open
|
|
23
|
+
* of a FIFO waits for a reader that never comes — reproduced 2026-10-02, #1487).
|
|
24
|
+
* On a regular file `O_NONBLOCK` changes nothing.
|
|
25
|
+
* Named ceiling (BV-004): both are POSIX-only — Node leaves them undefined on
|
|
26
|
+
* Windows, where a linked ledger is still followed; revisit if the reaper or the
|
|
27
|
+
* gate ledger ever runs there.
|
|
28
|
+
*/
|
|
29
|
+
const APPEND_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT
|
|
30
|
+
| (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
31
|
+
|
|
32
|
+
/** @returns {Error & {code: string}} */
|
|
33
|
+
function notARegularFile() {
|
|
34
|
+
return Object.assign(new Error(NOT_A_REGULAR_FILE), { code: 'ERR_NOT_REGULAR_FILE' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Append `data` to `target`, creating it if absent — but never THROUGH a link
|
|
39
|
+
* and never blocked by a FIFO.
|
|
40
|
+
*
|
|
41
|
+
* The check is the open itself ({@link APPEND_FLAGS}) plus an `fstat` of the
|
|
42
|
+
* descriptor that was opened — never a separate lstat. That `fstat` refuses
|
|
43
|
+
* what the open let through: a FIFO that HAD a reader, a directory, a device,
|
|
44
|
+
* and a HARD-linked file (`nlink !== 1`): `ln <victim> <ledger>` passes every
|
|
45
|
+
* symlink check, and each append would then land in the victim (reproduced
|
|
46
|
+
* 2026-10-02 against the audit).
|
|
47
|
+
*
|
|
48
|
+
* THROWS — mapping a failure to a WARN line is the caller's job, as with
|
|
49
|
+
* `readTailWindow`. A symlink or any non-regular file throws
|
|
50
|
+
* `code: 'ERR_NOT_REGULAR_FILE'` with message {@link NOT_A_REGULAR_FILE}; a
|
|
51
|
+
* hard-linked file throws `code: 'ERR_HARD_LINKED'`; a FIFO without a reader
|
|
52
|
+
* throws the kernel's ENXIO unchanged; every other fs error passes through.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} target Absolute path; its directory must already exist.
|
|
55
|
+
* @param {string} data
|
|
56
|
+
* @returns {void}
|
|
57
|
+
*/
|
|
58
|
+
export function appendNoFollow(target, data) {
|
|
59
|
+
let fd;
|
|
60
|
+
try {
|
|
61
|
+
fd = openSync(target, APPEND_FLAGS);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw err?.code === 'ELOOP' ? notARegularFile() : err;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const stats = fstatSync(fd);
|
|
67
|
+
if (!stats.isFile()) throw notARegularFile();
|
|
68
|
+
// nlink 0: the name was replaced between open and fstat (a concurrent prune
|
|
69
|
+
// renames a fresh inode over it). Not a planted link — say so, so the
|
|
70
|
+
// operator does not go hunting for one.
|
|
71
|
+
if (stats.nlink === 0) {
|
|
72
|
+
throw Object.assign(
|
|
73
|
+
new Error('replaced while being opened (a concurrent rewrite renamed a new file over it) — this record was not written'),
|
|
74
|
+
{ code: 'ERR_UNLINKED' },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (stats.nlink !== 1) {
|
|
78
|
+
throw Object.assign(
|
|
79
|
+
new Error(`hard-linked (${stats.nlink} names) — an append would write into every one of them; left untouched`),
|
|
80
|
+
{ code: 'ERR_HARD_LINKED' },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
appendFileSync(fd, data, 'utf8');
|
|
84
|
+
} finally {
|
|
85
|
+
try { closeSync(fd); } catch { /* nothing left to release */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/ps-snapshot.mjs — the binding `ps` format: how the reaper
|
|
3
|
+
* measures the process table, and how it reads what it measured.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
import { parseEtimeToSeconds } from '../resource-probe/parsers.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The binding `ps` invocation (Discovery d-2, 2026-09-21, Darwin 25.6.0).
|
|
12
|
+
*
|
|
13
|
+
* Headerless (`=` per field) and six NUMERIC fields before `args`, because on
|
|
14
|
+
* macOS `comm` is a 16-character-truncated PATH that may contain spaces (68 of
|
|
15
|
+
* 784 processes carried a space, 13 of them survived the truncation) — appending
|
|
16
|
+
* `args` after `comm` breaks whitespace splitting outright. `-ww` disables column
|
|
17
|
+
* truncation; `ps` escapes control characters, so one process is one line.
|
|
18
|
+
* `rss` is in KiB. Roundtrip measured at ~47 ms for 287 KB / 784 processes.
|
|
19
|
+
*
|
|
20
|
+
* `pgid=` is the sixth column and is what lets a row join the ledger as a GROUP
|
|
21
|
+
* MEMBER rather than only as the recorded leader (see `reaper-decide.mjs`).
|
|
22
|
+
* Availability measured 2026-09-22 on Darwin 25.6.0 — `ps -Aww -o
|
|
23
|
+
* pid=,ppid=,pgid=,rss=,etime=,%cpu=,args=` exits 0 and prints the column; it is
|
|
24
|
+
* POSIX (`pgid` is a standard `-o` keyword) and present on Linux `procps` too.
|
|
25
|
+
*
|
|
26
|
+
* A targeted call, NOT the full `probe()` — that one spawns up to five
|
|
27
|
+
* subprocesses and has no caching (PRD § B4).
|
|
28
|
+
*/
|
|
29
|
+
export const PS_ARGS = Object.freeze(['-Aww', '-o', 'pid=,ppid=,pgid=,rss=,etime=,%cpu=,args=']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The TARGETED variant of {@link PS_ARGS}: one pid, same seven columns.
|
|
33
|
+
*
|
|
34
|
+
* Used immediately before every signal and once after the ladder (B3/B6). It is
|
|
35
|
+
* a separate call rather than a re-run of the full `-A` scan because the whole
|
|
36
|
+
* point is freshness at the moment of signalling — a 784-row snapshot taken for
|
|
37
|
+
* the population is already stale by the time the ladder's 10 s grace elapses,
|
|
38
|
+
* and re-taking it per signal would cost 287 KB to learn one row.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} pid
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
export function psPidArgs(pid) {
|
|
44
|
+
return ['-ww', '-p', String(pid), '-o', 'pid=,ppid=,pgid=,rss=,etime=,%cpu=,args='];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Wall-clock ceiling for one `ps` call. Same 2 s as `runPsDetailed()` in
|
|
48
|
+
* `resource-probe/probe-platform.mjs`, whose spawn/timeout shape this mirrors. */
|
|
49
|
+
const PS_TIMEOUT_MS = 2000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One parsed `ps` row.
|
|
53
|
+
* @typedef {object} PsRow
|
|
54
|
+
* @property {number} pid
|
|
55
|
+
* @property {number} ppid
|
|
56
|
+
* @property {number} pgid Process-group id — the ledger's group join key.
|
|
57
|
+
* @property {number} rssKb Resident set size in KiB.
|
|
58
|
+
* @property {number} etimeSeconds Elapsed seconds since exec.
|
|
59
|
+
* @property {number} cpuPct
|
|
60
|
+
* @property {string} args Full command line.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse `ps` output in the {@link PS_ARGS} format, tolerantly — and COUNT what
|
|
65
|
+
* it drops.
|
|
66
|
+
*
|
|
67
|
+
* A silently skipping parser turns a partial read into a clean verdict, which is
|
|
68
|
+
* the exact failure mode a reaper must not have; hence the `malformed` count
|
|
69
|
+
* ({@link parsePsSnapshotDetailed}). A row whose `etime` does not parse is
|
|
70
|
+
* treated as malformed rather than returned with a null age: every returned row
|
|
71
|
+
* must carry a usable age, because age is a load-bearing gate here.
|
|
72
|
+
*
|
|
73
|
+
* @param {string|null|undefined} text
|
|
74
|
+
* @returns {{rows: PsRow[], malformed: number}}
|
|
75
|
+
*/
|
|
76
|
+
export function parsePsSnapshotDetailed(text) {
|
|
77
|
+
if (text === null || text === undefined) return { rows: [], malformed: 0 };
|
|
78
|
+
/** @type {PsRow[]} */
|
|
79
|
+
const rows = [];
|
|
80
|
+
let malformed = 0;
|
|
81
|
+
// Six whitespace-free fields, then `args` as the ENTIRE rest of the line —
|
|
82
|
+
// args legitimately contains spaces, so it must never be split.
|
|
83
|
+
const rowRe = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
|
|
84
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
85
|
+
if (raw.trim().length === 0) continue;
|
|
86
|
+
const m = rowRe.exec(raw);
|
|
87
|
+
if (!m) { malformed += 1; continue; }
|
|
88
|
+
const etimeSeconds = parseEtimeToSeconds(m[5]);
|
|
89
|
+
const cpuPct = parseFloat(m[6]);
|
|
90
|
+
if (etimeSeconds === null || Number.isNaN(cpuPct)) { malformed += 1; continue; }
|
|
91
|
+
rows.push({
|
|
92
|
+
pid: parseInt(m[1], 10),
|
|
93
|
+
ppid: parseInt(m[2], 10),
|
|
94
|
+
pgid: parseInt(m[3], 10),
|
|
95
|
+
rssKb: parseInt(m[4], 10),
|
|
96
|
+
etimeSeconds,
|
|
97
|
+
cpuPct,
|
|
98
|
+
args: m[7] ?? '',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { rows, malformed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* {@link parsePsSnapshotDetailed} without the drop count.
|
|
106
|
+
* @param {string|null|undefined} text
|
|
107
|
+
* @returns {PsRow[]}
|
|
108
|
+
*/
|
|
109
|
+
export function parsePsSnapshot(text) {
|
|
110
|
+
return parsePsSnapshotDetailed(text).rows;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Default `ps` runner: the {@link PS_ARGS} call with a hard 2 s deadline,
|
|
115
|
+
* SIGKILL on overrun, and `null` on any failure. Never throws.
|
|
116
|
+
*
|
|
117
|
+
* Mirrors `runPsDetailed()` in `resource-probe/probe-platform.mjs` — the same
|
|
118
|
+
* spawn/settle/timeout shape, a different column set.
|
|
119
|
+
*
|
|
120
|
+
* @param {number} [timeoutMs]
|
|
121
|
+
* @returns {Promise<string|null>}
|
|
122
|
+
*/
|
|
123
|
+
export function runPs(timeoutMs = PS_TIMEOUT_MS) {
|
|
124
|
+
return runPsArgs([...PS_ARGS], timeoutMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Default TARGETED `ps` runner (B3/B6): {@link psPidArgs} with the same hard
|
|
129
|
+
* deadline and the same `null`-on-any-failure contract as {@link runPs}.
|
|
130
|
+
*
|
|
131
|
+
* `ps -p <gone-pid>` exits NON-ZERO with empty stdout on macOS, which
|
|
132
|
+
* {@link runPsArgs} maps to `null` — so "gone" and "could not measure" arrive
|
|
133
|
+
* here as the same value. That is why the caller treats a `null` as UNMEASURED
|
|
134
|
+
* and refuses the signal, rather than reading it as proof of death: refusing on
|
|
135
|
+
* an absent measurement costs one skipped reap, believing it costs a bystander.
|
|
136
|
+
*
|
|
137
|
+
* @param {number} pid
|
|
138
|
+
* @param {number} [timeoutMs]
|
|
139
|
+
* @returns {Promise<string|null>}
|
|
140
|
+
*/
|
|
141
|
+
export function runPsPid(pid, timeoutMs = PS_TIMEOUT_MS) {
|
|
142
|
+
if (!Number.isInteger(pid) || pid <= 0) return Promise.resolve(null);
|
|
143
|
+
return runPsArgs(psPidArgs(pid), timeoutMs);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Spawn `ps` with `args`, capped at `timeoutMs`, `null` on any failure.
|
|
148
|
+
* Never throws. Mirrors `runPsDetailed()` in `resource-probe/probe-platform.mjs`.
|
|
149
|
+
*
|
|
150
|
+
* @param {string[]} args
|
|
151
|
+
* @param {number} timeoutMs
|
|
152
|
+
* @returns {Promise<string|null>}
|
|
153
|
+
*/
|
|
154
|
+
function runPsArgs(args, timeoutMs) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
if (process.platform === 'win32') { resolve(null); return; }
|
|
157
|
+
let settled = false;
|
|
158
|
+
const finish = (value) => {
|
|
159
|
+
if (settled) return;
|
|
160
|
+
settled = true;
|
|
161
|
+
resolve(value);
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
const child = spawn('ps', args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
165
|
+
const chunks = [];
|
|
166
|
+
child.stdout.on('data', (c) => chunks.push(c));
|
|
167
|
+
child.on('error', () => finish(null));
|
|
168
|
+
child.on('close', (code) => {
|
|
169
|
+
if (code !== 0) return finish(null);
|
|
170
|
+
finish(Buffer.concat(chunks).toString('utf8'));
|
|
171
|
+
});
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
174
|
+
finish(null);
|
|
175
|
+
}, timeoutMs);
|
|
176
|
+
timer.unref?.();
|
|
177
|
+
} catch {
|
|
178
|
+
finish(null);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/reaper-audit.mjs — the B5 kill audit: its record shape, its
|
|
3
|
+
* writer, its bounded reader and pruner, and the HR-101 false-alarm rate the
|
|
4
|
+
* reader exists for.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, statSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { replaceRegularFile } from '../process-group.mjs';
|
|
11
|
+
import { readTailWindow } from '../tail-window.mjs';
|
|
12
|
+
import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
|
|
13
|
+
import { NOT_A_REGULAR_FILE, appendNoFollow } from './no-follow-append.mjs';
|
|
14
|
+
|
|
15
|
+
/** Relative path of the JSONL kill audit (B5). Gitignored via `.gitignore:55`
|
|
16
|
+
* (`.orchestrator/metrics/*.jsonl`), verified with `git check-ignore --no-index -v`.
|
|
17
|
+
* Under the `ledger-delete-protected` policy rule: in-process fs only, never a
|
|
18
|
+
* shell `rm`/`mv`. */
|
|
19
|
+
export const REAPER_AUDIT_RELPATH = '.orchestrator/metrics/reaper-audit.jsonl';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* HR-101 ceiling: above this false-alarm rate the INSTRUMENT is suspect, and
|
|
23
|
+
* the answer is to re-aim it — never to raise the threshold it fires on. Surfaced
|
|
24
|
+
* as `instrumentSuspect` in the scan result; nothing in the reaper acts on it,
|
|
25
|
+
* because acting on a broken instrument is the failure it names.
|
|
26
|
+
*/
|
|
27
|
+
export const FALSE_ALARM_SUSPECT_RATE = 0.1;
|
|
28
|
+
|
|
29
|
+
/** Characters of `args` copied into an audit record — and ONLY for a command
|
|
30
|
+
* that already matched the read-only allowlist, so a foreign process's command
|
|
31
|
+
* line (which may carry paths or tokens) never reaches the audit. */
|
|
32
|
+
const ARGS_HEAD_CHARS = 80;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Size ceiling of the audit file and what a prune keeps. Named ceiling (BV-004):
|
|
36
|
+
* a `kill` record with `args_head` and `result` serialises to 505 bytes, a
|
|
37
|
+
* `report` record to 285 (measured on {@link buildAuditRecord} output 2026-10-01;
|
|
38
|
+
* no real audit file existed on this host), so the kept 512 KiB hold ~1,000-1,800
|
|
39
|
+
* records against the 50 decisions the false-alarm window reads — fewer
|
|
40
|
+
* decisions where reports dominate: a scan re-reporting R processes keeps
|
|
41
|
+
* ~1,800/(R+1) of them, below 50 from R ≈ 35. Revisit if
|
|
42
|
+
* `reaper.false-alarm-window` is ever configured above ~1,000.
|
|
43
|
+
*/
|
|
44
|
+
const REAPER_AUDIT_MAX_BYTES = 1024 * 1024;
|
|
45
|
+
const REAPER_AUDIT_KEEP_BYTES = 512 * 1024;
|
|
46
|
+
|
|
47
|
+
/** First tail window {@link readAuditRecords} tries; it grows ×4 only while the
|
|
48
|
+
* window holds fewer complete lines, or fewer rate decisions, than requested,
|
|
49
|
+
* and never past {@link REAPER_AUDIT_MAX_BYTES}. */
|
|
50
|
+
const AUDIT_TAIL_START_BYTES = 64 * 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether an audit record is one of the HR-101 rate's DECISIONS: the reaper
|
|
54
|
+
* judged a process a reapable orphan (`kill` | `dry-run` | `reject`). The one
|
|
55
|
+
* definition {@link readAuditRecords} (how far back to read) and
|
|
56
|
+
* {@link falseAlarmRate} (what to count) share — if they disagreed, the reader
|
|
57
|
+
* would stop growing at a population the rate does not count.
|
|
58
|
+
*
|
|
59
|
+
* @param {{decision?: unknown}|null|undefined} record
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function isRateDecision(record) {
|
|
63
|
+
const decision = record?.decision;
|
|
64
|
+
return decision === 'kill' || decision === 'dry-run' || decision === 'reject';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Absolute path of the kill audit for a repo. One constant per path.
|
|
68
|
+
* @param {string} repoRoot @returns {string} */
|
|
69
|
+
export function auditPath(repoRoot) {
|
|
70
|
+
return underRepo(repoRoot, REAPER_AUDIT_RELPATH);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build one B5 audit record.
|
|
75
|
+
*
|
|
76
|
+
* Exactly ONE `trigger` per record, taken from the single verdict
|
|
77
|
+
* `decideReapCandidates` assigned at its fixed priority — a kill carries
|
|
78
|
+
* `orphan-ppid1`, a report carries the reason it was reported for. That is what
|
|
79
|
+
* makes "why exactly was this killed" answerable afterwards; a record listing
|
|
80
|
+
* two triggers would answer it with a shrug.
|
|
81
|
+
*
|
|
82
|
+
* `reason` is separate from `trigger` and present on every WITHDRAWAL: the
|
|
83
|
+
* trigger says what made this a candidate, the reason says what took it back.
|
|
84
|
+
* `args_head` is present ONLY for a command that cleared the read-only
|
|
85
|
+
* allowlist — see {@link ARGS_HEAD_CHARS}.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} entry A candidate or a reported/rejected entry.
|
|
88
|
+
* @param {'kill'|'report'|'reject'|'dry-run'} decision
|
|
89
|
+
* @param {object} [extra]
|
|
90
|
+
* @param {string} extra.timestamp
|
|
91
|
+
* @param {string|null} [extra.sessionId]
|
|
92
|
+
* @param {object} [extra.result]
|
|
93
|
+
* @param {string} [extra.reason]
|
|
94
|
+
* @returns {object}
|
|
95
|
+
*/
|
|
96
|
+
export function buildAuditRecord(entry, decision, {
|
|
97
|
+
timestamp, sessionId = null, result, reason,
|
|
98
|
+
} = {}) {
|
|
99
|
+
/** @type {Record<string, unknown>} */
|
|
100
|
+
const record = {
|
|
101
|
+
timestamp,
|
|
102
|
+
session_id: sessionId,
|
|
103
|
+
pid: entry.pid,
|
|
104
|
+
pgid: entry.pgid ?? null,
|
|
105
|
+
trigger: entry.trigger ?? entry.reason ?? null,
|
|
106
|
+
threshold: entry.threshold ?? null,
|
|
107
|
+
actual: entry.actual ?? (typeof entry.ageSeconds === 'number' ? { ageSeconds: entry.ageSeconds } : null),
|
|
108
|
+
unit: 'seconds',
|
|
109
|
+
command_signature: entry.commandSignature ?? null,
|
|
110
|
+
decision,
|
|
111
|
+
};
|
|
112
|
+
if (typeof entry.args === 'string' && entry.args.length > 0) {
|
|
113
|
+
record.args_head = entry.args.slice(0, ARGS_HEAD_CHARS);
|
|
114
|
+
}
|
|
115
|
+
if (result !== undefined) record.result = result;
|
|
116
|
+
const effectiveReason = reason ?? (decision === 'report' ? entry.reason : undefined);
|
|
117
|
+
if (effectiveReason !== undefined && effectiveReason !== null) record.reason = effectiveReason;
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Default audit sink: append one JSONL line to {@link REAPER_AUDIT_RELPATH}.
|
|
123
|
+
* In-process fs only (the path sits under the `ledger-delete-protected` policy
|
|
124
|
+
* rule). Best-effort — an audit write must never fail a scan — but a failure
|
|
125
|
+
* prints one WARN line rather than vanishing.
|
|
126
|
+
*
|
|
127
|
+
* Refuses a symlinked or non-regular audit exactly as {@link pruneReaperAudit}
|
|
128
|
+
* does: `appendFileSync(path)` follows a link, so `ln -s events.jsonl
|
|
129
|
+
* reaper-audit.jsonl` routed every kill record into the link target (CWE-59,
|
|
130
|
+
* #1487). The write goes through {@link appendNoFollow} (`no-follow-append.mjs`,
|
|
131
|
+
* shared with the gate-process ledger): an `O_NOFOLLOW|O_NONBLOCK` open plus an
|
|
132
|
+
* `fstat` of the opened descriptor, which also refuses a FIFO and a HARD-linked
|
|
133
|
+
* audit (`ln <victim> reaper-audit.jsonl` passes every symlink check, and each
|
|
134
|
+
* append then landed in the victim — reproduced 2026-10-02). That protects the
|
|
135
|
+
* append only: the scan child then READS the same path for the rate and opens
|
|
136
|
+
* it again to prune — both reads therefore go through `readTailWindow`'s
|
|
137
|
+
* `noFollow` open, the read-side twin. The prune's rewrite is immune to hard
|
|
138
|
+
* links — it renames a new inode over the name.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} repoRoot
|
|
141
|
+
* @param {object} record
|
|
142
|
+
* @returns {void}
|
|
143
|
+
*/
|
|
144
|
+
export function appendAuditRecord(repoRoot, record) {
|
|
145
|
+
const target = auditPath(repoRoot);
|
|
146
|
+
try {
|
|
147
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
148
|
+
appendNoFollow(target, `${JSON.stringify(record)}\n`);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
process.stderr.write(`orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Default audit READER — the other half of {@link appendAuditRecord}, and the
|
|
156
|
+
* data source {@link falseAlarmRate} needs (B5: "Dieselbe Datei ist die
|
|
157
|
+
* Datenquelle für `reaper.false-alarm-window`").
|
|
158
|
+
*
|
|
159
|
+
* Returns the parsed records of the newest `limit` non-empty lines, oldest
|
|
160
|
+
* first, EXTENDED BACKWARDS until they hold the newest `limit` rate decisions
|
|
161
|
+
* ({@link isRateDecision}) or reach the start of the file. `limit` is
|
|
162
|
+
* `reaper.false-alarm-window`, which counts DECISIONS; a line window let
|
|
163
|
+
* `report` records — re-written by every scan for every long-lived foreign or
|
|
164
|
+
* unattributed process — push the decisions out, so the rate was judged on the
|
|
165
|
+
* newest handful (measured on HEAD 3479e1bf: 30 scans of 4 reports + 1 decision,
|
|
166
|
+
* window 50 → n 10, rate 0 against a true 6/30; #1487). Widened, never
|
|
167
|
+
* narrowed: the newest `limit` lines are always the suffix of the result, so a
|
|
168
|
+
* caller that wanted lines still finds them there.
|
|
169
|
+
*
|
|
170
|
+
* Read from a tail window that grows ×4 only while it holds fewer than `limit`
|
|
171
|
+
* lines or decisions; the first line of a window that does not start at byte 0
|
|
172
|
+
* is a fragment and is dropped before anything is parsed. Named ceiling
|
|
173
|
+
* (BV-004): the window stops at {@link REAPER_AUDIT_MAX_BYTES} (1 MiB, ~3,700
|
|
174
|
+
* report records of 285 bytes) — the prune's own ceiling, enforced HERE rather
|
|
175
|
+
* than assumed from the prune, because the prune refuses a linked audit and
|
|
176
|
+
* runs only after this read (a 40 MB link target was read whole, 64 MB window,
|
|
177
|
+
* 293 MB RSS per scan — #1487). Decisions older than the newest 1 MiB are not
|
|
178
|
+
* read; revisit if that ceiling is raised.
|
|
179
|
+
*
|
|
180
|
+
* The open is `readTailWindow`'s `noFollow` form: a symlinked, FIFO or other
|
|
181
|
+
* non-regular audit reads as `[]` — refused like the writers refuse it — and a
|
|
182
|
+
* planted FIFO can no longer block the scan child in `open`.
|
|
183
|
+
*
|
|
184
|
+
* A malformed line is dropped from the rate's population rather than counted,
|
|
185
|
+
* because an unreadable record carries no decision to classify — and the rate
|
|
186
|
+
* reports its own `n`, so a shrinking population is visible in the number's
|
|
187
|
+
* denominator.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} repoRoot
|
|
190
|
+
* @param {number} [limit]
|
|
191
|
+
* @returns {object[]} parsed records in chronological order; `[]` on any failure
|
|
192
|
+
*/
|
|
193
|
+
export function readAuditRecords(repoRoot, limit = REAPER_DEFAULTS.falseAlarmWindow) {
|
|
194
|
+
const target = auditPath(repoRoot);
|
|
195
|
+
const want = Math.max(1, limit);
|
|
196
|
+
let span;
|
|
197
|
+
try {
|
|
198
|
+
let windowBytes = AUDIT_TAIL_START_BYTES;
|
|
199
|
+
for (;;) {
|
|
200
|
+
const { text, cut } = readTailWindow(target, windowBytes, { noFollow: true });
|
|
201
|
+
const all = text.split('\n');
|
|
202
|
+
if (cut) all.shift();
|
|
203
|
+
span = newestSpan(all.filter((l) => l.trim().length > 0), want);
|
|
204
|
+
if (!cut || span.complete || windowBytes >= REAPER_AUDIT_MAX_BYTES) break;
|
|
205
|
+
windowBytes = Math.min(windowBytes * 4, REAPER_AUDIT_MAX_BYTES);
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
return span.records;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Walk a window's complete lines newest-first until the walk has covered `want`
|
|
215
|
+
* lines AND `want` rate decisions — the population {@link readAuditRecords}
|
|
216
|
+
* documents. `complete: false` means the window ran out first.
|
|
217
|
+
*
|
|
218
|
+
* @param {string[]} lines Complete, non-empty lines in file order.
|
|
219
|
+
* @param {number} want
|
|
220
|
+
* @returns {{records: object[], complete: boolean}} records in file order
|
|
221
|
+
*/
|
|
222
|
+
function newestSpan(lines, want) {
|
|
223
|
+
const newestFirst = [];
|
|
224
|
+
let taken = 0;
|
|
225
|
+
let decisions = 0;
|
|
226
|
+
for (let i = lines.length - 1; i >= 0 && (taken < want || decisions < want); i -= 1) {
|
|
227
|
+
taken += 1;
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(lines[i]);
|
|
231
|
+
} catch {
|
|
232
|
+
continue; // an unreadable line carries no decision to classify
|
|
233
|
+
}
|
|
234
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
235
|
+
newestFirst.push(parsed);
|
|
236
|
+
if (isRateDecision(parsed)) decisions += 1;
|
|
237
|
+
}
|
|
238
|
+
return { records: newestFirst.reverse(), complete: taken >= want && decisions >= want };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Size-bounded prune of the audit: once the file passes `maxBytes`, rewrite it
|
|
243
|
+
* atomically with its newest `keepBytes`, cut at a line boundary. The same
|
|
244
|
+
* rewrite shape as `pruneGateProcessLedger` (`process-group.mjs`), the other
|
|
245
|
+
* ledger the scan's housekeeping prunes; the audit is pruned by SIZE rather than
|
|
246
|
+
* age because its population is a rolling window of decisions, not of days.
|
|
247
|
+
*
|
|
248
|
+
* Never throws; a failure — including a symlinked or non-regular audit, which
|
|
249
|
+
* is refused rather than written through — prints one WARN line and leaves the
|
|
250
|
+
* file as it was.
|
|
251
|
+
* Named ceiling (BV-004): an append that lands between the read and the write
|
|
252
|
+
* is lost — a window of one read + one write, once per ~0.5 MB of audit.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} repoRoot
|
|
255
|
+
* @param {object} [opts]
|
|
256
|
+
* @param {number} [opts.maxBytes]
|
|
257
|
+
* @param {number} [opts.keepBytes]
|
|
258
|
+
* @returns {number} bytes removed; 0 when nothing was pruned
|
|
259
|
+
*/
|
|
260
|
+
export function pruneReaperAudit(repoRoot, {
|
|
261
|
+
maxBytes = REAPER_AUDIT_MAX_BYTES,
|
|
262
|
+
keepBytes = REAPER_AUDIT_KEEP_BYTES,
|
|
263
|
+
} = {}) {
|
|
264
|
+
const target = auditPath(repoRoot);
|
|
265
|
+
let size;
|
|
266
|
+
try {
|
|
267
|
+
size = statSync(target).size;
|
|
268
|
+
} catch {
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
if (size <= maxBytes) return 0;
|
|
272
|
+
try {
|
|
273
|
+
// `noFollow`: `statSync` above follows a link, so without it the prune read
|
|
274
|
+
// a linked target's tail before `replaceRegularFile` refused the write.
|
|
275
|
+
const { text, cut } = readTailWindow(target, Math.min(keepBytes, maxBytes), { noFollow: true });
|
|
276
|
+
// A cut window starts mid-record; with no newline at all it is ONE record
|
|
277
|
+
// larger than the window, and keeping any part of it would keep a fragment.
|
|
278
|
+
const newline = text.indexOf('\n');
|
|
279
|
+
const kept = !cut ? text : (newline === -1 ? '' : text.slice(newline + 1));
|
|
280
|
+
replaceRegularFile(target, kept);
|
|
281
|
+
return size - Buffer.byteLength(kept, 'utf8');
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const why = err?.code === 'ELOOP' || err?.code === 'ERR_NOT_REGULAR_FILE'
|
|
284
|
+
? NOT_A_REGULAR_FILE
|
|
285
|
+
: (err?.message ?? String(err));
|
|
286
|
+
process.stderr.write(`orphan-reaper: could not prune ${REAPER_AUDIT_RELPATH}: ${why}\n`);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* False-alarm rate over the last `windowSize` audit decisions — the HR-101
|
|
293
|
+
* instrument-health check ("a signal may only warn if it is rare"; above ~10%
|
|
294
|
+
* the instrument is broken and gets re-aimed, never re-thresholded).
|
|
295
|
+
*
|
|
296
|
+
* POPULATION (the number's denominator, stated because a rate without one is a
|
|
297
|
+
* claim): audit records where the reaper judged a process a reapable orphan —
|
|
298
|
+
* `decision` in `kill` | `dry-run` | `reject` ({@link isRateDecision}, the same
|
|
299
|
+
* predicate {@link readAuditRecords} reads back far enough for). `report`
|
|
300
|
+
* records are excluded: reporting a foreign or non-read-only process is the
|
|
301
|
+
* correct outcome, not a firing of the kill signal.
|
|
302
|
+
*
|
|
303
|
+
* FALSE ALARM: a record where that judgement was refuted afterwards —
|
|
304
|
+
* `decision: 'reject'` (the identity re-check withdrew the candidate) or a kill
|
|
305
|
+
* whose `result.ok` is false (the signal did not take effect).
|
|
306
|
+
*
|
|
307
|
+
* Returns `{rate: null}` below 10 records: a rate over a handful of decisions
|
|
308
|
+
* says nothing, and `null` is distinguishable from a measured 0 (a missing
|
|
309
|
+
* measurement must never look like a zero).
|
|
310
|
+
*
|
|
311
|
+
* @param {object[]} auditRecords In chronological order; the LAST `windowSize` are used.
|
|
312
|
+
* @param {number} [windowSize]
|
|
313
|
+
* @returns {{rate: number|null, n: number}}
|
|
314
|
+
*/
|
|
315
|
+
export function falseAlarmRate(auditRecords, windowSize = REAPER_DEFAULTS.falseAlarmWindow) {
|
|
316
|
+
const all = Array.isArray(auditRecords) ? auditRecords : [];
|
|
317
|
+
const firings = all.filter(isRateDecision);
|
|
318
|
+
const window = windowSize > 0 ? firings.slice(-windowSize) : firings;
|
|
319
|
+
const n = window.length;
|
|
320
|
+
if (n < 10) return { rate: null, n };
|
|
321
|
+
const falseAlarms = window.filter((r) => r.decision === 'reject' || r?.result?.ok === false).length;
|
|
322
|
+
return { rate: falseAlarms / n, n };
|
|
323
|
+
}
|