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,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/scan.mjs — one orphan scan end to end: `ps` → decide → (unless
|
|
3
|
+
* `dryRun`) re-verify identity before every signal → kill the group → audit.
|
|
4
|
+
*
|
|
5
|
+
* The IMPURE half of the reaper. Everything impure reaches the pure decision
|
|
6
|
+
* (`reaper-decide.mjs`) through `deps` ({@link resolveDeps}), the
|
|
7
|
+
* `lock-reaper.mjs` split of `evaluateRepo()` / `archiveLock()`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
killProcessGroup,
|
|
12
|
+
pruneGateProcessLedger,
|
|
13
|
+
readGateProcessLedger,
|
|
14
|
+
verifyProcessIdentity,
|
|
15
|
+
} from '../process-group.mjs';
|
|
16
|
+
import { REAPER_DEFAULTS } from './defaults.mjs';
|
|
17
|
+
import { parsePsSnapshot, parsePsSnapshotDetailed, runPs, runPsPid } from './ps-snapshot.mjs';
|
|
18
|
+
import {
|
|
19
|
+
FALSE_ALARM_SUSPECT_RATE,
|
|
20
|
+
appendAuditRecord,
|
|
21
|
+
buildAuditRecord,
|
|
22
|
+
falseAlarmRate,
|
|
23
|
+
pruneReaperAudit,
|
|
24
|
+
readAuditRecords,
|
|
25
|
+
} from './reaper-audit.mjs';
|
|
26
|
+
import { decideReapCandidates, verifyGroupMemberIdentity } from './reaper-decide.mjs';
|
|
27
|
+
|
|
28
|
+
/** Name const for the one event this module emits. @see docs/events-schema.md */
|
|
29
|
+
export const REAPER_SCAN_EVENT = 'orchestrator.reaper.scan_completed';
|
|
30
|
+
|
|
31
|
+
/** Default own-session-id reader. Lazily imported so the static import closure
|
|
32
|
+
* of this module stays small — it is destined for a hot-path hook (#1432).
|
|
33
|
+
* @param {string} repoRoot @returns {Promise<string|null>} */
|
|
34
|
+
async function defaultReadOwnSessionId(repoRoot) {
|
|
35
|
+
try {
|
|
36
|
+
const mod = await import('../session-identity/own-session.mjs');
|
|
37
|
+
const ids = mod.readOwnSessionIds(repoRoot);
|
|
38
|
+
for (const id of ids) return id;
|
|
39
|
+
return null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default live-peer probe. Lazily imported for the same reason as
|
|
47
|
+
* {@link defaultReadOwnSessionId}.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` — never `[]` — when the registry cannot be read. The two mean
|
|
50
|
+
* opposite things to `decideReapCandidates`: `[]` is a MEASUREMENT ("no
|
|
51
|
+
* peer is alive", so a foreign session's leftovers are reapable), `null` is the
|
|
52
|
+
* absence of one ("report it, do not touch it").
|
|
53
|
+
*
|
|
54
|
+
* @param {string|null} sessionId
|
|
55
|
+
* @returns {Promise<string[]|null>}
|
|
56
|
+
*/
|
|
57
|
+
async function defaultDetectPeers(sessionId) {
|
|
58
|
+
try {
|
|
59
|
+
const mod = await import('../session-registry.mjs');
|
|
60
|
+
const peers = await mod.detectPeers({ sessionId: sessionId ?? undefined });
|
|
61
|
+
if (!Array.isArray(peers)) return null;
|
|
62
|
+
return peers.map((p) => p?.session_id).filter((v) => typeof v === 'string' && v.length > 0);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Default event emitter. Lazily imported for the same reason as
|
|
70
|
+
* {@link defaultReadOwnSessionId}: `events.mjs` pulls in the schema validator,
|
|
71
|
+
* the attribution chain and the webhook client, and this module is destined for
|
|
72
|
+
* a hot-path hook (#1432) whose static import closure is measured.
|
|
73
|
+
*
|
|
74
|
+
* Never throws and never rejects — telemetry that can fail a scan would fail the
|
|
75
|
+
* hook the scan runs in.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} type
|
|
78
|
+
* @param {object} payload
|
|
79
|
+
* @param {{repoRoot?: string}} [opts]
|
|
80
|
+
* @returns {Promise<boolean>} whether the record was written
|
|
81
|
+
*/
|
|
82
|
+
async function defaultEmitEvent(type, payload, opts = {}) {
|
|
83
|
+
try {
|
|
84
|
+
const mod = await import('../events.mjs');
|
|
85
|
+
await mod.emitEvent(type, payload, opts);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {number} ms @returns {Promise<void>} */
|
|
93
|
+
function defaultSleep(ms) {
|
|
94
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the adapter, defaulting every seam to the real implementation — the
|
|
99
|
+
* `resolveDeps()` pattern from `lock-reaper.mjs:77-100`.
|
|
100
|
+
*
|
|
101
|
+
* `killProcessGroup` HAS a real default, and that is safe only because
|
|
102
|
+
* {@link runOrphanScan} defaults `dryRun: true`. Tests ALWAYS inject it
|
|
103
|
+
* (`.claude/rules/testing.md`: never a real process as a kill target).
|
|
104
|
+
*
|
|
105
|
+
* @param {object} [deps]
|
|
106
|
+
* @returns {object}
|
|
107
|
+
*/
|
|
108
|
+
export function resolveDeps(deps = {}) {
|
|
109
|
+
const d = deps ?? {};
|
|
110
|
+
return {
|
|
111
|
+
runPs: d.runPs ?? runPs,
|
|
112
|
+
runPsPid: d.runPsPid ?? runPsPid,
|
|
113
|
+
readLedger: d.readLedger ?? readGateProcessLedger,
|
|
114
|
+
verifyIdentity: d.verifyIdentity ?? verifyProcessIdentity,
|
|
115
|
+
killProcessGroup: d.killProcessGroup ?? killProcessGroup,
|
|
116
|
+
detectPeers: d.detectPeers ?? defaultDetectPeers,
|
|
117
|
+
readOwnSessionId: d.readOwnSessionId ?? defaultReadOwnSessionId,
|
|
118
|
+
appendAudit: d.appendAudit ?? appendAuditRecord,
|
|
119
|
+
readAuditRecords: d.readAuditRecords ?? readAuditRecords,
|
|
120
|
+
emitEvent: d.emitEvent ?? defaultEmitEvent,
|
|
121
|
+
now: d.now ?? Date.now,
|
|
122
|
+
sleep: d.sleep ?? defaultSleep,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Map an identity verdict's reason to the audit's `reason` vocabulary.
|
|
128
|
+
*
|
|
129
|
+
* Three outcomes, never two: `gone` (measured, the process is not there),
|
|
130
|
+
* `signature-mismatch` / `identity-mismatch` (measured, it is a DIFFERENT
|
|
131
|
+
* process) and `unmeasured` (the probe itself could not answer). The third is
|
|
132
|
+
* the one a two-state mapping loses, and losing it is how an absent measurement
|
|
133
|
+
* starts reading like a clean verdict.
|
|
134
|
+
*
|
|
135
|
+
* @param {string|null|undefined} identityReason
|
|
136
|
+
* @returns {'gone'|'signature-mismatch'|'identity-mismatch'|'unmeasured'}
|
|
137
|
+
*/
|
|
138
|
+
function rejectReasonFor(identityReason) {
|
|
139
|
+
if (identityReason === 'gone') return 'gone';
|
|
140
|
+
if (identityReason === 'unmeasured') return 'unmeasured';
|
|
141
|
+
if (identityReason === 'signature-mismatch') return 'signature-mismatch';
|
|
142
|
+
return 'identity-mismatch';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run one orphan scan: `ps` → decide → (unless `dryRun`) re-verify identity
|
|
147
|
+
* against a FRESH snapshot → kill the group → audit.
|
|
148
|
+
*
|
|
149
|
+
* NO-THROW contract: every failure degrades into `skipped: '<reason>'` or a
|
|
150
|
+
* `rejected` entry, because this runs from a hook and must never fail it
|
|
151
|
+
* (PRD FA4: "bei jedem Fehler degradiert er lautlos").
|
|
152
|
+
*
|
|
153
|
+
* The pre-signal re-check is the TOCTOU defence and the whole point of FA3: a
|
|
154
|
+
* PID recycled between decision and signal must NOT be signalled. It runs a
|
|
155
|
+
* TARGETED `ps` (`psPidArgs`, `ps-snapshot.mjs`) through `deps.runPsPid` and feeds the row to
|
|
156
|
+
* {@link verifyProcessIdentity} — immediately before SIGTERM **and** immediately
|
|
157
|
+
* before SIGKILL, via `killProcessGroup`'s `beforeSignal` gate, because the
|
|
158
|
+
* ladder's 10 s grace is itself a recycling window. On any mismatch no further
|
|
159
|
+
* signal is sent and the candidate is recorded as `decision: 'reject'` with the
|
|
160
|
+
* reason that withdrew it. Such a candidate stays in `candidates` (that WAS the
|
|
161
|
+
* decision) and additionally appears in `rejected` (that is the withdrawal) —
|
|
162
|
+
* the two arrays answer different questions and collapsing them would lose the
|
|
163
|
+
* TOCTOU event.
|
|
164
|
+
*
|
|
165
|
+
* Success is proven the same way, never from an exit code: after the ladder the
|
|
166
|
+
* scan waits `verifyWaitMs` and re-measures (B6). `ok` is true only for a
|
|
167
|
+
* process that is GONE; one that outlived SIGKILL is booked
|
|
168
|
+
* `survivedSigkill: true`, and one the probe could not measure is `verified:
|
|
169
|
+
* 'unmeasured'` — neither is ever a success.
|
|
170
|
+
*
|
|
171
|
+
* `dryRun` defaults to TRUE: arming happens at the CALL SITE (#1432), so a
|
|
172
|
+
* caller that forgets the flag scans and reports instead of killing.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} opts
|
|
175
|
+
* @param {string} opts.repoRoot
|
|
176
|
+
* @param {number} [opts.now]
|
|
177
|
+
* @param {boolean} [opts.dryRun]
|
|
178
|
+
* @param {object} [opts.deps]
|
|
179
|
+
* @param {number} [opts.minAgeSeconds]
|
|
180
|
+
* @param {number} [opts.killGraceMs]
|
|
181
|
+
* @param {number} [opts.verifyWaitMs]
|
|
182
|
+
* @param {number} [opts.falseAlarmWindow] Rolling window of audit DECISIONS the
|
|
183
|
+
* HR-101 rate is judged over (`reaper.false-alarm-window`).
|
|
184
|
+
* @returns {Promise<{scanned: number, candidates: object[], reported: object[],
|
|
185
|
+
* rejected: object[], killed: object[], unattributed: number,
|
|
186
|
+
* peerLiveness: 'measured'|'unmeasured'|null, skipped?: string, malformed: number,
|
|
187
|
+
* durationMs: number, instrumentSuspect: boolean|null, falseAlarmRate: number|null,
|
|
188
|
+
* falseAlarmWindowN: number}>}
|
|
189
|
+
* `instrumentSuspect`/`falseAlarmRate` are `null` on a degraded (`skipped`) scan
|
|
190
|
+
* and `falseAlarmRate` is `null` below the 10-decision floor — in both cases a
|
|
191
|
+
* measurement that does not exist, never a measured zero. `peerLiveness` is the
|
|
192
|
+
* same distinction for the peer probe, and `null` only on a degraded scan.
|
|
193
|
+
*/
|
|
194
|
+
export async function runOrphanScan({
|
|
195
|
+
repoRoot,
|
|
196
|
+
now,
|
|
197
|
+
dryRun = true,
|
|
198
|
+
deps,
|
|
199
|
+
minAgeSeconds = REAPER_DEFAULTS.minAgeSeconds,
|
|
200
|
+
killGraceMs = REAPER_DEFAULTS.killGraceMs,
|
|
201
|
+
verifyWaitMs = REAPER_DEFAULTS.verifyWaitMs,
|
|
202
|
+
falseAlarmWindow = REAPER_DEFAULTS.falseAlarmWindow,
|
|
203
|
+
} = {}) {
|
|
204
|
+
const d = resolveDeps(deps);
|
|
205
|
+
const startedAt = typeof now === 'number' ? now : d.now();
|
|
206
|
+
// A degraded scan measured NOTHING — including the instrument's own health.
|
|
207
|
+
// `null` rather than `false`/`0` for both instrument fields: a scan that never
|
|
208
|
+
// ran must not report a healthy instrument it never looked at.
|
|
209
|
+
const empty = (skipped, malformed = 0) => ({
|
|
210
|
+
scanned: 0,
|
|
211
|
+
candidates: [],
|
|
212
|
+
reported: [],
|
|
213
|
+
rejected: [],
|
|
214
|
+
killed: [],
|
|
215
|
+
unattributed: 0,
|
|
216
|
+
// Nothing was measured, so peer liveness was not measured either — `null`
|
|
217
|
+
// rather than the string, because "unmeasured" is a MEASUREMENT OUTCOME and
|
|
218
|
+
// a degraded scan never got as far as the probe.
|
|
219
|
+
peerLiveness: null,
|
|
220
|
+
skipped,
|
|
221
|
+
malformed,
|
|
222
|
+
durationMs: Math.max(0, d.now() - startedAt),
|
|
223
|
+
instrumentSuspect: null,
|
|
224
|
+
falseAlarmRate: null,
|
|
225
|
+
falseAlarmWindowN: 0,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return empty('no-repo-root');
|
|
229
|
+
|
|
230
|
+
let text;
|
|
231
|
+
try {
|
|
232
|
+
text = await d.runPs();
|
|
233
|
+
} catch {
|
|
234
|
+
return empty('ps-failed');
|
|
235
|
+
}
|
|
236
|
+
if (text === null || text === undefined) return empty('ps-failed');
|
|
237
|
+
|
|
238
|
+
const { rows, malformed } = parsePsSnapshotDetailed(text);
|
|
239
|
+
|
|
240
|
+
let ledger;
|
|
241
|
+
try {
|
|
242
|
+
ledger = d.readLedger(repoRoot, { nowMs: startedAt });
|
|
243
|
+
} catch {
|
|
244
|
+
return empty('ledger-unreadable', malformed);
|
|
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);
|
|
250
|
+
|
|
251
|
+
let ownSessionId = null;
|
|
252
|
+
try {
|
|
253
|
+
ownSessionId = await d.readOwnSessionId(repoRoot);
|
|
254
|
+
} catch {
|
|
255
|
+
ownSessionId = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @type {string[]|null} */
|
|
259
|
+
let livePeerSessionIds;
|
|
260
|
+
try {
|
|
261
|
+
livePeerSessionIds = await d.detectPeers(ownSessionId);
|
|
262
|
+
} catch {
|
|
263
|
+
// null = unmeasured, NOT "no peers" — see decideReapCandidates.
|
|
264
|
+
livePeerSessionIds = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { candidates, reported, rejected } = decideReapCandidates(
|
|
268
|
+
rows,
|
|
269
|
+
ledger?.records ?? [],
|
|
270
|
+
startedAt,
|
|
271
|
+
{ ownSessionId, livePeerSessionIds, minAgeSeconds },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const timestamp = new Date(startedAt).toISOString();
|
|
275
|
+
const audit = (entry, decision, extra = {}) => {
|
|
276
|
+
try {
|
|
277
|
+
d.appendAudit(repoRoot, buildAuditRecord(entry, decision, {
|
|
278
|
+
timestamp, sessionId: ownSessionId, ...extra,
|
|
279
|
+
}));
|
|
280
|
+
} catch {
|
|
281
|
+
/* the audit is an aid, never a precondition */
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const entry of reported) audit(entry, 'report');
|
|
286
|
+
|
|
287
|
+
/** @type {object[]} */
|
|
288
|
+
const killed = [];
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Close the scan: measure the instrument's own health (HR-101), emit at most
|
|
292
|
+
* one event, return the result. Both real exits go through here so the rate
|
|
293
|
+
* and the event can never be computed twice or forgotten once.
|
|
294
|
+
* @returns {Promise<object>}
|
|
295
|
+
*/
|
|
296
|
+
const complete = async () => {
|
|
297
|
+
let auditRecords;
|
|
298
|
+
try {
|
|
299
|
+
auditRecords = (await d.readAuditRecords(repoRoot, falseAlarmWindow)) ?? [];
|
|
300
|
+
} catch {
|
|
301
|
+
auditRecords = [];
|
|
302
|
+
}
|
|
303
|
+
const fa = falseAlarmRate(auditRecords, falseAlarmWindow);
|
|
304
|
+
// HR-101: the rate re-aims the instrument, it never re-thresholds it — so
|
|
305
|
+
// this flag is REPORTED and nothing here branches on it.
|
|
306
|
+
const instrumentSuspect = typeof fa.rate === 'number' && fa.rate > FALSE_ALARM_SUSPECT_RATE;
|
|
307
|
+
const survivedSigkill = killed.filter((k) => k.survivedSigkill === true).length;
|
|
308
|
+
const durationMs = Math.max(0, d.now() - startedAt);
|
|
309
|
+
// Two counts that must not hide inside `reported`: an unattributed record is
|
|
310
|
+
// a PRODUCER defect (the gate runner passed no session id), and an
|
|
311
|
+
// unmeasured peer probe is an INSTRUMENT gap. Folded into the generic
|
|
312
|
+
// `reported` number, both are invisible — which is how the foreign-session
|
|
313
|
+
// guard stayed inert across 377 of 377 records without anything saying so.
|
|
314
|
+
const unattributed = reported.filter((r) => r.reason === 'unattributed').length;
|
|
315
|
+
const peerLiveness = livePeerSessionIds === null ? 'unmeasured' : 'measured';
|
|
316
|
+
|
|
317
|
+
// HR-101 again, in the other direction: a signal that fires on every hook
|
|
318
|
+
// is noise nobody reads. A scan that found nothing emits nothing — the
|
|
319
|
+
// absence of a record IS the healthy state, and `instrumentSuspect` is the
|
|
320
|
+
// one finding that must surface even from an empty scan.
|
|
321
|
+
if (candidates.length + reported.length + killed.length > 0 || instrumentSuspect) {
|
|
322
|
+
try {
|
|
323
|
+
await d.emitEvent(REAPER_SCAN_EVENT, {
|
|
324
|
+
scanned: rows.length,
|
|
325
|
+
candidates: candidates.length,
|
|
326
|
+
reported: reported.length,
|
|
327
|
+
rejected: rejected.length,
|
|
328
|
+
killed: killed.length,
|
|
329
|
+
unattributed,
|
|
330
|
+
peer_liveness: peerLiveness,
|
|
331
|
+
survived_sigkill: survivedSigkill,
|
|
332
|
+
dry_run: dryRun === true,
|
|
333
|
+
duration_ms: durationMs,
|
|
334
|
+
instrument_suspect: instrumentSuspect,
|
|
335
|
+
// OMITTED below the 10-decision floor, never 0: absence means "no
|
|
336
|
+
// population yet", and a fabricated zero would read as a clean
|
|
337
|
+
// instrument (`falseAlarmRate` returns `{rate: null}` there).
|
|
338
|
+
...(typeof fa.rate === 'number' ? { false_alarm_rate: fa.rate } : {}),
|
|
339
|
+
}, { repoRoot });
|
|
340
|
+
} catch {
|
|
341
|
+
/* telemetry that can fail a scan would fail the hook the scan runs in */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Housekeeping, last and best-effort: the ledger is append-only and grew
|
|
346
|
+
// monotonically (measured 2026-09-22: 369 lines/day) because nothing in
|
|
347
|
+
// production ever called the pruner.
|
|
348
|
+
try {
|
|
349
|
+
pruneGateProcessLedger(repoRoot);
|
|
350
|
+
// The audit is pruned AFTER it was read for the rate above, so a prune
|
|
351
|
+
// can never shrink the population this scan judged itself on.
|
|
352
|
+
pruneReaperAudit(repoRoot);
|
|
353
|
+
} catch {
|
|
354
|
+
/* housekeeping, never a precondition of a scan */
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
scanned: rows.length,
|
|
359
|
+
candidates,
|
|
360
|
+
reported,
|
|
361
|
+
rejected,
|
|
362
|
+
killed,
|
|
363
|
+
unattributed,
|
|
364
|
+
peerLiveness,
|
|
365
|
+
malformed,
|
|
366
|
+
durationMs,
|
|
367
|
+
instrumentSuspect,
|
|
368
|
+
falseAlarmRate: fa.rate,
|
|
369
|
+
falseAlarmWindowN: fa.n,
|
|
370
|
+
};
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
if (dryRun) {
|
|
374
|
+
for (const c of candidates) audit(c, 'dry-run');
|
|
375
|
+
return complete();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* ONE fresh, targeted identity measurement for one candidate (B3).
|
|
380
|
+
*
|
|
381
|
+
* There is deliberately no second, bulk `ps` pass any more: two TOCTOU
|
|
382
|
+
* instruments measuring the same property at different freshness is how the
|
|
383
|
+
* stale one silently wins. This is the only pre-signal measurement, and it is
|
|
384
|
+
* taken immediately before each signal rather than once for the whole pass.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} c
|
|
387
|
+
* @returns {Promise<{match: boolean, reason: string, observed?: object}>}
|
|
388
|
+
*/
|
|
389
|
+
const freshIdentity = async (c) => {
|
|
390
|
+
let text;
|
|
391
|
+
try {
|
|
392
|
+
text = await d.runPsPid(c.pid);
|
|
393
|
+
} catch {
|
|
394
|
+
text = null;
|
|
395
|
+
}
|
|
396
|
+
// `ps -p` cannot distinguish "gone" from "could not run"; both must refuse
|
|
397
|
+
// the signal, and only the second is an instrument gap worth its own reason.
|
|
398
|
+
if (text === null || text === undefined) return { match: false, reason: 'unmeasured' };
|
|
399
|
+
const row = parsePsSnapshot(text).find((r) => r.pid === c.pid) ?? null;
|
|
400
|
+
try {
|
|
401
|
+
// A group MEMBER is re-verified the way it was decided — by the leader's
|
|
402
|
+
// seam it would fail every time (its own start time, its own command),
|
|
403
|
+
// and a pre-signal check that always refuses is a disarmed feature, not a
|
|
404
|
+
// strict one.
|
|
405
|
+
if (c.isLeader === false) {
|
|
406
|
+
return verifyGroupMemberIdentity(row, c.ledgerRecord, { nowMs: d.now() });
|
|
407
|
+
}
|
|
408
|
+
return d.verifyIdentity(
|
|
409
|
+
c.pid,
|
|
410
|
+
{ startTime: c.ledgerRecord.startTime, commandSignature: c.ledgerRecord.commandSignature },
|
|
411
|
+
{ snapshotLine: row, nowMs: d.now() },
|
|
412
|
+
);
|
|
413
|
+
} catch {
|
|
414
|
+
return { match: false, reason: 'gone' };
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// ONE ladder per GROUP, not per row: with the pgid join a single group can
|
|
419
|
+
// contribute several candidate rows (leader + descendants), and they all name
|
|
420
|
+
// the same kill target. Leaders first, so the target of a group is the process
|
|
421
|
+
// the ledger actually recorded whenever it is still alive.
|
|
422
|
+
const killTargets = [];
|
|
423
|
+
const coveredPgids = new Set();
|
|
424
|
+
for (const c of [...candidates.filter((x) => x.isLeader), ...candidates.filter((x) => !x.isLeader)]) {
|
|
425
|
+
if (coveredPgids.has(c.pgid)) continue;
|
|
426
|
+
coveredPgids.add(c.pgid);
|
|
427
|
+
killTargets.push(c);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for (const c of killTargets) {
|
|
431
|
+
/** Set by the gate below when it refuses; `null` means every signal was permitted. */
|
|
432
|
+
let withdrawal = null;
|
|
433
|
+
const beforeSignal = async (signal) => {
|
|
434
|
+
const identity = await freshIdentity(c);
|
|
435
|
+
if (identity?.match === true) return true;
|
|
436
|
+
withdrawal = { signal, identity, reason: rejectReasonFor(identity?.reason) };
|
|
437
|
+
return false;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
let result;
|
|
441
|
+
try {
|
|
442
|
+
result = await d.killProcessGroup(c.pgid, {
|
|
443
|
+
killGraceMs, verifyWaitMs, sleepFn: d.sleep, beforeSignal,
|
|
444
|
+
});
|
|
445
|
+
} catch (err) {
|
|
446
|
+
result = { ok: false, signalsSent: [], survivors: [c.pgid], error: err?.code ?? null, aborted: null };
|
|
447
|
+
}
|
|
448
|
+
const signalsSent = Array.isArray(result?.signalsSent) ? result.signalsSent : [];
|
|
449
|
+
|
|
450
|
+
if (withdrawal !== null) {
|
|
451
|
+
// The candidate was withdrawn between decision and signal. It stays in
|
|
452
|
+
// `candidates` (that WAS the decision) and appears in `rejected` (that is
|
|
453
|
+
// the withdrawal) — collapsing the two would lose the TOCTOU event.
|
|
454
|
+
// `signalsSent` is carried even here: a withdrawal before the ESCALATION
|
|
455
|
+
// still means a SIGTERM went out, and an audit that hid it would
|
|
456
|
+
// under-report what this reaper did to the host.
|
|
457
|
+
rejected.push({
|
|
458
|
+
...c, reason: withdrawal.reason, identity: withdrawal.identity, signalsSent,
|
|
459
|
+
});
|
|
460
|
+
audit(c, 'reject', {
|
|
461
|
+
reason: withdrawal.reason,
|
|
462
|
+
result: {
|
|
463
|
+
ok: false,
|
|
464
|
+
signalsSent,
|
|
465
|
+
survivors: [],
|
|
466
|
+
survivedSigkill: false,
|
|
467
|
+
verifiedAfterMs: 0,
|
|
468
|
+
verified: 'withdrawn',
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// B6: prove the EFFECT, after a wait, from a fresh measurement of the
|
|
475
|
+
// process table. An exit code and a sent signal prove nothing — on
|
|
476
|
+
// 2026-09-20 a probe with no wait reported "still alive" for dead processes.
|
|
477
|
+
let verified = 'unverified';
|
|
478
|
+
if (signalsSent.length > 0) {
|
|
479
|
+
await d.sleep(verifyWaitMs);
|
|
480
|
+
const after = await freshIdentity(c);
|
|
481
|
+
if (after?.match === true) verified = 'alive';
|
|
482
|
+
else if (after?.reason === 'unmeasured') verified = 'unmeasured';
|
|
483
|
+
else verified = 'gone';
|
|
484
|
+
}
|
|
485
|
+
const ok = verified === 'gone';
|
|
486
|
+
const survivedSigkill = verified === 'alive' && signalsSent.includes('SIGKILL');
|
|
487
|
+
const survivors = ok ? [] : [c.pgid];
|
|
488
|
+
const verifiedAfterMs = signalsSent.length > 0 ? verifyWaitMs : 0;
|
|
489
|
+
|
|
490
|
+
killed.push({
|
|
491
|
+
pid: c.pid,
|
|
492
|
+
pgid: c.pgid,
|
|
493
|
+
// Every candidate row this one ladder covers — the group join means one
|
|
494
|
+
// signal can end several candidates, and a `killed` list that named only
|
|
495
|
+
// the target would under-report what the reaper did to the host.
|
|
496
|
+
groupMemberPids: candidates.filter((x) => x.pgid === c.pgid).map((x) => x.pid),
|
|
497
|
+
ok,
|
|
498
|
+
signalsSent,
|
|
499
|
+
survivors,
|
|
500
|
+
survivedSigkill,
|
|
501
|
+
verified,
|
|
502
|
+
verifiedAfterMs,
|
|
503
|
+
});
|
|
504
|
+
audit(c, 'kill', {
|
|
505
|
+
result: { ok, signalsSent, survivors, survivedSigkill, verifiedAfterMs, verified },
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return complete();
|
|
510
|
+
}
|
|
@@ -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
|
+
}
|