session-orchestrator 3.20.0 → 3.22.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/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -8,12 +8,70 @@
|
|
|
8
8
|
* Extended in v3.2 Phase C-2 (#296): swap + memory_pressure verdict rules.
|
|
9
9
|
* Zombie rule wired end-to-end in #178.
|
|
10
10
|
* Split from resource-probe.mjs in #287 (hotspot 2/2).
|
|
11
|
+
* Rebuilt in #1089 — signal precedence + right units + the two-signal rule.
|
|
12
|
+
*
|
|
13
|
+
* ---------------------------------------------------------------------------
|
|
14
|
+
* #1089 — why this file was rebuilt
|
|
15
|
+
* ---------------------------------------------------------------------------
|
|
16
|
+
* Measured 2026-08-21 over 1477 `orchestrator.session.started` events across 18
|
|
17
|
+
* repos (2026-04-19 → 2026-08-21): the previous rule set produced a warn-or-worse
|
|
18
|
+
* verdict on 1462 of them — 99.0%. The three drivers were each a MEASUREMENT
|
|
19
|
+
* defect, not a threshold set too tight:
|
|
20
|
+
*
|
|
21
|
+
* 1. `ram_free_gb` is `os.freemem()`, which on Darwin reports only `Pages free`.
|
|
22
|
+
* Median across the corpus: 0.4 GB; 53.8% of samples below 0.5 GB. So
|
|
23
|
+
* `ram-free-critical-gb: 2` fired on 84.0% of ALL session starts. Six repos
|
|
24
|
+
* independently recorded this as a false alarm in their learnings store —
|
|
25
|
+
* one at confidence 1.0 ("decide concurrency on ramAvailableGb, not the free
|
|
26
|
+
* number"), one noting "five consecutive sessions wrongly capped at 2".
|
|
27
|
+
* 2. `concurrent-sessions-warn` is named for SESSIONS but was compared against
|
|
28
|
+
* `claude_processes_count`. Measured ratio processes:sessions = 6.0. That
|
|
29
|
+
* threshold fired on 93.6% of starts; against the real peer count it fires
|
|
30
|
+
* on 4.2%.
|
|
31
|
+
* 3. Any ONE soft signal capped agents-per-wave at 2 outright, so the union of
|
|
32
|
+
* three noisy axes decided wave size on its noisiest member.
|
|
33
|
+
*
|
|
34
|
+
* Counter-evidence that makes this a re-aim rather than a removal: the
|
|
35
|
+
* 2026-04-19 incident (8-session host freeze + 2 worktree OOMs) was real, and a
|
|
36
|
+
* separate repo recorded (conf 0.9) that Chromium mis-paints under genuine
|
|
37
|
+
* memory pressure. The hazard exists — the old detector could not see it through
|
|
38
|
+
* its own noise floor.
|
|
39
|
+
*
|
|
40
|
+
* The rebuilt rules keep every protection and change what is measured:
|
|
41
|
+
*
|
|
42
|
+
* - SIGNAL PRECEDENCE (memory): judge on the best available signal, never on a
|
|
43
|
+
* worse one that merely happens to also be present. `memory_pressure_pct_free`
|
|
44
|
+
* (the OS's own health verdict) outranks `ram_available_gb`, which outranks
|
|
45
|
+
* `ram_free_gb`. On Linux/Windows `os.freemem()` IS accurate and stays the
|
|
46
|
+
* signal — the demotion is Darwin-specific by construction, since only Darwin
|
|
47
|
+
* publishes the two better signals. The old code merely SUPPRESSED a free-RAM
|
|
48
|
+
* verdict when pressure was healthy, which left the whole band
|
|
49
|
+
* "pressure present but <30%" still being judged on Pages-free.
|
|
50
|
+
* - RIGHT UNIT (concurrency): `peer_sessions_count` (live session registry)
|
|
51
|
+
* when present. `claude_processes_count` survives only as a fallback, scaled
|
|
52
|
+
* by the measured PROCESSES_PER_SESSION factor so the configured threshold
|
|
53
|
+
* keeps meaning "sessions" on hosts where the registry is unavailable.
|
|
54
|
+
* - TWO-SIGNAL RULE: a single soft signal is informational — reported in
|
|
55
|
+
* `reasons`, capping nothing. A cap needs two INDEPENDENT soft signals
|
|
56
|
+
* agreeing, or one hard signal. Simulated on the same 1477-sample corpus:
|
|
57
|
+
* `cpu>90 AND peers>=3` fires on 2.2%, `peers>=4` alone on 7.4%.
|
|
58
|
+
*
|
|
59
|
+
* Governing principle, stated here and in `.claude/rules/host-resources.md`:
|
|
60
|
+
* a signal may only warn if it is rare. A warning class firing on more than ~10%
|
|
61
|
+
* of starts is a broken instrument to be re-aimed, not a policy to obey.
|
|
11
62
|
*/
|
|
12
63
|
|
|
13
64
|
// ---------------------------------------------------------------------------
|
|
14
65
|
// Verdict precedence helper
|
|
15
66
|
// ---------------------------------------------------------------------------
|
|
16
67
|
|
|
68
|
+
/**
|
|
69
|
+
* `degraded` is retained in the rank table for back-compat: `evaluate()` no
|
|
70
|
+
* longer PRODUCES it (the rebuilt rules emit green | warn | critical only), but
|
|
71
|
+
* external callers may still pass a stored legacy verdict through
|
|
72
|
+
* {@link bumpVerdict}, and ranking it between warn and critical keeps that
|
|
73
|
+
* comparison meaningful.
|
|
74
|
+
*/
|
|
17
75
|
const VERDICT_RANK = { green: 0, warn: 1, degraded: 2, critical: 3 };
|
|
18
76
|
|
|
19
77
|
/**
|
|
@@ -22,47 +80,176 @@ const VERDICT_RANK = { green: 0, warn: 1, degraded: 2, critical: 3 };
|
|
|
22
80
|
* @param {string} target
|
|
23
81
|
* @returns {string}
|
|
24
82
|
*/
|
|
25
|
-
function bumpVerdict(current, target) {
|
|
83
|
+
export function bumpVerdict(current, target) {
|
|
26
84
|
const currentRank = VERDICT_RANK[current] ?? 0;
|
|
27
85
|
const targetRank = VERDICT_RANK[target] ?? 0;
|
|
28
86
|
return targetRank > currentRank ? target : current;
|
|
29
87
|
}
|
|
30
88
|
|
|
31
89
|
/**
|
|
32
|
-
* macOS
|
|
33
|
-
* the OS reports that compressor + caches are doing their job, and `ram_free_gb`
|
|
34
|
-
* (which on macOS reflects only `Pages free`, not `inactive`) is misleading
|
|
35
|
-
* — the system has plenty of reclaimable memory. We suppress free-RAM-only
|
|
36
|
-
* verdict-bumps in that regime.
|
|
90
|
+
* macOS memory_pressure bands, as percentages of memory the OS reports free.
|
|
37
91
|
*
|
|
38
|
-
*
|
|
39
|
-
* indicator since 10.9 Mavericks
|
|
40
|
-
*
|
|
41
|
-
*
|
|
92
|
+
* Apple's Activity Monitor has used memory_pressure — not "free RAM" — as the
|
|
93
|
+
* canonical health indicator since 10.9 Mavericks; these bands match the colours
|
|
94
|
+
* it renders (green ≥30, yellow 15..30, red <15).
|
|
95
|
+
*
|
|
96
|
+
* HEALTHY (≥30): compressor + caches are keeping up. No memory signal at all.
|
|
97
|
+
* SOFT (<30): worth reporting; not worth capping on its own.
|
|
98
|
+
* HARD (<15): genuine pressure — the band the 2026-04-19 freeze sat in.
|
|
42
99
|
*/
|
|
43
|
-
const
|
|
100
|
+
export const PRESSURE_HEALTHY_PCT = 30;
|
|
101
|
+
export const PRESSURE_SOFT_PCT = 30;
|
|
102
|
+
export const PRESSURE_HARD_PCT = 15;
|
|
44
103
|
|
|
45
104
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
* Measured processes-per-session factor (median `claude_processes_count` /
|
|
106
|
+
* `peer_count` = 6.0 over 1461 paired samples, 2026-08-21). Used ONLY to rescale
|
|
107
|
+
* the session-denominated `concurrent-sessions-warn` threshold when the live
|
|
108
|
+
* registry count is unavailable and a raw process count is all we have.
|
|
109
|
+
*
|
|
110
|
+
* Named ceiling (BV-004): this is one host family's ratio, not a law. Revisit if
|
|
111
|
+
* `peer_sessions_count` is unavailable on a host where Claude Code's per-session
|
|
112
|
+
* process fan-out differs materially — the fallback then mis-scales in whichever
|
|
113
|
+
* direction the real ratio moved.
|
|
114
|
+
*/
|
|
115
|
+
export const PROCESSES_PER_SESSION = 6;
|
|
116
|
+
|
|
117
|
+
/** Swap bands (MB). Only consulted when the memory signal is NOT healthy. */
|
|
118
|
+
const SWAP_HARD_MB = 3072;
|
|
119
|
+
const SWAP_SOFT_MB = 1024;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Canonical `resource-thresholds` defaults — the ONE definition (#1089).
|
|
123
|
+
*
|
|
124
|
+
* Before this constant existed there were three, and they had drifted:
|
|
125
|
+
* `config/vault-integration.mjs` had 4 / 2 / 80 / 5, `dispatcher/rank.mjs` had
|
|
126
|
+
* the same four, and `autopilot.mjs` had 2.0 / 0.5 / 85 / 3 under a comment
|
|
127
|
+
* claiming it "mirrors resource-probe hard-coded values" — so autopilot was
|
|
128
|
+
* judging every host against a threshold set nothing else used, and the comment
|
|
129
|
+
* asserting otherwise is what kept anyone from noticing. Config-supplied values
|
|
130
|
+
* still override these; this is only the floor when a repo declares none.
|
|
131
|
+
*/
|
|
132
|
+
export const DEFAULT_RESOURCE_THRESHOLDS = Object.freeze({
|
|
133
|
+
'ram-free-min-gb': 4,
|
|
134
|
+
'ram-free-critical-gb': 2,
|
|
135
|
+
'cpu-load-max-pct': 90,
|
|
136
|
+
'concurrent-sessions-warn': 5,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve which memory signal to judge on, in strict precedence order.
|
|
141
|
+
*
|
|
142
|
+
* This is the heart of the #1089 fix. The old code fell back to `ram_free_gb`
|
|
143
|
+
* whenever `ram_available_gb` was absent, EVEN IF `memory_pressure_pct_free` was
|
|
144
|
+
* present but merely below its healthy band — so a Darwin host reporting
|
|
145
|
+
* "0.3 GB free / 20% pressure-free" was judged on the 0.3 and went critical.
|
|
146
|
+
* Precedence removes that: a better signal, when present, does not suppress the
|
|
147
|
+
* worse one, it REPLACES it.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} snapshot
|
|
150
|
+
* @param {number} ramMin — `ram-free-min-gb`
|
|
151
|
+
* @param {number} ramCrit — `ram-free-critical-gb`
|
|
152
|
+
* @returns {{kind: 'pressure'|'available'|'free', hard: boolean, soft: boolean, reason: string|null}}
|
|
153
|
+
*/
|
|
154
|
+
function memorySignal(snapshot, ramMin, ramCrit) {
|
|
155
|
+
const { ram_free_gb, ram_available_gb, memory_pressure_pct_free } = snapshot;
|
|
156
|
+
|
|
157
|
+
const hasPressure =
|
|
158
|
+
memory_pressure_pct_free !== null && memory_pressure_pct_free !== undefined;
|
|
159
|
+
if (hasPressure) {
|
|
160
|
+
if (memory_pressure_pct_free < PRESSURE_HARD_PCT) {
|
|
161
|
+
return {
|
|
162
|
+
kind: 'pressure',
|
|
163
|
+
hard: true,
|
|
164
|
+
soft: false,
|
|
165
|
+
reason: `macOS memory_pressure ${memory_pressure_pct_free}% free — below the hard band (<${PRESSURE_HARD_PCT}%); the OS reports genuine memory pressure.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (memory_pressure_pct_free < PRESSURE_SOFT_PCT) {
|
|
169
|
+
return {
|
|
170
|
+
kind: 'pressure',
|
|
171
|
+
hard: false,
|
|
172
|
+
soft: true,
|
|
173
|
+
reason: `macOS memory_pressure ${memory_pressure_pct_free}% free — in the soft band (${PRESSURE_HARD_PCT}..${PRESSURE_SOFT_PCT}%).`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
kind: 'pressure',
|
|
178
|
+
hard: false,
|
|
179
|
+
soft: false,
|
|
180
|
+
reason: `macOS memory_pressure healthy (${memory_pressure_pct_free}% free ≥ ${PRESSURE_HEALTHY_PCT}%) — free-RAM not consulted (Pages-free underreports on Darwin).`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hasAvailable =
|
|
185
|
+
ram_available_gb !== null && ram_available_gb !== undefined;
|
|
186
|
+
const effective = hasAvailable ? ram_available_gb : ram_free_gb;
|
|
187
|
+
const label = hasAvailable ? 'RAM available' : 'RAM free';
|
|
188
|
+
|
|
189
|
+
if (effective < ramCrit) {
|
|
190
|
+
return {
|
|
191
|
+
kind: hasAvailable ? 'available' : 'free',
|
|
192
|
+
hard: true,
|
|
193
|
+
soft: false,
|
|
194
|
+
reason: `${label} ${effective.toFixed(1)} GB below critical threshold ${ramCrit} GB.`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (effective < ramMin) {
|
|
198
|
+
return {
|
|
199
|
+
kind: hasAvailable ? 'available' : 'free',
|
|
200
|
+
hard: false,
|
|
201
|
+
soft: true,
|
|
202
|
+
reason: `${label} ${effective.toFixed(1)} GB below threshold ${ramMin} GB.`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
kind: hasAvailable ? 'available' : 'free',
|
|
207
|
+
hard: false,
|
|
208
|
+
soft: false,
|
|
209
|
+
reason: null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolve the concurrency signal, preferring the live session registry count
|
|
215
|
+
* over the raw process count. Returns `null` when neither is available.
|
|
57
216
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* `skills/session-start/phase-4-5-resource-health.md` documents wiring
|
|
62
|
-
* `config['agents-per-wave']` straight into this option, so the object shape
|
|
63
|
-
* is a real input here, not a hypothetical one.
|
|
64
|
-
* @returns {{verdict: 'green'|'warn'|'degraded'|'critical', reasons: string[], recommended_agents_per_wave_cap: number|null}}
|
|
217
|
+
* @param {object} snapshot
|
|
218
|
+
* @param {number} concWarn — configured threshold, denominated in SESSIONS
|
|
219
|
+
* @returns {{soft: boolean, reason: string|null}|null}
|
|
65
220
|
*/
|
|
221
|
+
function concurrencySignal(snapshot, concWarn) {
|
|
222
|
+
const { peer_sessions_count, claude_processes_count } = snapshot;
|
|
223
|
+
|
|
224
|
+
const hasPeers =
|
|
225
|
+
peer_sessions_count !== null && peer_sessions_count !== undefined;
|
|
226
|
+
if (hasPeers) {
|
|
227
|
+
if (peer_sessions_count >= concWarn) {
|
|
228
|
+
return {
|
|
229
|
+
soft: true,
|
|
230
|
+
reason: `${peer_sessions_count} peer session(s) live in the registry (threshold: ${concWarn}) — consider sequencing this session after others finish.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { soft: false, reason: null };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const hasProcs =
|
|
237
|
+
claude_processes_count !== null && claude_processes_count !== undefined;
|
|
238
|
+
if (!hasProcs) return null;
|
|
239
|
+
|
|
240
|
+
// Registry unavailable — fall back to the process count, rescaled so the
|
|
241
|
+
// session-denominated threshold keeps its meaning. Without this factor the
|
|
242
|
+
// comparison carries a measured 6x unit error, which is what fired on 93.6%
|
|
243
|
+
// of session starts before #1089.
|
|
244
|
+
const procThreshold = concWarn * PROCESSES_PER_SESSION;
|
|
245
|
+
if (claude_processes_count >= procThreshold) {
|
|
246
|
+
return {
|
|
247
|
+
soft: true,
|
|
248
|
+
reason: `${claude_processes_count} Claude processes running (fallback threshold ${procThreshold} = ${concWarn} sessions × ${PROCESSES_PER_SESSION} processes/session; the live session registry was unavailable).`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { soft: false, reason: null };
|
|
252
|
+
}
|
|
66
253
|
|
|
67
254
|
/**
|
|
68
255
|
* Resolve an `agentsPerWave` option value into a plain numeric cap, or `null`
|
|
@@ -89,12 +276,32 @@ function resolveAgentsPerWaveCap(cap) {
|
|
|
89
276
|
return null;
|
|
90
277
|
}
|
|
91
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Evaluate a snapshot against `resource-thresholds` (from Session Config #166)
|
|
281
|
+
* and return a verdict used by session-start Phase 4.5.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} snapshot — output of probe()
|
|
284
|
+
* @param {object} thresholds — resource-thresholds block from parseSessionConfig
|
|
285
|
+
* @param {{heavyRepo?: boolean, agentsPerWave?: number|{default: number, [mode: string]: number}}} [options] — HR-003/HR-004
|
|
286
|
+
* preflight ceiling (baseline #60). When `heavyRepo` is true and `agentsPerWave`
|
|
287
|
+
* resolves to a number, `recommended_agents_per_wave_cap` is forced to at most
|
|
288
|
+
* that number REGARDLESS of the live-probe verdict — a static preflight ceiling,
|
|
289
|
+
* not a runtime signal. More-restrictive-wins. Omitted entirely = back-compat.
|
|
290
|
+
* @returns {{verdict: 'green'|'warn'|'critical', reasons: string[], recommended_agents_per_wave_cap: number|null, signals: {hard: string[], soft: string[]}}}
|
|
291
|
+
*/
|
|
92
292
|
export function evaluate(snapshot, thresholds, options = {}) {
|
|
93
293
|
const reasons = [];
|
|
94
|
-
|
|
95
|
-
|
|
294
|
+
const hardSignals = [];
|
|
295
|
+
const softSignals = [];
|
|
96
296
|
|
|
97
|
-
const {
|
|
297
|
+
const {
|
|
298
|
+
cpu_load_pct,
|
|
299
|
+
cpu_load_5m_pct,
|
|
300
|
+
claude_processes_count,
|
|
301
|
+
peer_sessions_count,
|
|
302
|
+
swap_used_mb,
|
|
303
|
+
zombie_processes_count,
|
|
304
|
+
} = snapshot;
|
|
98
305
|
const {
|
|
99
306
|
'ram-free-min-gb': ramMin,
|
|
100
307
|
'ram-free-critical-gb': ramCrit,
|
|
@@ -102,142 +309,111 @@ export function evaluate(snapshot, thresholds, options = {}) {
|
|
|
102
309
|
'concurrent-sessions-warn': concWarn,
|
|
103
310
|
} = thresholds;
|
|
104
311
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const macosPressureHealthy =
|
|
118
|
-
memory_pressure_pct_free !== null &&
|
|
119
|
-
memory_pressure_pct_free !== undefined &&
|
|
120
|
-
memory_pressure_pct_free >= MACOS_HEALTHY_PRESSURE_PCT;
|
|
121
|
-
|
|
122
|
-
if (!macosPressureHealthy) {
|
|
123
|
-
if (effectiveRamGb < ramCrit) {
|
|
124
|
-
verdict = 'critical';
|
|
125
|
-
cap = 0;
|
|
126
|
-
reasons.push(`${ramLabel} ${effectiveRamGb.toFixed(1)} GB below critical threshold ${ramCrit} GB — recommend coordinator-direct (0 agents).`);
|
|
127
|
-
} else if (effectiveRamGb < ramMin) {
|
|
128
|
-
if (verdict === 'green') verdict = 'warn';
|
|
129
|
-
cap = cap === null ? 2 : Math.min(cap, 2);
|
|
130
|
-
reasons.push(`${ramLabel} ${effectiveRamGb.toFixed(1)} GB below threshold ${ramMin} GB — capping agents-per-wave at 2.`);
|
|
131
|
-
}
|
|
132
|
-
} else {
|
|
133
|
-
reasons.push(`macOS memory_pressure healthy (${memory_pressure_pct_free}% free ≥ ${MACOS_HEALTHY_PRESSURE_PCT}%) — free-RAM signal suppressed (Pages-free underreports on Darwin).`);
|
|
312
|
+
// --- Memory axis: strict signal precedence (see memorySignal docstring) ----
|
|
313
|
+
const mem = memorySignal(snapshot, ramMin, ramCrit);
|
|
314
|
+
if (mem.hard) {
|
|
315
|
+
hardSignals.push('memory');
|
|
316
|
+
reasons.push(mem.reason);
|
|
317
|
+
} else if (mem.soft) {
|
|
318
|
+
softSignals.push('memory');
|
|
319
|
+
reasons.push(mem.reason);
|
|
320
|
+
} else if (mem.reason) {
|
|
321
|
+
// Healthy-pressure note — kept because it explains why an alarming
|
|
322
|
+
// "0.x GB free" banner number did NOT drive the verdict.
|
|
323
|
+
reasons.push(mem.reason);
|
|
134
324
|
}
|
|
325
|
+
const memoryHealthy = !mem.hard && !mem.soft;
|
|
135
326
|
|
|
136
|
-
// CPU axis (#943)
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
// load.
|
|
142
|
-
// `cpu_load_5m_pct` absent/null (legacy snapshots, Windows) → legacy 1m-only.
|
|
327
|
+
// --- CPU axis (#943) ------------------------------------------------------
|
|
328
|
+
// The 1m load average systematically carries the decaying tail of the
|
|
329
|
+
// coordinator's own just-finished gate run (the caller sits right after the
|
|
330
|
+
// inter-wave Quality Gate by construction). Judge on min(1m, 5m) when the
|
|
331
|
+
// probe supplied a numeric 5m value: only-1m-high is a decaying transient,
|
|
332
|
+
// both-high is genuine sustained load. Null 5m (Windows, zero-load) → 1m only.
|
|
143
333
|
const has5mCpu = typeof cpu_load_5m_pct === 'number' && Number.isFinite(cpu_load_5m_pct);
|
|
144
334
|
const effectiveCpuPct = has5mCpu ? Math.min(cpu_load_pct, cpu_load_5m_pct) : cpu_load_pct;
|
|
145
335
|
if (effectiveCpuPct > cpuMax) {
|
|
146
|
-
|
|
147
|
-
cap = cap === null ? 2 : Math.min(cap, 2);
|
|
336
|
+
softSignals.push('cpu');
|
|
148
337
|
const detail = has5mCpu ? ` (min of 1m ${cpu_load_pct}% / 5m ${cpu_load_5m_pct}%)` : '';
|
|
149
|
-
reasons.push(`CPU load ${effectiveCpuPct}%${detail} above threshold ${cpuMax}
|
|
338
|
+
reasons.push(`CPU load ${effectiveCpuPct}%${detail} above threshold ${cpuMax}%.`);
|
|
150
339
|
} else if (has5mCpu && cpu_load_pct > cpuMax) {
|
|
151
|
-
|
|
152
|
-
// (typically the coordinator's own just-finished gate run). No cap.
|
|
153
|
-
reasons.push(`CPU 1m load ${cpu_load_pct}% above threshold ${cpuMax}% but 5m load ${cpu_load_5m_pct}% is below — decaying transient (likely the coordinator's own gate run); no cap (#943).`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (claude_processes_count !== null && claude_processes_count !== undefined && claude_processes_count >= concWarn) {
|
|
157
|
-
if (verdict === 'green') verdict = 'warn';
|
|
158
|
-
reasons.push(`${claude_processes_count} Claude processes already running (threshold: ${concWarn}) — consider sequencing this session after others finish.`);
|
|
340
|
+
reasons.push(`info: CPU 1m load ${cpu_load_pct}% above threshold ${cpuMax}% but 5m load ${cpu_load_5m_pct}% is below — decaying transient (likely the coordinator's own gate run); not counted as a signal (#943).`);
|
|
159
341
|
}
|
|
160
342
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const { zombie_processes_count } = snapshot;
|
|
167
|
-
if (zombie_processes_count !== null && zombie_processes_count !== undefined && zombie_processes_count >= 1) {
|
|
168
|
-
const claudeElevated =
|
|
169
|
-
claude_processes_count !== null &&
|
|
170
|
-
claude_processes_count !== undefined &&
|
|
171
|
-
claude_processes_count > 0;
|
|
172
|
-
if (claudeElevated) {
|
|
173
|
-
verdict = bumpVerdict(verdict, 'warn');
|
|
174
|
-
reasons.push(`${zombie_processes_count} zombie Claude/Node process(es) detected (age > ${thresholds['zombie-threshold-min'] ?? 30} min, idle CPU) with ${claude_processes_count} Claude process(es) running — consider sweeping stale sessions.`);
|
|
175
|
-
}
|
|
343
|
+
// --- Concurrency axis: registry sessions, not raw process count -----------
|
|
344
|
+
const conc = concurrencySignal(snapshot, concWarn);
|
|
345
|
+
if (conc && conc.soft) {
|
|
346
|
+
softSignals.push('concurrency');
|
|
347
|
+
reasons.push(conc.reason);
|
|
176
348
|
}
|
|
177
349
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// swap signal is treated as informational only (downgrades critical→warn).
|
|
184
|
-
// ---------------------------------------------------------------------------
|
|
185
|
-
const { swap_used_mb } = snapshot;
|
|
350
|
+
// --- Swap: only meaningful when the memory signal is NOT healthy ----------
|
|
351
|
+
// On macOS swap accumulates over a machine's uptime and is not a real-time
|
|
352
|
+
// pressure indicator, so a large cumulative figure under healthy pressure is
|
|
353
|
+
// history, not load. Observed on the reference host 2026-08-21: 6884 MB swap
|
|
354
|
+
// used with memory_pressure reporting 35% free and the machine responsive.
|
|
186
355
|
if (swap_used_mb !== null && swap_used_mb !== undefined) {
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
reasons.push(`Swap usage ${swap_used_mb} MB above critical threshold 3072 MB — recommend coordinator-direct (0 agents).`);
|
|
191
|
-
} else if (swap_used_mb > 2048 && !macosPressureHealthy) {
|
|
192
|
-
const newVerdict = bumpVerdict(verdict, 'degraded');
|
|
193
|
-
if (newVerdict !== verdict) {
|
|
194
|
-
verdict = newVerdict;
|
|
195
|
-
cap = cap === null ? 2 : Math.min(cap, 2);
|
|
196
|
-
}
|
|
197
|
-
reasons.push(`Swap usage ${swap_used_mb} MB in degraded range (2048..3072 MB) — capping agents-per-wave at 2.`);
|
|
198
|
-
} else if (swap_used_mb > 1024 && !macosPressureHealthy) {
|
|
199
|
-
const newVerdict = bumpVerdict(verdict, 'warn');
|
|
200
|
-
if (newVerdict !== verdict) {
|
|
201
|
-
verdict = newVerdict;
|
|
202
|
-
cap = cap === null ? 2 : Math.min(cap, 2);
|
|
356
|
+
if (memoryHealthy) {
|
|
357
|
+
if (swap_used_mb > SWAP_SOFT_MB) {
|
|
358
|
+
reasons.push(`info: swap usage ${swap_used_mb} MB present but the memory signal is healthy — cumulative, not live pressure; not counted as a signal.`);
|
|
203
359
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
360
|
+
} else if (swap_used_mb > SWAP_HARD_MB) {
|
|
361
|
+
hardSignals.push('swap');
|
|
362
|
+
reasons.push(`Swap usage ${swap_used_mb} MB above hard threshold ${SWAP_HARD_MB} MB while memory is under pressure.`);
|
|
363
|
+
} else if (swap_used_mb > SWAP_SOFT_MB) {
|
|
364
|
+
softSignals.push('swap');
|
|
365
|
+
reasons.push(`Swap usage ${swap_used_mb} MB above ${SWAP_SOFT_MB} MB while memory is under pressure.`);
|
|
208
366
|
}
|
|
209
367
|
}
|
|
210
368
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
369
|
+
// --- Zombies (#178) — INFORMATIONAL ONLY ----------------------------------
|
|
370
|
+
//
|
|
371
|
+
// A stale-but-idle process holds RAM without doing work, so it is worth
|
|
372
|
+
// reporting. It is NOT a capacity signal, and #1089's first live run proved
|
|
373
|
+
// why counting it as one re-opens the bug this module just closed:
|
|
374
|
+
//
|
|
375
|
+
// VERDICT: warn | cap 2 | soft: ["cpu", "zombies"]
|
|
376
|
+
//
|
|
377
|
+
// Zombies are, by their own definition, idle (CPU <= 1%) — they are not
|
|
378
|
+
// causing the CPU load they were pairing with. And they are a STANDING
|
|
379
|
+
// condition on a developer host: three measurements minutes apart on the
|
|
380
|
+
// reference machine read 6, 13 and 9. A signal that is essentially always
|
|
381
|
+
// present is not a second opinion; pairing it with any other axis silently
|
|
382
|
+
// restores the one-signal cap under a two-signal name.
|
|
383
|
+
//
|
|
384
|
+
// So it reports and never counts. This also aligns the code with the rule
|
|
385
|
+
// text it always claimed to implement — `.claude/rules/host-resources.md`
|
|
386
|
+
// HR-104: "sweeping stale sessions is housekeeping advice, not a reason to
|
|
387
|
+
// shrink a wave."
|
|
388
|
+
//
|
|
389
|
+
// Still gated on a live peer/process context so a lone leftover on an
|
|
390
|
+
// otherwise idle host stays quiet.
|
|
391
|
+
if (zombie_processes_count !== null && zombie_processes_count !== undefined && zombie_processes_count >= 1) {
|
|
392
|
+
const liveContext =
|
|
393
|
+
(peer_sessions_count !== null && peer_sessions_count !== undefined && peer_sessions_count > 0) ||
|
|
394
|
+
(claude_processes_count !== null && claude_processes_count !== undefined && claude_processes_count > 0);
|
|
395
|
+
if (liveContext) {
|
|
396
|
+
reasons.push(`info: ${zombie_processes_count} zombie Claude/Node process(es) detected (age > ${thresholds['zombie-threshold-min'] ?? 30} min, idle CPU) — consider sweeping stale sessions; not counted as a capacity signal.`);
|
|
234
397
|
}
|
|
235
398
|
}
|
|
236
399
|
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
400
|
+
// --- Two-signal rule ------------------------------------------------------
|
|
401
|
+
// One hard signal → critical. Two independent soft signals → warn. A single
|
|
402
|
+
// soft signal is reported and caps nothing: that is the whole point of #1089,
|
|
403
|
+
// because each axis alone is noisy enough that their union fired on 99.0% of
|
|
404
|
+
// measured session starts.
|
|
405
|
+
let verdict = 'green';
|
|
406
|
+
let cap = null;
|
|
407
|
+
if (hardSignals.length > 0) {
|
|
408
|
+
verdict = 'critical';
|
|
240
409
|
cap = 0;
|
|
410
|
+
reasons.push(`Hard signal (${hardSignals.join(' + ')}) — recommend coordinator-direct (0 agents).`);
|
|
411
|
+
} else if (softSignals.length >= 2) {
|
|
412
|
+
verdict = 'warn';
|
|
413
|
+
cap = 2;
|
|
414
|
+
reasons.push(`Two independent signals agree (${softSignals.join(' + ')}) — capping agents-per-wave at 2.`);
|
|
415
|
+
} else if (softSignals.length === 1) {
|
|
416
|
+
reasons.push(`info: one signal (${softSignals[0]}) is elevated but no second signal agrees — reporting only, no cap (two-signal rule, #1089).`);
|
|
241
417
|
}
|
|
242
418
|
|
|
243
419
|
// ---------------------------------------------------------------------------
|
|
@@ -252,5 +428,10 @@ export function evaluate(snapshot, thresholds, options = {}) {
|
|
|
252
428
|
cap = cap === null ? resolvedApwCap : Math.min(cap, resolvedApwCap);
|
|
253
429
|
}
|
|
254
430
|
|
|
255
|
-
return {
|
|
431
|
+
return {
|
|
432
|
+
verdict,
|
|
433
|
+
reasons,
|
|
434
|
+
recommended_agents_per_wave_cap: cap,
|
|
435
|
+
signals: { hard: hardSignals, soft: softSignals },
|
|
436
|
+
};
|
|
256
437
|
}
|
|
@@ -298,3 +298,38 @@ export async function ramAvailableGb() {
|
|
|
298
298
|
const output = await runCommand('vm_stat', []);
|
|
299
299
|
return parseVmStatAvailableGb(output);
|
|
300
300
|
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Count LIVE PEER SESSIONS on this host from the session registry (#1089).
|
|
304
|
+
*
|
|
305
|
+
* This is the signal `concurrent-sessions-warn` was always named for. Until
|
|
306
|
+
* #1089 the threshold was compared against `claude_processes_count` instead —
|
|
307
|
+
* a measured 6x unit error (median processes:sessions = 6.0 over 1461 paired
|
|
308
|
+
* samples), which is why a threshold of 5 fired on 93.6% of session starts
|
|
309
|
+
* while the same threshold against the real count fires on 4.2%.
|
|
310
|
+
*
|
|
311
|
+
* Semantics mirror `detectPeers()` exactly: EXCLUDES the calling session and
|
|
312
|
+
* counts only entries whose `last_heartbeat` is within `freshnessMin` minutes.
|
|
313
|
+
* That matches the `peer_count` field already recorded on
|
|
314
|
+
* `orchestrator.session.started` events, so historical telemetry and live
|
|
315
|
+
* probes are denominated identically.
|
|
316
|
+
*
|
|
317
|
+
* Best-effort: returns null when the registry is unreadable (sandboxed test
|
|
318
|
+
* environments, a foreign `SO_SESSION_REGISTRY_DIR`, first run before any
|
|
319
|
+
* registration). Consumers treat null as "unknown" and fall back to the
|
|
320
|
+
* rescaled process count.
|
|
321
|
+
*
|
|
322
|
+
* @param {object} [opts]
|
|
323
|
+
* @param {string|null} [opts.sessionId] — own session id, excluded from the count
|
|
324
|
+
* @param {number} [opts.freshnessMin=15]
|
|
325
|
+
* @returns {Promise<number|null>}
|
|
326
|
+
*/
|
|
327
|
+
export async function peerSessionsCount({ sessionId = null, freshnessMin = 15 } = {}) {
|
|
328
|
+
try {
|
|
329
|
+
const { detectPeers } = await import('../session-registry.mjs');
|
|
330
|
+
const peers = await detectPeers({ sessionId, freshnessMin });
|
|
331
|
+
return Array.isArray(peers) ? peers.length : null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|