session-orchestrator 5.4.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- 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.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- 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 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- 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 +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- 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-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- 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 +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- 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 +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- 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 +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -4,19 +4,13 @@
|
|
|
4
4
|
* reader exists for.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
appendFileSync,
|
|
9
|
-
lstatSync,
|
|
10
|
-
mkdirSync,
|
|
11
|
-
renameSync,
|
|
12
|
-
statSync,
|
|
13
|
-
unlinkSync,
|
|
14
|
-
writeFileSync,
|
|
15
|
-
} from 'node:fs';
|
|
7
|
+
import { mkdirSync, statSync } from 'node:fs';
|
|
16
8
|
import path from 'node:path';
|
|
17
9
|
|
|
10
|
+
import { replaceRegularFile } from '../process-group.mjs';
|
|
18
11
|
import { readTailWindow } from '../tail-window.mjs';
|
|
19
12
|
import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
|
|
13
|
+
import { NOT_A_REGULAR_FILE, appendNoFollow } from './no-follow-append.mjs';
|
|
20
14
|
|
|
21
15
|
/** Relative path of the JSONL kill audit (B5). Gitignored via `.gitignore:55`
|
|
22
16
|
* (`.orchestrator/metrics/*.jsonl`), verified with `git check-ignore --no-index -v`.
|
|
@@ -42,16 +36,34 @@ const ARGS_HEAD_CHARS = 80;
|
|
|
42
36
|
* a `kill` record with `args_head` and `result` serialises to 505 bytes, a
|
|
43
37
|
* `report` record to 285 (measured on {@link buildAuditRecord} output 2026-10-01;
|
|
44
38
|
* no real audit file existed on this host), so the kept 512 KiB hold ~1,000-1,800
|
|
45
|
-
*
|
|
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
|
|
46
42
|
* `reaper.false-alarm-window` is ever configured above ~1,000.
|
|
47
43
|
*/
|
|
48
44
|
const REAPER_AUDIT_MAX_BYTES = 1024 * 1024;
|
|
49
45
|
const REAPER_AUDIT_KEEP_BYTES = 512 * 1024;
|
|
50
46
|
|
|
51
47
|
/** First tail window {@link readAuditRecords} tries; it grows ×4 only while the
|
|
52
|
-
* window holds fewer complete lines than requested
|
|
48
|
+
* window holds fewer complete lines, or fewer rate decisions, than requested,
|
|
49
|
+
* and never past {@link REAPER_AUDIT_MAX_BYTES}. */
|
|
53
50
|
const AUDIT_TAIL_START_BYTES = 64 * 1024;
|
|
54
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
|
+
|
|
55
67
|
/** Absolute path of the kill audit for a repo. One constant per path.
|
|
56
68
|
* @param {string} repoRoot @returns {string} */
|
|
57
69
|
export function auditPath(repoRoot) {
|
|
@@ -112,6 +124,19 @@ export function buildAuditRecord(entry, decision, {
|
|
|
112
124
|
* rule). Best-effort — an audit write must never fail a scan — but a failure
|
|
113
125
|
* prints one WARN line rather than vanishing.
|
|
114
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
|
+
*
|
|
115
140
|
* @param {string} repoRoot
|
|
116
141
|
* @param {object} record
|
|
117
142
|
* @returns {void}
|
|
@@ -120,11 +145,9 @@ export function appendAuditRecord(repoRoot, record) {
|
|
|
120
145
|
const target = auditPath(repoRoot);
|
|
121
146
|
try {
|
|
122
147
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
123
|
-
|
|
148
|
+
appendNoFollow(target, `${JSON.stringify(record)}\n`);
|
|
124
149
|
} catch (err) {
|
|
125
|
-
process.stderr.write(
|
|
126
|
-
`orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`,
|
|
127
|
-
);
|
|
150
|
+
process.stderr.write(`orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`);
|
|
128
151
|
}
|
|
129
152
|
}
|
|
130
153
|
|
|
@@ -133,13 +156,30 @@ export function appendAuditRecord(repoRoot, record) {
|
|
|
133
156
|
* data source {@link falseAlarmRate} needs (B5: "Dieselbe Datei ist die
|
|
134
157
|
* Datenquelle für `reaper.false-alarm-window`").
|
|
135
158
|
*
|
|
136
|
-
* Returns the parsed records
|
|
137
|
-
* first
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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`.
|
|
143
183
|
*
|
|
144
184
|
* A malformed line is dropped from the rate's population rather than counted,
|
|
145
185
|
* because an unreadable record carries no decision to classify — and the rate
|
|
@@ -153,61 +193,49 @@ export function appendAuditRecord(repoRoot, record) {
|
|
|
153
193
|
export function readAuditRecords(repoRoot, limit = REAPER_DEFAULTS.falseAlarmWindow) {
|
|
154
194
|
const target = auditPath(repoRoot);
|
|
155
195
|
const want = Math.max(1, limit);
|
|
156
|
-
let
|
|
196
|
+
let span;
|
|
157
197
|
try {
|
|
158
198
|
let windowBytes = AUDIT_TAIL_START_BYTES;
|
|
159
199
|
for (;;) {
|
|
160
|
-
const { text, cut } = readTailWindow(target, windowBytes);
|
|
200
|
+
const { text, cut } = readTailWindow(target, windowBytes, { noFollow: true });
|
|
161
201
|
const all = text.split('\n');
|
|
162
202
|
if (cut) all.shift();
|
|
163
|
-
|
|
164
|
-
if (!cut ||
|
|
165
|
-
windowBytes
|
|
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);
|
|
166
206
|
}
|
|
167
207
|
} catch {
|
|
168
208
|
return [];
|
|
169
209
|
}
|
|
170
|
-
|
|
171
|
-
for (const line of lines.slice(-want)) {
|
|
172
|
-
try {
|
|
173
|
-
const parsed = JSON.parse(line);
|
|
174
|
-
if (parsed && typeof parsed === 'object') records.push(parsed);
|
|
175
|
-
} catch {
|
|
176
|
-
/* an unreadable line carries no decision to classify */
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return records;
|
|
210
|
+
return span.records;
|
|
180
211
|
}
|
|
181
212
|
|
|
182
213
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* killed between truncate and write left the "why was this killed" record empty
|
|
187
|
-
* — and would write THROUGH a symlink, cutting e.g. a linked `events.jsonl` to
|
|
188
|
-
* 512 KiB (CWE-59, reproduced 2026-10-01). `rename` replaces the link itself,
|
|
189
|
-
* and `wx` refuses a pre-planted file or link at the tmp name. The same few
|
|
190
|
-
* lines as `process-group.mjs`'s `replaceRegularFile`, kept local because that
|
|
191
|
-
* module is not this one's to depend on for a file write.
|
|
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.
|
|
192
217
|
*
|
|
193
|
-
* @param {string}
|
|
194
|
-
* @param {
|
|
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
|
|
195
221
|
*/
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
try { unlinkSync(tmp); } catch { /* nothing was created */ }
|
|
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
|
|
208
233
|
}
|
|
209
|
-
|
|
234
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
235
|
+
newestFirst.push(parsed);
|
|
236
|
+
if (isRateDecision(parsed)) decisions += 1;
|
|
210
237
|
}
|
|
238
|
+
return { records: newestFirst.reverse(), complete: taken >= want && decisions >= want };
|
|
211
239
|
}
|
|
212
240
|
|
|
213
241
|
/**
|
|
@@ -242,7 +270,9 @@ export function pruneReaperAudit(repoRoot, {
|
|
|
242
270
|
}
|
|
243
271
|
if (size <= maxBytes) return 0;
|
|
244
272
|
try {
|
|
245
|
-
|
|
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 });
|
|
246
276
|
// A cut window starts mid-record; with no newline at all it is ONE record
|
|
247
277
|
// larger than the window, and keeping any part of it would keep a fragment.
|
|
248
278
|
const newline = text.indexOf('\n');
|
|
@@ -250,9 +280,10 @@ export function pruneReaperAudit(repoRoot, {
|
|
|
250
280
|
replaceRegularFile(target, kept);
|
|
251
281
|
return size - Buffer.byteLength(kept, 'utf8');
|
|
252
282
|
} catch (err) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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`);
|
|
256
287
|
return 0;
|
|
257
288
|
}
|
|
258
289
|
}
|
|
@@ -264,9 +295,10 @@ export function pruneReaperAudit(repoRoot, {
|
|
|
264
295
|
*
|
|
265
296
|
* POPULATION (the number's denominator, stated because a rate without one is a
|
|
266
297
|
* claim): audit records where the reaper judged a process a reapable orphan —
|
|
267
|
-
* `decision` in `kill` | `dry-run` | `reject
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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.
|
|
270
302
|
*
|
|
271
303
|
* FALSE ALARM: a record where that judgement was refuted afterwards —
|
|
272
304
|
* `decision: 'reject'` (the identity re-check withdrew the candidate) or a kill
|
|
@@ -282,7 +314,7 @@ export function pruneReaperAudit(repoRoot, {
|
|
|
282
314
|
*/
|
|
283
315
|
export function falseAlarmRate(auditRecords, windowSize = REAPER_DEFAULTS.falseAlarmWindow) {
|
|
284
316
|
const all = Array.isArray(auditRecords) ? auditRecords : [];
|
|
285
|
-
const firings = all.filter(
|
|
317
|
+
const firings = all.filter(isRateDecision);
|
|
286
318
|
const window = windowSize > 0 ? firings.slice(-windowSize) : firings;
|
|
287
319
|
const n = window.length;
|
|
288
320
|
if (n < 10) return { rate: null, n };
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* orphan-reaper/scan-throttle.mjs — the B4 throttle the
|
|
2
|
+
* orphan-reaper/scan-throttle.mjs — the B4 throttle the trigger calls.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `hooks/post-tool-batch-wave-signal.mjs`
|
|
6
|
-
*
|
|
7
|
-
* `ps` or kill-ladder code
|
|
4
|
+
* Together with `trigger.mjs` this is the reaper's only hot-path surface:
|
|
5
|
+
* `hooks/on-stop.mjs` and `hooks/post-tool-batch-wave-signal.mjs` reach THIS
|
|
6
|
+
* module (lazily, via `trigger.mjs`), not the scan, so a tool batch pays for one
|
|
7
|
+
* `stat` and a few tiny modules — never for the ledger, `ps` or kill-ladder code
|
|
8
|
+
* the detached scan child loads.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import {
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
13
|
import path from 'node:path';
|
|
12
14
|
|
|
13
15
|
import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
|
|
@@ -24,40 +26,78 @@ export const SCAN_MARKER_RELPATH = '.orchestrator/tmp/reaper-last-scan';
|
|
|
24
26
|
* file. Writing the marker is the CALLER's job ({@link touchScanMarker}), so
|
|
25
27
|
* this stays a read-only predicate a hook can call cheaply.
|
|
26
28
|
*
|
|
29
|
+
* `lstat`, never `stat`: a symlinked (or otherwise non-regular) marker is not
|
|
30
|
+
* one {@link touchScanMarker} wrote, and `stat` read the TARGET's mtime — so a
|
|
31
|
+
* link to any often-written file (`events.jsonl`) throttled every scan for good
|
|
32
|
+
* (#1487). Such a marker counts as absent, i.e. "scan" — and that scan's stamp
|
|
33
|
+
* replaces a link with a regular marker, so the next fire throttles again
|
|
34
|
+
* ({@link touchScanMarker}). Counting it as absent is the fail-safe
|
|
35
|
+
* direction for this throttle: too many scans cost one detached child per hook
|
|
36
|
+
* fire — visible in the process table, and in the `scan_completed` rate
|
|
37
|
+
* whenever a scan finds anything — in an opt-in state (`reaper.enabled`
|
|
38
|
+
* defaults false); too few silently disable the reaper, whose
|
|
39
|
+
* orphans were measured at 86-588 % CPU and up to 8 GB RSS (HR-107), and a
|
|
40
|
+
* disabled reaper is a state nothing can falsify (HR-105).
|
|
41
|
+
*
|
|
27
42
|
* @param {string} markerPath Absolute path — build it with {@link scanMarkerPath}.
|
|
28
43
|
* @param {number} nowMs
|
|
29
44
|
* @param {number} [minIntervalSeconds]
|
|
30
45
|
* @param {object} [opts]
|
|
31
|
-
* @param {(p: string) => {mtimeMs: number}} [opts.statFn]
|
|
46
|
+
* @param {(p: string) => {mtimeMs: number, isFile?: () => boolean}} [opts.statFn]
|
|
47
|
+
* Defaults to `lstatSync`; an injected stat without `isFile` skips the
|
|
48
|
+
* regular-file check (test seam).
|
|
32
49
|
* @returns {boolean}
|
|
33
50
|
*/
|
|
34
51
|
export function shouldScanNow(markerPath, nowMs, minIntervalSeconds = REAPER_DEFAULTS.minScanIntervalSeconds, {
|
|
35
|
-
statFn =
|
|
52
|
+
statFn = lstatSync,
|
|
36
53
|
} = {}) {
|
|
37
54
|
if (typeof markerPath !== 'string' || markerPath.length === 0) return false;
|
|
38
|
-
let
|
|
55
|
+
let stats;
|
|
39
56
|
try {
|
|
40
|
-
|
|
57
|
+
stats = statFn(markerPath);
|
|
41
58
|
} catch {
|
|
42
59
|
return true; // no marker yet → first scan
|
|
43
60
|
}
|
|
61
|
+
if (typeof stats?.isFile === 'function' && !stats.isFile()) return true;
|
|
62
|
+
const mtimeMs = stats?.mtimeMs;
|
|
44
63
|
if (typeof mtimeMs !== 'number' || Number.isNaN(mtimeMs)) return true;
|
|
64
|
+
// A marker stamped in the future (`touch -t`, a clock stepped backwards) made
|
|
65
|
+
// the age negative, so every fire read "too soon" — and the marker is only
|
|
66
|
+
// re-stamped AFTER a scan, so the reaper never scanned again (#1487). Fail
|
|
67
|
+
// toward a scan, the same direction as every other unreadable marker here.
|
|
68
|
+
if (mtimeMs > nowMs) return true;
|
|
45
69
|
return (nowMs - mtimeMs) >= minIntervalSeconds * 1000;
|
|
46
70
|
}
|
|
47
71
|
|
|
48
72
|
/**
|
|
49
73
|
* Stamp the throttle marker. Best-effort and never throws — a marker that could
|
|
50
74
|
* not be written means the next scan runs, which is the safe direction for a
|
|
51
|
-
* read-only probe.
|
|
75
|
+
* read-only probe (the reasoning is on {@link shouldScanNow}). Callers must not
|
|
76
|
+
* read a `false` as a stamped throttle: `maybeTriggerOrphanScan`
|
|
77
|
+
* (`trigger.mjs`) returns it as `reason: 'spawned-unthrottled'`, and
|
|
78
|
+
* `hooks/on-stop.mjs` records that reason as `reaper_trigger` on its
|
|
79
|
+
* Stop/SubagentStop records — so an unwritable marker is countable in
|
|
80
|
+
* events.jsonl, not only visible as one detached scan child per hook fire.
|
|
81
|
+
*
|
|
82
|
+
* Written to a sibling tmp file, then renamed over the marker path. A rename
|
|
83
|
+
* replaces the directory ENTRY and never follows a symlink sitting there, so a
|
|
84
|
+
* planted or accidental link is healed by the next stamp — not written through
|
|
85
|
+
* (CWE-59: `writeFileSync` on the marker replaced the link's target), and not
|
|
86
|
+
* refused either: refusing it returned `false` on every fire, which switched the
|
|
87
|
+
* throttle off for good (#1489 Pkt 9). With no lstat-then-write step there is no
|
|
88
|
+
* check-to-use window left. The tmp name carries pid + random bytes and is
|
|
89
|
+
* opened `wx` (O_EXCL), so anything already at that name fails the stamp
|
|
90
|
+
* instead of being written through; a tmp this call created is removed again
|
|
91
|
+
* when the rename fails, or every failed fire would leave one behind.
|
|
52
92
|
*
|
|
53
|
-
* A marker path
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* Named ceiling (BV-004):
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* carries data.
|
|
93
|
+
* A DIRECTORY at the marker path is the one shape that stays unhealed: a rename
|
|
94
|
+
* cannot replace it (EISDIR), and removing it would delete content this module
|
|
95
|
+
* never wrote. It stays `false`. Silent, like every other failure here — this
|
|
96
|
+
* runs inside a 50 ms hook budget.
|
|
97
|
+
* Named ceiling (BV-004): the PARENT path is resolved normally, so a symlinked
|
|
98
|
+
* `.orchestrator/tmp/` puts the marker into the link's target directory, where
|
|
99
|
+
* it can replace only an entry of its own name. Acceptable for a gitignored
|
|
100
|
+
* throttle stamp; revisit if the marker ever carries data.
|
|
61
101
|
*
|
|
62
102
|
* @param {string} markerPath
|
|
63
103
|
* @param {object} [opts]
|
|
@@ -66,25 +106,37 @@ export function shouldScanNow(markerPath, nowMs, minIntervalSeconds = REAPER_DEF
|
|
|
66
106
|
*/
|
|
67
107
|
export function touchScanMarker(markerPath, { writeFn } = {}) {
|
|
68
108
|
try {
|
|
109
|
+
const stamp = `${new Date().toISOString()}\n`;
|
|
69
110
|
if (writeFn) {
|
|
70
|
-
writeFn(markerPath,
|
|
111
|
+
writeFn(markerPath, stamp);
|
|
71
112
|
return true;
|
|
72
113
|
}
|
|
73
|
-
|
|
114
|
+
mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
115
|
+
const tmp = `${markerPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
writeFileSync(tmp, stamp, { encoding: 'utf8', flag: 'wx' });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// EEXIST: the `wx` open was refused, so the entry at `tmp` is not ours.
|
|
120
|
+
if (err?.code !== 'EEXIST') removeOwnTmp(tmp);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
74
123
|
try {
|
|
75
|
-
|
|
124
|
+
renameSync(tmp, markerPath);
|
|
125
|
+
return true;
|
|
76
126
|
} catch {
|
|
77
|
-
|
|
127
|
+
removeOwnTmp(tmp);
|
|
128
|
+
return false;
|
|
78
129
|
}
|
|
79
|
-
if (existing && !existing.isFile()) return false;
|
|
80
|
-
mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
81
|
-
writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
|
|
82
|
-
return true;
|
|
83
130
|
} catch {
|
|
84
131
|
return false;
|
|
85
132
|
}
|
|
86
133
|
}
|
|
87
134
|
|
|
135
|
+
/** Best-effort removal of a tmp file {@link touchScanMarker} created. @param {string} tmp */
|
|
136
|
+
function removeOwnTmp(tmp) {
|
|
137
|
+
try { unlinkSync(tmp); } catch { /* never created, or already gone */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
88
140
|
/** Absolute path of the throttle marker for a repo. One constant per path.
|
|
89
141
|
* @param {string} repoRoot @returns {string} */
|
|
90
142
|
export function scanMarkerPath(repoRoot) {
|
|
@@ -243,6 +243,10 @@ export async function runOrphanScan({
|
|
|
243
243
|
} catch {
|
|
244
244
|
return empty('ledger-unreadable', malformed);
|
|
245
245
|
}
|
|
246
|
+
// The default reader never throws: a refused or unreadable ledger comes back
|
|
247
|
+
// as no records with `state: 'unreadable'` (#1498). Read as "no register
|
|
248
|
+
// hits" it ran a clean-looking scan that measured nothing.
|
|
249
|
+
if (ledger?.state === 'unreadable') return empty('ledger-unreadable', malformed);
|
|
246
250
|
|
|
247
251
|
let ownSessionId = null;
|
|
248
252
|
try {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/trigger.mjs — the B4 trigger both hooks call (#1432 B4).
|
|
3
|
+
*
|
|
4
|
+
* `hooks/on-stop.mjs` (Stop + SubagentStop) and
|
|
5
|
+
* `hooks/post-tool-batch-wave-signal.mjs` (PostToolBatch) are the two trigger
|
|
6
|
+
* points the PRD declares. Until 2026-10-02 each carried its own verbatim copy
|
|
7
|
+
* of this code (138 identical lines, one comment apart, measured by diffing the
|
|
8
|
+
* blocks); the copies are now this one module (#1489).
|
|
9
|
+
*
|
|
10
|
+
* Its own file rather than part of `scan-throttle.mjs`, for two closures:
|
|
11
|
+
* - `scan-throttle.mjs` is re-exported by the detached scan CLI
|
|
12
|
+
* (`scripts/lib/orphan-reaper.mjs`); putting the trigger there would load the
|
|
13
|
+
* config parser and `platform.mjs` into every scan child for nothing.
|
|
14
|
+
* - The throttle stays a LAZY import below, so a host with the reaper disabled
|
|
15
|
+
* (the default) pays for one config read and no throttle module at all.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { performance } from 'node:perf_hooks';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
import { _parseReaper } from '../config/reaper.mjs';
|
|
25
|
+
import { getProjectDir } from '../platform.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filesystem path of the scan CLI, spawned as a PLAIN argv call.
|
|
29
|
+
*
|
|
30
|
+
* It used to be a `file://` URL handed to `node --input-type=module -e
|
|
31
|
+
* <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
|
|
32
|
+
* has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
|
|
33
|
+
* contract lives in that module instead of as source text duplicated here —
|
|
34
|
+
* and nothing this trigger builds is a program any more: every variable part is
|
|
35
|
+
* an argv value, which cannot become code whatever the checkout path contains.
|
|
36
|
+
*
|
|
37
|
+
* `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
|
|
38
|
+
* percent-encoded path for any checkout directory containing a space.
|
|
39
|
+
*/
|
|
40
|
+
const ORPHAN_REAPER_SCRIPT = fileURLToPath(new URL('../orphan-reaper.mjs', import.meta.url));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the `reaper:` block from the project's Session Config host file.
|
|
44
|
+
* Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
|
|
45
|
+
* path must not import the full config orchestrator. A missing or unreadable
|
|
46
|
+
* file yields the parser defaults, i.e. DISABLED.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} projectDir
|
|
49
|
+
* @returns {ReturnType<typeof _parseReaper>}
|
|
50
|
+
*/
|
|
51
|
+
function loadReaperConfig(projectDir) {
|
|
52
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
53
|
+
try {
|
|
54
|
+
return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
// missing or unreadable — try the next candidate
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return _parseReaper('');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
|
|
64
|
+
*
|
|
65
|
+
* The hook itself does exactly two pieces of I/O — one config read and one
|
|
66
|
+
* `stat` of the throttle marker — and then hands the work to a DETACHED,
|
|
67
|
+
* unref'd child. The scan is never run inline: one `ps` round-trip out of Node
|
|
68
|
+
* was measured at ~47 ms over 287 KB of output, which alone would blow the
|
|
69
|
+
* 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
|
|
70
|
+
*
|
|
71
|
+
* The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
|
|
72
|
+
* the throttle window — otherwise a broken spawn would be retried on every
|
|
73
|
+
* single tool batch.
|
|
74
|
+
*
|
|
75
|
+
* `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
|
|
76
|
+
* with `performance.now()` and reported as ONE stderr WARN line when exceeded.
|
|
77
|
+
* A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
|
|
78
|
+
* per-fire telemetry record is exactly the always-on signal
|
|
79
|
+
* `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
|
|
80
|
+
* measurement covers preparation only — the scan itself runs detached, which is
|
|
81
|
+
* the whole reason the budget can be held.
|
|
82
|
+
*
|
|
83
|
+
* Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
|
|
84
|
+
*
|
|
85
|
+
* @param {object} [opts]
|
|
86
|
+
* @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
|
|
87
|
+
* @param {number} [opts.now] Injected clock (ms).
|
|
88
|
+
* @param {Function} [opts.spawnFn] Injected `spawn` (tests).
|
|
89
|
+
* @param {Function} [opts.statFn] Injected marker `lstatSync` (tests).
|
|
90
|
+
* @param {Function} [opts.writeFn] Injected marker writer (tests).
|
|
91
|
+
* @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
|
|
92
|
+
* budget (tests); defaults to `performance.now`.
|
|
93
|
+
* @returns {Promise<{spawned: boolean, reason: string}>} `reason` is one of
|
|
94
|
+
* `'disabled' | 'throttled' | 'spawned' | 'spawned-unthrottled' | 'error'`.
|
|
95
|
+
* `'spawned-unthrottled'` means the scan ran but the marker could not be
|
|
96
|
+
* stamped — the next fire will scan again, and the result must not read like
|
|
97
|
+
* a throttled spawn. `hooks/on-stop.mjs` records `reason` as `reaper_trigger`
|
|
98
|
+
* on its per-turn and per-agent records (HR-105: a signal nothing records is
|
|
99
|
+
* unfalsifiable); the PostToolBatch hook does not, because the marker is one
|
|
100
|
+
* file per repo — a defeated marker shows on the very next Stop record, and a
|
|
101
|
+
* per-batch record is the HR-101 shape the WARN above already refuses.
|
|
102
|
+
*/
|
|
103
|
+
export async function maybeTriggerOrphanScan({
|
|
104
|
+
projectDir,
|
|
105
|
+
now,
|
|
106
|
+
spawnFn = spawn,
|
|
107
|
+
statFn,
|
|
108
|
+
writeFn,
|
|
109
|
+
clockFn = () => performance.now(),
|
|
110
|
+
} = {}) {
|
|
111
|
+
const startedAt = clockFn();
|
|
112
|
+
/** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
|
|
113
|
+
const checkLatency = (budgetMs) => {
|
|
114
|
+
const elapsed = clockFn() - startedAt;
|
|
115
|
+
if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
|
|
116
|
+
process.stderr.write(
|
|
117
|
+
`orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
|
|
118
|
+
+ `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
let root;
|
|
123
|
+
try {
|
|
124
|
+
root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
|
|
125
|
+
} catch {
|
|
126
|
+
// No project dir, no config to read (#1498): `process.cwd()` throws ENOENT
|
|
127
|
+
// when the hook starts in a deleted directory with no `*_PROJECT_DIR` set.
|
|
128
|
+
// That is an unreadable config — the parser defaults, DISABLED — and not a
|
|
129
|
+
// failure of an armed reaper, so it must not surface as `error`: on-stop
|
|
130
|
+
// stamps `reaper_trigger` only for a reaper that is enabled.
|
|
131
|
+
return { spawned: false, reason: 'disabled' };
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const cfg = loadReaperConfig(root);
|
|
135
|
+
// Cheapest gate first: disabled means no stat, no spawn, no module load.
|
|
136
|
+
if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
|
|
137
|
+
|
|
138
|
+
// The throttle module only — the scan's ledger, `ps` and kill-ladder code
|
|
139
|
+
// load in the detached child, never on this hook path (#1437).
|
|
140
|
+
const reaper = await import('./scan-throttle.mjs');
|
|
141
|
+
const markerPath = reaper.scanMarkerPath(root);
|
|
142
|
+
const nowMs = typeof now === 'number' ? now : Date.now();
|
|
143
|
+
|
|
144
|
+
if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
|
|
145
|
+
checkLatency(cfg['max-hook-latency-ms']);
|
|
146
|
+
return { spawned: false, reason: 'throttled' };
|
|
147
|
+
}
|
|
148
|
+
// An unwritable marker does not cancel the scan — the throttle fails toward
|
|
149
|
+
// scanning (see shouldScanNow) — but the result says the throttle is off.
|
|
150
|
+
// No output: this runs on every hook fire (each tool batch, each Stop) in
|
|
151
|
+
// every session on the host.
|
|
152
|
+
const stamped = reaper.touchScanMarker(markerPath, { writeFn });
|
|
153
|
+
|
|
154
|
+
const child = spawnFn(
|
|
155
|
+
process.execPath,
|
|
156
|
+
[
|
|
157
|
+
ORPHAN_REAPER_SCRIPT,
|
|
158
|
+
'--repo-root', root,
|
|
159
|
+
'--mode', cfg.mode,
|
|
160
|
+
'--min-age-seconds', String(cfg['min-age-seconds']),
|
|
161
|
+
'--kill-grace-ms', String(cfg['kill-grace-ms']),
|
|
162
|
+
'--verify-wait-ms', String(cfg['verify-wait-ms']),
|
|
163
|
+
'--false-alarm-window', String(cfg['false-alarm-window']),
|
|
164
|
+
],
|
|
165
|
+
{ detached: true, stdio: 'ignore' },
|
|
166
|
+
);
|
|
167
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
168
|
+
checkLatency(cfg['max-hook-latency-ms']);
|
|
169
|
+
return { spawned: true, reason: stamped ? 'spawned' : 'spawned-unthrottled' };
|
|
170
|
+
} catch {
|
|
171
|
+
return { spawned: false, reason: 'error' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
* trigger hooks spawn. The implementation lives in `orphan-reaper/`:
|
|
26
26
|
*
|
|
27
27
|
* defaults.mjs `REAPER_DEFAULTS` + the repo-path join (node:path only)
|
|
28
|
-
*
|
|
28
|
+
* trigger.mjs `maybeTriggerOrphanScan` — the ONE trigger both hooks
|
|
29
|
+
* import; deliberately NOT re-exported here, so this CLI's
|
|
30
|
+
* closure stays free of the config parser and platform.mjs
|
|
31
|
+
* scan-throttle.mjs B4 throttle marker — loaded lazily by trigger.mjs
|
|
32
|
+
* no-follow-append.mjs the O_NOFOLLOW|O_NONBLOCK append both ledgers share
|
|
29
33
|
* ps-snapshot.mjs the binding `ps` format, its parser and runners
|
|
30
34
|
* reaper-decide.mjs the PURE candidate decision and its identity checks
|
|
31
35
|
* reaper-audit.mjs B5 audit record, writer, bounded reader, pruner, HR-101 rate
|