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,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/reaper-decide.mjs — the PURE half of the reaper: which processes
|
|
3
|
+
* are reapable orphans, decided from a `ps` snapshot and the gate-process ledger.
|
|
4
|
+
*
|
|
5
|
+
* ## Why PPID 1 is necessary and never sufficient
|
|
6
|
+
*
|
|
7
|
+
* Measured on this host 2026-09-21: 538 of 784 processes (68.6%) have PPID 1 —
|
|
8
|
+
* on macOS launchd is the parent of nearly everything. A reaper keyed on PPID 1
|
|
9
|
+
* alone would be a weapon pointed at the operating system. So a candidate is a
|
|
10
|
+
* CONJUNCTION: in our own gate-process ledger, PPID 1, old enough, a read-only
|
|
11
|
+
* gate command, and an identity that still verifies against the ledger record.
|
|
12
|
+
*
|
|
13
|
+
* ## Coverage: the ledger is joined by PID **and** by PGID
|
|
14
|
+
*
|
|
15
|
+
* The binding `ps` format (`PS_ARGS` in `ps-snapshot.mjs`) publishes `pgid`, so a row joins the
|
|
16
|
+
* ledger either as the recorded LEADER (`row.pid === record.pid`) or as any
|
|
17
|
+
* member of the recorded GROUP (`row.pgid === record.pgid`). The group join is
|
|
18
|
+
* what covers the measured grandchild case: `npm run typecheck` (82507) spawns
|
|
19
|
+
* `node scripts/typecheck.mjs` (82591) which spawns `tsgo`, all three in pgid
|
|
20
|
+
* 82507 — when only the leader dies, 82591 sits at PPID 1 and the ledger knows
|
|
21
|
+
* its group but not its pid. Named ceiling (BV-004): the join FINDS every
|
|
22
|
+
* member, but a member is only reaped when its own command passes the read-only
|
|
23
|
+
* allowlist — `tsgo`/`vitest` descendants are, a repo-local `node scripts/…`
|
|
24
|
+
* runner is NOT and is reported as `not-read-only` (measured 2026-09-22); the
|
|
25
|
+
* group still dies through its allowlisted sibling. Revisit trigger: an orphaned
|
|
26
|
+
* node runner that outlives every allowlisted sibling.
|
|
27
|
+
*
|
|
28
|
+
* A non-leader cannot be identified the leader's way: its start time is its own
|
|
29
|
+
* and its command line is not the recorded one. Its identity is therefore
|
|
30
|
+
* {@link verifyGroupMemberIdentity} — born no earlier than its group leader
|
|
31
|
+
* (a descendant is never older than the process that forked it), plus either
|
|
32
|
+
* the record's signature token or a command name the read-only allowlist knows.
|
|
33
|
+
*
|
|
34
|
+
* The KILL TARGET is the group in both cases (`-pgid`), so one record still
|
|
35
|
+
* covers its whole group with one ladder.
|
|
36
|
+
*
|
|
37
|
+
* Named ceiling (BV-004): a process that `setsid`-ed out of its group carries no
|
|
38
|
+
* ledger identity in this format and is not a candidate BY DESIGN (PRD
|
|
39
|
+
* § Umfangsgrenze: orphan-confidence via PPID history is Stufe 2 / C4). Revisit
|
|
40
|
+
* if such a case is ever observed for a process the ledger DID record.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { verifyProcessIdentity } from '../process-group.mjs';
|
|
44
|
+
import { REAPER_DEFAULTS } from './defaults.mjs';
|
|
45
|
+
|
|
46
|
+
/** @typedef {import('./ps-snapshot.mjs').PsRow} PsRow */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The read-only gate command class — an ALLOWLIST, which is why no denylist is
|
|
50
|
+
* needed: a dev server or MCP server matches nothing here and is therefore never
|
|
51
|
+
* a candidate, orphaned or not (PRD § Out-of-Scope: two orphaned `browser-kit`
|
|
52
|
+
* servers were reported, not killed).
|
|
53
|
+
*
|
|
54
|
+
* IN — commands that only read the working copy and can be re-run at will:
|
|
55
|
+
* `tsgo`, `tsc`, `vitest`, `eslint`, `node …vitest…`,
|
|
56
|
+
* `npm test` / `npm run typecheck` / `npm run lint`.
|
|
57
|
+
* DELIBERATELY OUT — anything that holds state or serves a port: dev servers,
|
|
58
|
+
* MCP servers, `npm run build`, `git` (writes the index), database processes.
|
|
59
|
+
*
|
|
60
|
+
* Matched per STATEMENT (see {@link isReadOnlyCommand}), with a `(^|[\s/])`
|
|
61
|
+
* boundary so both `tsgo --noEmit` and `/opt/homebrew/bin/tsgo --noEmit` and
|
|
62
|
+
* `npx tsgo` hit.
|
|
63
|
+
*
|
|
64
|
+
* `eslint` carries a negative lookahead for its WRITING flags: `eslint . --fix`
|
|
65
|
+
* is this repo's own `lint:fix` script and rewrites the working copy, so it is
|
|
66
|
+
* not re-runnable at will and has no business on a read-only allowlist.
|
|
67
|
+
*/
|
|
68
|
+
export const READ_ONLY_COMMAND_PATTERNS = Object.freeze([
|
|
69
|
+
/(^|[\s/])tsgo(\s|$)/,
|
|
70
|
+
/(^|[\s/])tsc(\s|$)/,
|
|
71
|
+
/(^|[\s/])vitest(\s|$)/,
|
|
72
|
+
/(^|[\s/])eslint(?!\S)(?!.*\s--(fix|fix-dry-run|output-file)(\s|=|$))/,
|
|
73
|
+
/(^|[\s/])node\s+\S*vitest/,
|
|
74
|
+
/(^|[\s/])npm\s+(run\s+)?(test|typecheck|lint)(\s|$)/,
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Shell operators that separate one STATEMENT from the next in a `ps` `args`
|
|
79
|
+
* line. `ps` prints argv joined by spaces with the quoting already stripped, so
|
|
80
|
+
* a quote-aware lexer (`command-blocker.mjs` `splitChainSegments`) would have
|
|
81
|
+
* nothing left to be aware of here — and it would put 2.190 lines into the
|
|
82
|
+
* static import closure of a module that is destined for a hot-path hook
|
|
83
|
+
* (#1432) and lazily imports even `events.mjs` for that reason.
|
|
84
|
+
*/
|
|
85
|
+
const STATEMENT_SEPARATOR_RE = /\s*(?:&&|\|\||[;|&])\s*/;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True when EVERY statement of `args` is a read-only gate command per
|
|
89
|
+
* {@link READ_ONLY_COMMAND_PATTERNS}.
|
|
90
|
+
*
|
|
91
|
+
* Per-statement and ALL, not the whole string and ANY: judged over the whole
|
|
92
|
+
* line, `sh -c npm run build && npm test` matches the `npm test` pattern as a
|
|
93
|
+
* SUBSTRING and a build — which writes — is allowlisted by the read-only half
|
|
94
|
+
* of its own command. This is the destructive-guard's per-statement rule
|
|
95
|
+
* (`.claude/rules/guard-design.md` § "Widening a matcher without narrowing its
|
|
96
|
+
* bypass"), applied in the other direction: there one appended statement lifts
|
|
97
|
+
* a block, here one appended statement must be able to REVOKE a permission.
|
|
98
|
+
*
|
|
99
|
+
* An empty statement (a trailing `&&`, a doubled separator) is skipped rather
|
|
100
|
+
* than counted as a failure; a line with no statement at all is not read-only.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} args
|
|
103
|
+
* @param {readonly RegExp[]} [patterns]
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
export function isReadOnlyCommand(args, patterns = READ_ONLY_COMMAND_PATTERNS) {
|
|
107
|
+
const s = typeof args === 'string' ? args : '';
|
|
108
|
+
if (s.trim().length === 0) return false;
|
|
109
|
+
const statements = s.split(STATEMENT_SEPARATOR_RE).filter((part) => part.trim().length > 0);
|
|
110
|
+
if (statements.length === 0) return false;
|
|
111
|
+
return statements.every((statement) => patterns.some((re) => re.test(statement)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Identity check for a NON-LEADER member of a recorded process group — PURE.
|
|
116
|
+
*
|
|
117
|
+
* {@link verifyProcessIdentity} cannot serve here: it compares the row's start
|
|
118
|
+
* time against the LEADER's and the row's first token against the LEADER's
|
|
119
|
+
* signature, and a descendant matches neither (`node scripts/typecheck.mjs` was
|
|
120
|
+
* forked by `npm run typecheck` seconds after it).
|
|
121
|
+
*
|
|
122
|
+
* Both checks must hold, and both are one-sided on purpose:
|
|
123
|
+
* 1. NOT OLDER than the group leader, within `toleranceMs`. A process that
|
|
124
|
+
* existed BEFORE the leader cannot be its descendant, so an older row in the
|
|
125
|
+
* same pgid is a recycled group id, never a grandchild.
|
|
126
|
+
* 2. Either the leader's signature token prefixes the row's command (the
|
|
127
|
+
* descendant re-execs the same binary — `npm` → `npm`), or the row's command
|
|
128
|
+
* is itself on the read-only allowlist (`tsgo`, `node …vitest` — NOT a
|
|
129
|
+
* repo-local `node scripts/typecheck.mjs`, see the module head).
|
|
130
|
+
*
|
|
131
|
+
* @param {PsRow|null} row
|
|
132
|
+
* @param {import('../process-group.mjs').GateProcessRecord} record
|
|
133
|
+
* @param {object} [opts]
|
|
134
|
+
* @param {number} [opts.nowMs]
|
|
135
|
+
* @param {number} [opts.toleranceMs]
|
|
136
|
+
* @param {readonly RegExp[]} [opts.readOnlyPatterns]
|
|
137
|
+
* @returns {{match: boolean, reason: 'ok'|'gone'|'start-time-mismatch'|'signature-mismatch'}}
|
|
138
|
+
*/
|
|
139
|
+
export function verifyGroupMemberIdentity(row, record, {
|
|
140
|
+
nowMs = Date.now(),
|
|
141
|
+
toleranceMs = 2000,
|
|
142
|
+
readOnlyPatterns = READ_ONLY_COMMAND_PATTERNS,
|
|
143
|
+
} = {}) {
|
|
144
|
+
if (!row || typeof row !== 'object') return { match: false, reason: 'gone' };
|
|
145
|
+
const etimeSeconds = typeof row.etimeSeconds === 'number' && Number.isFinite(row.etimeSeconds)
|
|
146
|
+
? row.etimeSeconds
|
|
147
|
+
: null;
|
|
148
|
+
// An unmeasurable age is a refusal, never a pass — the same fail-closed
|
|
149
|
+
// direction `verifyProcessIdentity` takes for the leader.
|
|
150
|
+
if (etimeSeconds === null) return { match: false, reason: 'start-time-mismatch' };
|
|
151
|
+
const observedStart = nowMs - etimeSeconds * 1000;
|
|
152
|
+
const leaderStart = Number(record?.startTime);
|
|
153
|
+
if (!Number.isFinite(leaderStart)) return { match: false, reason: 'start-time-mismatch' };
|
|
154
|
+
if (observedStart < leaderStart - toleranceMs) {
|
|
155
|
+
return { match: false, reason: 'start-time-mismatch' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const args = typeof row.args === 'string' ? row.args : '';
|
|
159
|
+
const token = signatureTokenOf(record?.commandSignature);
|
|
160
|
+
const firstToken = args.trim().split(/\s+/)[0] ?? '';
|
|
161
|
+
const tokenOk = token.length > 0 && firstToken === token;
|
|
162
|
+
if (!tokenOk && !isReadOnlyCommand(args, readOnlyPatterns)) {
|
|
163
|
+
return { match: false, reason: 'signature-mismatch' };
|
|
164
|
+
}
|
|
165
|
+
return { match: true, reason: 'ok' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The command-name half of a command signature (`npm:6f1c…` → `npm`).
|
|
170
|
+
* A local four-liner rather than an import: `process-group.mjs` keeps its own
|
|
171
|
+
* copy private, and re-exporting it for one caller would widen that module's
|
|
172
|
+
* interface for no second consumer.
|
|
173
|
+
*
|
|
174
|
+
* @param {unknown} signature
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function signatureTokenOf(signature) {
|
|
178
|
+
const s = typeof signature === 'string' ? signature : '';
|
|
179
|
+
const i = s.lastIndexOf(':');
|
|
180
|
+
return i === -1 ? s : s.slice(0, i);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Decide which processes are reapable orphans — PURE.
|
|
185
|
+
*
|
|
186
|
+
* No I/O, no signal, no clock of its own: `nowMs` is an argument. The only
|
|
187
|
+
* imports it reaches are {@link verifyProcessIdentity} and
|
|
188
|
+
* {@link verifyGroupMemberIdentity}, both themselves pure.
|
|
189
|
+
*
|
|
190
|
+
* Exactly ONE verdict per examined row, at a FIXED priority, so a row can never
|
|
191
|
+
* appear twice and a trigger is never ambiguous:
|
|
192
|
+
*
|
|
193
|
+
* 1. not in the ledger → `rejected: not-in-ledger`
|
|
194
|
+
* 2. record with `pgid !== pid` → `rejected: pgid-mismatch`
|
|
195
|
+
* 3. PPID !== 1 → `rejected: has-parent`
|
|
196
|
+
* 4. younger than `minAgeSeconds` → `rejected: too-young`
|
|
197
|
+
* 5. no `sessionId` on the record → `reported: unattributed` (never killed)
|
|
198
|
+
* 6. foreign session, live or of unmeasurable liveness
|
|
199
|
+
* → `reported: foreign-live-session`
|
|
200
|
+
* / `foreign-session-liveness-unknown` (never killed)
|
|
201
|
+
* 7. not a read-only command → `reported: not-read-only` (never killed)
|
|
202
|
+
* 8. identity does not verify → `rejected: identity-mismatch | signature-mismatch`
|
|
203
|
+
* 9. otherwise → `candidates` with `trigger: 'orphan-ppid1'`
|
|
204
|
+
*
|
|
205
|
+
* Step 2 is a LEDGER-INTEGRITY rejection, not a property of the row: under
|
|
206
|
+
* `detached: true` the leader IS its own group, so `pgid === pid` is the
|
|
207
|
+
* documented invariant (`process-group.mjs` — "the child calls setsid, so it IS
|
|
208
|
+
* its own group leader"). A record violating it describes a group this module
|
|
209
|
+
* did not create, and its `pgid` is the value that gets negated and signalled.
|
|
210
|
+
*
|
|
211
|
+
* Step 5 is fail-closed and was inert until 2026-09-22: `sessionId` was `null`
|
|
212
|
+
* in 377 of 377 live records because the gate runner passed none, and a null
|
|
213
|
+
* owner was silently read as "nobody's, therefore mine". An unattributed process
|
|
214
|
+
* is REPORTED — it is not evidence of ownership in either direction.
|
|
215
|
+
*
|
|
216
|
+
* Step 6 precedes step 7 on purpose: a foreign live session's process is
|
|
217
|
+
* reported for its OWNER, whether or not it also happens to be read-only
|
|
218
|
+
* (PRD FA3: "gemeldet, aber nicht automatisch getötet").
|
|
219
|
+
*
|
|
220
|
+
* Named ceiling (BV-004) on the `rejected` volume: every PPID-1 row that is not
|
|
221
|
+
* in the ledger is materialised as `not-in-ledger` — ~538 entries on this
|
|
222
|
+
* 784-process host. That is deliberate (the AC requires the rejection reason to
|
|
223
|
+
* be traceable in the RESULT) and bounded, because only candidates and reports
|
|
224
|
+
* are ever persisted to the audit; `rejected` stays in memory for one scan.
|
|
225
|
+
* Rows with a live parent that are ALSO not in the ledger are skipped entirely —
|
|
226
|
+
* they are neither orphan-shaped nor ours, and materialising them would double
|
|
227
|
+
* the array for no signal. Revisit if a consumer ever persists `rejected`.
|
|
228
|
+
*
|
|
229
|
+
* @param {PsRow[]} snapshot
|
|
230
|
+
* @param {import('../process-group.mjs').GateProcessRecord[]} ledgerRecords
|
|
231
|
+
* @param {number} nowMs
|
|
232
|
+
* @param {object} [opts]
|
|
233
|
+
* @param {string|null} [opts.ownSessionId]
|
|
234
|
+
* @param {string[]|null} [opts.livePeerSessionIds] Session ids `detectPeers()`
|
|
235
|
+
* reports LIVE — or `null` when the probe could not measure at all.
|
|
236
|
+
* @param {number} [opts.minAgeSeconds]
|
|
237
|
+
* @param {readonly RegExp[]} [opts.readOnlyPatterns]
|
|
238
|
+
* @param {number} [opts.identityToleranceMs]
|
|
239
|
+
* @returns {{candidates: object[], reported: object[], rejected: object[]}}
|
|
240
|
+
*/
|
|
241
|
+
export function decideReapCandidates(snapshot, ledgerRecords, nowMs, {
|
|
242
|
+
ownSessionId = null,
|
|
243
|
+
livePeerSessionIds = [],
|
|
244
|
+
minAgeSeconds = REAPER_DEFAULTS.minAgeSeconds,
|
|
245
|
+
readOnlyPatterns = READ_ONLY_COMMAND_PATTERNS,
|
|
246
|
+
identityToleranceMs = 2000,
|
|
247
|
+
} = {}) {
|
|
248
|
+
const rows = Array.isArray(snapshot) ? snapshot : [];
|
|
249
|
+
const records = Array.isArray(ledgerRecords) ? ledgerRecords : [];
|
|
250
|
+
// THREE states, not two: a measured list, an empty measured list, or `null`
|
|
251
|
+
// for "liveness could not be measured". A failed peer probe must not read as
|
|
252
|
+
// "no peers are alive" — that would turn an unmeasured foreign session into a
|
|
253
|
+
// kill target (a missing measurement must never look like a zero).
|
|
254
|
+
const peers = livePeerSessionIds === null || livePeerSessionIds === undefined
|
|
255
|
+
? null
|
|
256
|
+
: new Set((Array.isArray(livePeerSessionIds) ? livePeerSessionIds : [])
|
|
257
|
+
.filter((id) => typeof id === 'string' && id.length > 0));
|
|
258
|
+
|
|
259
|
+
/** Leader index: the pid the ledger recorded. @type {Map<number, object>} */
|
|
260
|
+
const byPid = new Map();
|
|
261
|
+
/** Group index: every member of a recorded group joins through this.
|
|
262
|
+
* @type {Map<number, object>} */
|
|
263
|
+
const byPgid = new Map();
|
|
264
|
+
for (const rec of records) {
|
|
265
|
+
if (!rec || typeof rec.pid !== 'number') continue;
|
|
266
|
+
byPid.set(rec.pid, rec);
|
|
267
|
+
// First record wins for a pgid: a recycled group id would otherwise let a
|
|
268
|
+
// newer record claim an older group's members.
|
|
269
|
+
if (typeof rec.pgid === 'number' && !byPgid.has(rec.pgid)) byPgid.set(rec.pgid, rec);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const candidates = [];
|
|
273
|
+
const reported = [];
|
|
274
|
+
const rejected = [];
|
|
275
|
+
|
|
276
|
+
for (const row of rows) {
|
|
277
|
+
// A `pid` hit is the LEADER; a `pgid` hit is any other member of its group
|
|
278
|
+
// (the measured grandchild case — see the module header). The leader wins,
|
|
279
|
+
// so a row is never judged by the weaker of the two identities.
|
|
280
|
+
const leaderRecord = byPid.get(row.pid) ?? null;
|
|
281
|
+
const record = leaderRecord
|
|
282
|
+
?? (typeof row.pgid === 'number' ? byPgid.get(row.pgid) ?? null : null);
|
|
283
|
+
const isLeader = leaderRecord !== null;
|
|
284
|
+
const orphanShaped = row.ppid === 1;
|
|
285
|
+
|
|
286
|
+
if (!record) {
|
|
287
|
+
if (orphanShaped) {
|
|
288
|
+
rejected.push({
|
|
289
|
+
pid: row.pid, ppid: row.ppid, ageSeconds: row.etimeSeconds, reason: 'not-in-ledger',
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const recordSessionId = typeof record.sessionId === 'string' && record.sessionId.length > 0
|
|
296
|
+
? record.sessionId
|
|
297
|
+
: null;
|
|
298
|
+
const base = {
|
|
299
|
+
pid: row.pid,
|
|
300
|
+
pgid: record.pgid,
|
|
301
|
+
ppid: row.ppid,
|
|
302
|
+
isLeader,
|
|
303
|
+
ageSeconds: row.etimeSeconds,
|
|
304
|
+
rssKb: row.rssKb,
|
|
305
|
+
cpuPct: row.cpuPct,
|
|
306
|
+
commandSignature: record.commandSignature ?? null,
|
|
307
|
+
// Normalised to `null`: an empty-string owner is an ABSENT owner, and
|
|
308
|
+
// leaving `''` here would let a downstream truthiness check read it as one.
|
|
309
|
+
sessionId: recordSessionId,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Ledger integrity before anything else: `record.pgid` is the value that
|
|
313
|
+
// gets NEGATED and signalled, and `pgid === pid` is the invariant every
|
|
314
|
+
// record this module writes satisfies.
|
|
315
|
+
if (record.pgid !== record.pid) {
|
|
316
|
+
rejected.push({ ...base, reason: 'pgid-mismatch' });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!orphanShaped) {
|
|
321
|
+
rejected.push({ ...base, reason: 'has-parent' });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (row.etimeSeconds < minAgeSeconds) {
|
|
325
|
+
rejected.push({ ...base, reason: 'too-young', threshold: { minAgeSeconds } });
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (recordSessionId === null) {
|
|
330
|
+
// Fail-closed: an unowned record is not an unowned PROCESS. Report it and
|
|
331
|
+
// let the operator (or a fixed producer) decide.
|
|
332
|
+
reported.push({ ...base, reason: 'unattributed' });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const foreignSessionId = recordSessionId !== ownSessionId ? recordSessionId : null;
|
|
336
|
+
if (foreignSessionId !== null) {
|
|
337
|
+
if (peers === null) {
|
|
338
|
+
// Unmeasurable liveness → report, never reap. The only foreign process
|
|
339
|
+
// this function reaps is one whose session a SUCCESSFUL probe proved dead.
|
|
340
|
+
reported.push({ ...base, reason: 'foreign-session-liveness-unknown' });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (peers.has(foreignSessionId)) {
|
|
344
|
+
// `args` ONLY for a command the allowlist already cleared — the same
|
|
345
|
+
// rule `ARGS_HEAD_CHARS` (`reaper-audit.mjs`) states, applied at the source rather than at
|
|
346
|
+
// the audit writer, so a foreign dev server's command line (paths,
|
|
347
|
+
// tokens) never enters the result in the first place.
|
|
348
|
+
reported.push({
|
|
349
|
+
...base,
|
|
350
|
+
...(isReadOnlyCommand(row.args, readOnlyPatterns) ? { args: row.args } : {}),
|
|
351
|
+
reason: 'foreign-live-session',
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!isReadOnlyCommand(row.args, readOnlyPatterns)) {
|
|
358
|
+
// No `args` here: a command the allowlist did not recognise is not ours to
|
|
359
|
+
// copy around (dev servers, MCP servers, anything with a command line we
|
|
360
|
+
// have no reason to retain).
|
|
361
|
+
reported.push({ ...base, reason: 'not-read-only' });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const identity = isLeader
|
|
366
|
+
? verifyProcessIdentity(
|
|
367
|
+
row.pid,
|
|
368
|
+
{ startTime: record.startTime, commandSignature: record.commandSignature },
|
|
369
|
+
{ snapshotLine: row, nowMs, toleranceMs: identityToleranceMs },
|
|
370
|
+
)
|
|
371
|
+
: verifyGroupMemberIdentity(row, record, {
|
|
372
|
+
nowMs, toleranceMs: identityToleranceMs, readOnlyPatterns,
|
|
373
|
+
});
|
|
374
|
+
if (!identity.match) {
|
|
375
|
+
rejected.push({
|
|
376
|
+
...base,
|
|
377
|
+
reason: identity.reason === 'signature-mismatch' ? 'signature-mismatch' : 'identity-mismatch',
|
|
378
|
+
identity: { match: identity.match, reason: identity.reason },
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
candidates.push({
|
|
384
|
+
...base,
|
|
385
|
+
args: row.args,
|
|
386
|
+
ledgerRecord: record,
|
|
387
|
+
identity: { match: identity.match, reason: identity.reason },
|
|
388
|
+
trigger: 'orphan-ppid1',
|
|
389
|
+
threshold: { minAgeSeconds },
|
|
390
|
+
actual: { ageSeconds: row.etimeSeconds },
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { candidates, reported, rejected };
|
|
395
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orphan-reaper/scan-throttle.mjs — the B4 throttle the trigger calls.
|
|
3
|
+
*
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
|
|
16
|
+
|
|
17
|
+
/** Relative path of the scan-throttle marker (B4). Gitignored via
|
|
18
|
+
* `.gitignore:141` (`.orchestrator/tmp/`). */
|
|
19
|
+
export const SCAN_MARKER_RELPATH = '.orchestrator/tmp/reaper-last-scan';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Throttle gate (B4): may a scan run now?
|
|
23
|
+
*
|
|
24
|
+
* Reads the marker's mtime; a missing or unreadable marker means "yes" — the
|
|
25
|
+
* first scan of a host must not be blocked by the absence of its own throttle
|
|
26
|
+
* file. Writing the marker is the CALLER's job ({@link touchScanMarker}), so
|
|
27
|
+
* this stays a read-only predicate a hook can call cheaply.
|
|
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
|
+
*
|
|
42
|
+
* @param {string} markerPath Absolute path — build it with {@link scanMarkerPath}.
|
|
43
|
+
* @param {number} nowMs
|
|
44
|
+
* @param {number} [minIntervalSeconds]
|
|
45
|
+
* @param {object} [opts]
|
|
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).
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function shouldScanNow(markerPath, nowMs, minIntervalSeconds = REAPER_DEFAULTS.minScanIntervalSeconds, {
|
|
52
|
+
statFn = lstatSync,
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (typeof markerPath !== 'string' || markerPath.length === 0) return false;
|
|
55
|
+
let stats;
|
|
56
|
+
try {
|
|
57
|
+
stats = statFn(markerPath);
|
|
58
|
+
} catch {
|
|
59
|
+
return true; // no marker yet → first scan
|
|
60
|
+
}
|
|
61
|
+
if (typeof stats?.isFile === 'function' && !stats.isFile()) return true;
|
|
62
|
+
const mtimeMs = stats?.mtimeMs;
|
|
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;
|
|
69
|
+
return (nowMs - mtimeMs) >= minIntervalSeconds * 1000;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Stamp the throttle marker. Best-effort and never throws — a marker that could
|
|
74
|
+
* not be written means the next scan runs, which is the safe direction for a
|
|
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.
|
|
92
|
+
*
|
|
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.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} markerPath
|
|
103
|
+
* @param {object} [opts]
|
|
104
|
+
* @param {(p: string, data: string) => void} [opts.writeFn]
|
|
105
|
+
* @returns {boolean} whether the marker was written
|
|
106
|
+
*/
|
|
107
|
+
export function touchScanMarker(markerPath, { writeFn } = {}) {
|
|
108
|
+
try {
|
|
109
|
+
const stamp = `${new Date().toISOString()}\n`;
|
|
110
|
+
if (writeFn) {
|
|
111
|
+
writeFn(markerPath, stamp);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
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
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
renameSync(tmp, markerPath);
|
|
125
|
+
return true;
|
|
126
|
+
} catch {
|
|
127
|
+
removeOwnTmp(tmp);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
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
|
+
|
|
140
|
+
/** Absolute path of the throttle marker for a repo. One constant per path.
|
|
141
|
+
* @param {string} repoRoot @returns {string} */
|
|
142
|
+
export function scanMarkerPath(repoRoot) {
|
|
143
|
+
return underRepo(repoRoot, SCAN_MARKER_RELPATH);
|
|
144
|
+
}
|