harnery 0.38.0 → 0.40.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/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +32 -8
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +97 -6
- package/dist/commands/backup.d.ts +16 -0
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +239 -33
- package/dist/commands/browse.d.ts +8 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +466 -231
- package/dist/commands/diagnostics.d.ts.map +1 -1
- package/dist/commands/diagnostics.js +6 -3
- package/dist/commands/ledger-v3.d.ts.map +1 -1
- package/dist/commands/ledger-v3.js +20 -4
- package/dist/commands/qa-run.d.ts.map +1 -1
- package/dist/commands/qa-run.js +26 -3
- package/dist/commands/qa-status.d.ts.map +1 -1
- package/dist/commands/qa-status.js +7 -2
- package/dist/commands/resources.d.ts.map +1 -1
- package/dist/commands/resources.js +23 -3
- package/dist/commands/review-pack.d.ts.map +1 -1
- package/dist/commands/review-pack.js +98 -23
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -2
- package/dist/core/adapters/profiles.d.ts +1 -1
- package/dist/core/agents/canonical-emit.d.ts +3 -2
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +2 -0
- package/dist/core/agents/cli.js +13 -6
- package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
- package/dist/core/agents/live-authority-v3.js +5 -3
- package/dist/core/agents/live-lifecycle-v3.d.ts +2 -2
- package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.d.ts +7 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +63 -8
- package/dist/core/agents/session-finalizer-v3.d.ts +2 -2
- package/dist/core/agents/session-finalizer-v3.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.d.ts +3 -3
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +2 -1
- package/dist/core/agents/state/names.d.ts +35 -2
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +80 -5
- package/dist/core/agents/state/stale-sweep.d.ts +2 -1
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +23 -6
- package/dist/core/agents/turn-elapsed.d.ts +51 -0
- package/dist/core/agents/turn-elapsed.d.ts.map +1 -0
- package/dist/core/agents/turn-elapsed.js +91 -0
- package/dist/core/artifacts/activity.d.ts +14 -0
- package/dist/core/artifacts/activity.d.ts.map +1 -0
- package/dist/core/artifacts/activity.js +52 -0
- package/dist/core/artifacts/delivery-card.d.ts +39 -0
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -0
- package/dist/core/artifacts/delivery-card.js +282 -0
- package/dist/core/artifacts/index.d.ts +42 -7
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +288 -76
- package/dist/core/artifacts/mutation-lock.d.ts +2 -0
- package/dist/core/artifacts/mutation-lock.d.ts.map +1 -0
- package/dist/core/artifacts/mutation-lock.js +91 -0
- package/dist/core/backup/host-snapshot.d.ts +18 -0
- package/dist/core/backup/host-snapshot.d.ts.map +1 -0
- package/dist/core/backup/host-snapshot.js +64 -0
- package/dist/core/config.d.ts +20 -5
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +44 -10
- package/dist/core/diagnostics/advice.d.ts +38 -3
- package/dist/core/diagnostics/advice.d.ts.map +1 -1
- package/dist/core/diagnostics/advice.js +103 -99
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +111 -47
- package/dist/core/diagnostics/comparison.d.ts.map +1 -1
- package/dist/core/diagnostics/comparison.js +3 -2
- package/dist/core/diagnostics/contract.d.ts +15 -31
- package/dist/core/diagnostics/contract.d.ts.map +1 -1
- package/dist/core/diagnostics/contract.js +2 -5
- package/dist/core/diagnostics/index.d.ts +2 -0
- package/dist/core/diagnostics/index.d.ts.map +1 -1
- package/dist/core/diagnostics/index.js +2 -0
- package/dist/core/diagnostics/pressure-contract.d.ts +260 -0
- package/dist/core/diagnostics/pressure-contract.d.ts.map +1 -0
- package/dist/core/diagnostics/pressure-contract.js +70 -0
- package/dist/core/diagnostics/pressure.d.ts +25 -0
- package/dist/core/diagnostics/pressure.d.ts.map +1 -0
- package/dist/core/diagnostics/pressure.js +1138 -0
- package/dist/core/diagnostics/replay.d.ts.map +1 -1
- package/dist/core/diagnostics/replay.js +90 -10
- package/dist/core/events/v3/adapter-id.d.ts +6 -0
- package/dist/core/events/v3/adapter-id.d.ts.map +1 -0
- package/dist/core/events/v3/adapter-id.js +7 -0
- package/dist/core/events/v3/archive-retention.d.ts.map +1 -1
- package/dist/core/events/v3/archive-retention.js +3 -2
- package/dist/core/events/v3/bootstrap.d.ts +41 -4
- package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
- package/dist/core/events/v3/bootstrap.js +120 -17
- package/dist/core/events/v3/capabilities.d.ts +16 -9
- package/dist/core/events/v3/capabilities.d.ts.map +1 -1
- package/dist/core/events/v3/capabilities.js +37 -2
- package/dist/core/events/v3/capability-drift.d.ts +2 -2
- package/dist/core/events/v3/capability-drift.d.ts.map +1 -1
- package/dist/core/events/v3/control-witness.d.ts +48 -5
- package/dist/core/events/v3/control-witness.d.ts.map +1 -1
- package/dist/core/events/v3/control-witness.js +91 -36
- package/dist/core/events/v3/control.d.ts.map +1 -1
- package/dist/core/events/v3/control.js +22 -2
- package/dist/core/events/v3/index.d.ts +2 -1
- package/dist/core/events/v3/index.d.ts.map +1 -1
- package/dist/core/events/v3/index.js +2 -1
- package/dist/core/events/v3/live-feed.d.ts +7 -1
- package/dist/core/events/v3/live-feed.d.ts.map +1 -1
- package/dist/core/events/v3/live-feed.js +21 -3
- package/dist/core/events/v3/live-routing.d.ts.map +1 -1
- package/dist/core/events/v3/live-routing.js +10 -2
- package/dist/core/events/v3/producers/command-recorder.d.ts +2 -2
- package/dist/core/events/v3/producers/command-recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/command-recorder.js +2 -1
- package/dist/core/events/v3/producers/coordination-recorder.d.ts +2 -2
- package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/coordination-recorder.js +2 -1
- package/dist/core/events/v3/producers/hook-base.d.ts +2 -2
- package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -1
- package/dist/core/events/v3/producers/hook-base.js +15 -5
- package/dist/core/events/v3/producers/hook.d.ts.map +1 -1
- package/dist/core/events/v3/producers/hook.js +17 -4
- package/dist/core/events/v3/producers/intake.d.ts +4 -4
- package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
- package/dist/core/events/v3/producers/intake.js +3 -2
- package/dist/core/events/v3/producers/producer-state-observer.d.ts.map +1 -1
- package/dist/core/events/v3/producers/producer-state-observer.js +2 -1
- package/dist/core/events/v3/producers/recorder.d.ts +16 -5
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +31 -9
- package/dist/core/events/v3/reader.d.ts.map +1 -1
- package/dist/core/events/v3/reader.js +10 -13
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +3 -2
- package/dist/core/events/v3/runtime-telemetry-capabilities.d.ts +2 -2
- package/dist/core/events/v3/runtime-telemetry-capabilities.d.ts.map +1 -1
- package/dist/core/events/v3/turn-telemetry.d.ts +2 -2
- package/dist/core/events/v3/turn-telemetry.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.d.ts +16 -14
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +36 -38
- package/dist/core/hooks/backup-schedule.d.ts +34 -0
- package/dist/core/hooks/backup-schedule.d.ts.map +1 -0
- package/dist/core/hooks/backup-schedule.js +114 -0
- package/dist/core/hooks/cli.js +61 -18
- package/dist/core/hooks/codex-wsl-bridge.d.ts +2 -0
- package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -1
- package/dist/core/hooks/codex-wsl-bridge.js +18 -4
- package/dist/core/hooks/index.d.ts +1 -0
- package/dist/core/hooks/index.d.ts.map +1 -1
- package/dist/core/hooks/index.js +1 -0
- package/dist/core/hooks/prompt-context/contract.d.ts +61 -0
- package/dist/core/hooks/prompt-context/contract.d.ts.map +1 -0
- package/dist/core/hooks/prompt-context/contract.js +102 -0
- package/dist/core/hooks/prompt-context/index.d.ts +5 -0
- package/dist/core/hooks/prompt-context/index.d.ts.map +1 -0
- package/dist/core/hooks/prompt-context/index.js +2 -0
- package/dist/core/hooks/prompt-context/runner.d.ts +24 -0
- package/dist/core/hooks/prompt-context/runner.d.ts.map +1 -0
- package/dist/core/hooks/prompt-context/runner.js +249 -0
- package/dist/core/qa-artifacts.d.ts +3 -0
- package/dist/core/qa-artifacts.d.ts.map +1 -1
- package/dist/core/qa-artifacts.js +25 -3
- package/dist/core/resources/contract.d.ts +69 -3
- package/dist/core/resources/contract.d.ts.map +1 -1
- package/dist/core/resources/contract.js +1 -1
- package/dist/core/resources/index.d.ts +1 -0
- package/dist/core/resources/index.d.ts.map +1 -1
- package/dist/core/resources/index.js +1 -0
- package/dist/core/resources/native.d.ts +36 -0
- package/dist/core/resources/native.d.ts.map +1 -0
- package/dist/core/resources/native.js +418 -0
- package/dist/core/resources/sampler.d.ts +2 -0
- package/dist/core/resources/sampler.d.ts.map +1 -1
- package/dist/core/resources/sampler.js +31 -1
- package/dist/core/resources/status.d.ts +61 -0
- package/dist/core/resources/status.d.ts.map +1 -0
- package/dist/core/resources/status.js +536 -0
- package/dist/core/resources/system.d.ts +27 -0
- package/dist/core/resources/system.d.ts.map +1 -0
- package/dist/core/resources/system.js +439 -0
- package/dist/core/semantic/contract.d.ts +13 -13
- package/dist/core/semantic/service.d.ts +2 -0
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +27 -4
- package/dist/core/storage/builtins.js +41 -0
- package/dist/core/supervisor/contract.d.ts +41 -3
- package/dist/core/supervisor/contract.d.ts.map +1 -1
- package/dist/core/supervisor/contract.js +3 -2
- package/dist/core/supervisor/findings.d.ts +3 -0
- package/dist/core/supervisor/findings.d.ts.map +1 -1
- package/dist/core/supervisor/findings.js +88 -0
- package/dist/core/supervisor/history.d.ts.map +1 -1
- package/dist/core/supervisor/history.js +14 -0
- package/dist/core/supervisor/ledger-state.d.ts +55 -0
- package/dist/core/supervisor/ledger-state.d.ts.map +1 -0
- package/dist/core/supervisor/ledger-state.js +0 -0
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/supervisor/service.js +120 -7
- package/dist/core/supervisor/storage.d.ts +3 -1
- package/dist/core/supervisor/storage.d.ts.map +1 -1
- package/dist/core/supervisor/storage.js +4 -0
- package/dist/core/workflow/admission.d.ts +3 -0
- package/dist/core/workflow/admission.d.ts.map +1 -1
- package/dist/core/workflow/admission.js +28 -16
- package/dist/core/workflow/engine.js +2 -2
- package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
- package/dist/core/workflow/live-session-v3.js +6 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +4 -3
- package/dist/lib/browser/capture-fidelity.d.ts +7 -0
- package/dist/lib/browser/capture-fidelity.d.ts.map +1 -1
- package/dist/lib/browser/capture-fidelity.js +17 -0
- package/dist/lib/browser/client.d.ts +9 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +85 -37
- package/dist/lib/browser/index.d.ts +3 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +2 -1
- package/dist/lib/browser/page-review-budget.d.ts +13 -0
- package/dist/lib/browser/page-review-budget.d.ts.map +1 -0
- package/dist/lib/browser/page-review-budget.js +251 -0
- package/dist/lib/browser/page-review-contracts.d.ts +127 -0
- package/dist/lib/browser/page-review-contracts.d.ts.map +1 -0
- package/dist/lib/browser/page-review-contracts.js +5 -0
- package/dist/lib/browser/page-review-evidence.d.ts +48 -0
- package/dist/lib/browser/page-review-evidence.d.ts.map +1 -0
- package/dist/lib/browser/page-review-evidence.js +340 -0
- package/dist/lib/browser/page-review-judge.d.ts +1 -1
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.js +46 -38
- package/dist/lib/browser/page-review-pack.d.ts +44 -22
- package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
- package/dist/lib/browser/page-review-pack.js +116 -36
- package/dist/lib/browser/page-review-source.d.ts +12 -0
- package/dist/lib/browser/page-review-source.d.ts.map +1 -0
- package/dist/lib/browser/page-review-source.js +22 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +35 -9
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run-contracts.js +48 -14
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +215 -68
- package/dist/lib/browser/qa-snapshot.d.ts +6 -0
- package/dist/lib/browser/qa-snapshot.d.ts.map +1 -1
- package/dist/lib/browser/qa-snapshot.js +259 -48
- package/dist/lib/browser/review-capture-transaction.d.ts +13 -0
- package/dist/lib/browser/review-capture-transaction.d.ts.map +1 -0
- package/dist/lib/browser/review-capture-transaction.js +79 -0
- package/dist/lib/browser/standalone-html.d.ts.map +1 -1
- package/dist/lib/browser/standalone-html.js +4 -2
- package/dist/lib/browser/thumbnail-association.d.ts +34 -0
- package/dist/lib/browser/thumbnail-association.d.ts.map +1 -0
- package/dist/lib/browser/thumbnail-association.js +158 -0
- package/dist/lib/browser/tiling.d.ts +30 -0
- package/dist/lib/browser/tiling.d.ts.map +1 -1
- package/dist/lib/browser/tiling.js +38 -0
- package/dist/lib/docs-metadata-managed.d.ts.map +1 -1
- package/dist/lib/docs-metadata-managed.js +4 -1
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +7 -1
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +1 -0
- package/dist/lib/local-file-url.d.ts +12 -0
- package/dist/lib/local-file-url.d.ts.map +1 -1
- package/dist/lib/local-file-url.js +16 -2
- package/dist/lib/tunnel/state.d.ts +19 -4
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +53 -13
- package/package.json +7 -4
- package/schemas/config.schema.json +69 -2
- package/src/commands/agents.ts +42 -9
- package/src/commands/artifacts.ts +130 -6
- package/src/commands/backup.ts +287 -34
- package/src/commands/browse.ts +564 -267
- package/src/commands/diagnostics.ts +6 -3
- package/src/commands/ledger-v3.ts +20 -3
- package/src/commands/qa-run.ts +37 -3
- package/src/commands/qa-status.ts +11 -2
- package/src/commands/resources.ts +23 -3
- package/src/commands/review-pack.ts +165 -62
- package/src/commands/tunnel.ts +2 -1
- package/src/core/agents/canonical-emit.ts +4 -2
- package/src/core/agents/cli.ts +14 -6
- package/src/core/agents/live-authority-v3.ts +7 -3
- package/src/core/agents/live-lifecycle-v3.ts +2 -2
- package/src/core/agents/render/prompt-context.ts +68 -14
- package/src/core/agents/session-finalizer-v3.ts +2 -2
- package/src/core/agents/state/live-coordination-view.ts +4 -4
- package/src/core/agents/state/names.ts +92 -7
- package/src/core/agents/state/stale-sweep.ts +22 -8
- package/src/core/agents/turn-elapsed.ts +135 -0
- package/src/core/artifacts/activity.ts +71 -0
- package/src/core/artifacts/delivery-card.ts +375 -0
- package/src/core/artifacts/index.ts +385 -91
- package/src/core/artifacts/mutation-lock.ts +106 -0
- package/src/core/backup/host-snapshot.ts +76 -0
- package/src/core/config.ts +66 -13
- package/src/core/diagnostics/advice.ts +148 -120
- package/src/core/diagnostics/bundle.ts +138 -52
- package/src/core/diagnostics/comparison.ts +6 -2
- package/src/core/diagnostics/contract.ts +39 -42
- package/src/core/diagnostics/index.ts +2 -0
- package/src/core/diagnostics/pressure-contract.ts +294 -0
- package/src/core/diagnostics/pressure.ts +1361 -0
- package/src/core/diagnostics/replay.ts +116 -11
- package/src/core/events/v3/adapter-id.ts +12 -0
- package/src/core/events/v3/archive-retention.ts +3 -6
- package/src/core/events/v3/bootstrap.ts +163 -16
- package/src/core/events/v3/capabilities.ts +75 -25
- package/src/core/events/v3/capability-drift.ts +2 -2
- package/src/core/events/v3/control-witness.ts +172 -41
- package/src/core/events/v3/control.ts +34 -2
- package/src/core/events/v3/index.ts +3 -0
- package/src/core/events/v3/live-feed.ts +23 -5
- package/src/core/events/v3/live-routing.ts +14 -2
- package/src/core/events/v3/producers/command-recorder.ts +4 -4
- package/src/core/events/v3/producers/coordination-recorder.ts +4 -4
- package/src/core/events/v3/producers/hook-base.ts +21 -16
- package/src/core/events/v3/producers/hook.ts +21 -4
- package/src/core/events/v3/producers/intake.ts +6 -6
- package/src/core/events/v3/producers/producer-state-observer.ts +2 -1
- package/src/core/events/v3/producers/recorder.ts +50 -21
- package/src/core/events/v3/reader.ts +14 -13
- package/src/core/events/v3/runtime-build.ts +3 -2
- package/src/core/events/v3/runtime-telemetry-capabilities.ts +3 -3
- package/src/core/events/v3/turn-telemetry.ts +4 -4
- package/src/core/hooks/adapter/output.ts +50 -36
- package/src/core/hooks/backup-schedule.ts +147 -0
- package/src/core/hooks/cli.ts +65 -14
- package/src/core/hooks/codex-wsl-bridge.ts +21 -5
- package/src/core/hooks/index.ts +1 -0
- package/src/core/hooks/prompt-context/contract.ts +177 -0
- package/src/core/hooks/prompt-context/index.ts +22 -0
- package/src/core/hooks/prompt-context/runner.ts +303 -0
- package/src/core/qa-artifacts.ts +29 -3
- package/src/core/resources/contract.ts +76 -3
- package/src/core/resources/index.ts +1 -0
- package/src/core/resources/native.ts +481 -0
- package/src/core/resources/sampler.ts +46 -1
- package/src/core/resources/status.ts +717 -0
- package/src/core/resources/system.ts +530 -0
- package/src/core/semantic/service.ts +37 -3
- package/src/core/storage/builtins.ts +41 -0
- package/src/core/supervisor/contract.ts +45 -3
- package/src/core/supervisor/findings.ts +159 -0
- package/src/core/supervisor/history.ts +15 -0
- package/src/core/supervisor/ledger-state.ts +0 -0
- package/src/core/supervisor/service.ts +133 -5
- package/src/core/supervisor/storage.ts +7 -0
- package/src/core/workflow/admission.ts +33 -17
- package/src/core/workflow/engine.ts +2 -2
- package/src/core/workflow/live-session-v3.ts +6 -8
- package/src/core/workflow/proof.ts +4 -3
- package/src/lib/browser/capture-fidelity.ts +23 -0
- package/src/lib/browser/client.ts +96 -44
- package/src/lib/browser/index.ts +15 -1
- package/src/lib/browser/page-review-budget.ts +309 -0
- package/src/lib/browser/page-review-contracts.ts +129 -0
- package/src/lib/browser/page-review-evidence.ts +465 -0
- package/src/lib/browser/page-review-judge.ts +56 -40
- package/src/lib/browser/page-review-pack.ts +181 -43
- package/src/lib/browser/page-review-source.ts +30 -0
- package/src/lib/browser/qa-run-contracts.ts +62 -18
- package/src/lib/browser/qa-run.ts +259 -71
- package/src/lib/browser/qa-snapshot.ts +289 -48
- package/src/lib/browser/review-capture-transaction.ts +106 -0
- package/src/lib/browser/standalone-html.ts +4 -2
- package/src/lib/browser/thumbnail-association.ts +190 -0
- package/src/lib/browser/tiling.ts +63 -0
- package/src/lib/docs-metadata-managed.ts +4 -1
- package/src/lib/identities/assume.ts +12 -4
- package/src/lib/instructions/templates.ts +1 -0
- package/src/lib/local-file-url.ts +17 -2
- package/src/lib/tunnel/state.ts +70 -13
|
@@ -0,0 +1,1361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic pressure assessment.
|
|
3
|
+
*
|
|
4
|
+
* Two rules shape everything below. State is derived only from contention
|
|
5
|
+
* dimensions the kernel measures, so a large process or a heavy hook can never
|
|
6
|
+
* make the machine look critical on its own; those findings are carried as
|
|
7
|
+
* contributors instead. And a dimension the platform does not expose is
|
|
8
|
+
* reported as unavailable rather than read as healthy, so an unavailable
|
|
9
|
+
* measurement can never pull a known critical signal down to normal.
|
|
10
|
+
*
|
|
11
|
+
* The module is pure. It reads no clock, no filesystem, and no findings store,
|
|
12
|
+
* and it imports only the frozen contract. `now_ms`, `observer_generation`, and
|
|
13
|
+
* `prior` come from the caller, which is what makes a frozen bundle replayable.
|
|
14
|
+
*
|
|
15
|
+
* Every numeric threshold is read from `PRESSURE_POLICY`. The only literals
|
|
16
|
+
* here are unit conversions used to phrase a summary in readable units.
|
|
17
|
+
*
|
|
18
|
+
* Exit is a dwell, not a second threshold. A sample counts as clear when no
|
|
19
|
+
* dimension sits at or above its entry threshold, and a state leaves after the
|
|
20
|
+
* policy's run of consecutive clear samples. A reading below the entry bar is
|
|
21
|
+
* normal by definition, so it can never hold a state open on its own.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ResourceSnapshot } from "../resources/contract.ts";
|
|
25
|
+
import {
|
|
26
|
+
type AssessPressure,
|
|
27
|
+
type AssessPressureInput,
|
|
28
|
+
PRESSURE_ASSESSMENT_SCHEMA_VERSION,
|
|
29
|
+
PRESSURE_POLICY,
|
|
30
|
+
type PressureAssessment,
|
|
31
|
+
type PressureContributor,
|
|
32
|
+
type PressureDimension,
|
|
33
|
+
type PressureEvidenceDimension,
|
|
34
|
+
type PressureFindingInput,
|
|
35
|
+
type PressureHistorySample,
|
|
36
|
+
type PressureHysteresisState,
|
|
37
|
+
type PressureLimitingResource,
|
|
38
|
+
type PressureReason,
|
|
39
|
+
type PressureRecommendedAction,
|
|
40
|
+
type PressureScope,
|
|
41
|
+
type PressureState,
|
|
42
|
+
type PressureTrend,
|
|
43
|
+
type PressureWorkloadClass,
|
|
44
|
+
type PressureWorkloadGuidance,
|
|
45
|
+
} from "./pressure-contract.ts";
|
|
46
|
+
|
|
47
|
+
/** Unit conversions for readable prose, not thresholds. */
|
|
48
|
+
const BYTES_PER_MIB = 1_024 * 1_024;
|
|
49
|
+
const BYTES_PER_GIB = 1_024 * BYTES_PER_MIB;
|
|
50
|
+
|
|
51
|
+
/** Report order for evidence, and the tie-break order for the limiting resource. */
|
|
52
|
+
const DIMENSION_ORDER: readonly PressureDimension[] = [
|
|
53
|
+
"oom_kills",
|
|
54
|
+
"memory_stall",
|
|
55
|
+
"swap_activity",
|
|
56
|
+
"direct_reclaim",
|
|
57
|
+
"memory_available",
|
|
58
|
+
"io_stall",
|
|
59
|
+
"cpu_stall",
|
|
60
|
+
"disk_available",
|
|
61
|
+
"host_memory",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const WORKLOAD_CLASSES: readonly PressureWorkloadClass[] = [
|
|
65
|
+
"lightweight",
|
|
66
|
+
"cpu-heavy",
|
|
67
|
+
"memory-heavy",
|
|
68
|
+
"storage-heavy",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const STATE_RANK: Readonly<Record<PressureState, number>> = {
|
|
72
|
+
unknown: -1,
|
|
73
|
+
normal: 0,
|
|
74
|
+
elevated: 1,
|
|
75
|
+
critical: 2,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const DIMENSION_RESOURCE: Readonly<Record<PressureDimension, PressureLimitingResource>> = {
|
|
79
|
+
memory_stall: "memory",
|
|
80
|
+
io_stall: "io",
|
|
81
|
+
cpu_stall: "cpu",
|
|
82
|
+
oom_kills: "memory",
|
|
83
|
+
swap_activity: "memory",
|
|
84
|
+
direct_reclaim: "memory",
|
|
85
|
+
memory_available: "memory",
|
|
86
|
+
disk_available: "storage",
|
|
87
|
+
host_memory: "memory",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The memory-family dimensions direct reclaim needs a companion signal from. */
|
|
91
|
+
const MEMORY_COMPANION_DIMENSIONS: readonly PressureDimension[] = [
|
|
92
|
+
"memory_stall",
|
|
93
|
+
"swap_activity",
|
|
94
|
+
"memory_available",
|
|
95
|
+
"oom_kills",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
interface StallReading {
|
|
99
|
+
avg10: number;
|
|
100
|
+
avg60: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface CapacityReading {
|
|
104
|
+
available_percent: number;
|
|
105
|
+
swap_used_percent: number | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface DiskReading {
|
|
109
|
+
path: string;
|
|
110
|
+
available_bytes: number;
|
|
111
|
+
used_percent: number | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Readings {
|
|
115
|
+
memory_stall: StallReading | null;
|
|
116
|
+
io_stall: StallReading | null;
|
|
117
|
+
cpu_stall: number | null;
|
|
118
|
+
oom_total_kills: number | null;
|
|
119
|
+
swap_out_bytes_per_second: number | null;
|
|
120
|
+
direct_reclaim_pages_per_second: number | null;
|
|
121
|
+
memory_available: CapacityReading | null;
|
|
122
|
+
disk_available: DiskReading | null;
|
|
123
|
+
host_memory: CapacityReading | null;
|
|
124
|
+
unavailable_reasons: Partial<Record<PressureDimension, string>>;
|
|
125
|
+
/** `host_memory` is only applicable when the snapshot carries a host block. */
|
|
126
|
+
host_applicable: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const assessPressure: AssessPressure = (input) => {
|
|
130
|
+
const policy = PRESSURE_POLICY;
|
|
131
|
+
const prior = carryPrior(input.prior, input.observer_generation);
|
|
132
|
+
const snapshot = input.snapshot;
|
|
133
|
+
const sampleAgeMs = snapshot ? sampleAge(snapshot, input.now_ms) : null;
|
|
134
|
+
if (!snapshot) {
|
|
135
|
+
return unknownAssessment(input, prior, sampleAgeMs, {
|
|
136
|
+
code: "evidence_unavailable",
|
|
137
|
+
summary: `No resource snapshot is available${input.snapshot_reason ? `, because ${input.snapshot_reason}` : ""}, so no contention dimension can be read.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const staleReason = stalenessReason(sampleAgeMs);
|
|
141
|
+
if (staleReason) return unknownAssessment(input, prior, sampleAgeMs, staleReason);
|
|
142
|
+
|
|
143
|
+
const readings = readSnapshot(snapshot, input.now_ms);
|
|
144
|
+
// A snapshot that measures nothing is a gap, never a healthy machine.
|
|
145
|
+
if (
|
|
146
|
+
applicableDimensions(readings.host_applicable).every(
|
|
147
|
+
(dimension) => measuredValue(readings, dimension) === null,
|
|
148
|
+
)
|
|
149
|
+
) {
|
|
150
|
+
return unknownAssessment(input, prior, sampleAgeMs, {
|
|
151
|
+
code: "evidence_unavailable",
|
|
152
|
+
summary: `The ${snapshot.platform} snapshot exposes no contention dimension, so the machine state cannot be read from it.`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const hot = hotDimensions(readings);
|
|
156
|
+
const streaks = advanceStreaks(prior.dimension_streaks, hot, readings);
|
|
157
|
+
|
|
158
|
+
const oom = evaluateOom(readings.oom_total_kills, prior, input.now_ms);
|
|
159
|
+
const reasons: PressureReason[] = [];
|
|
160
|
+
|
|
161
|
+
if (readings.memory_stall) {
|
|
162
|
+
const stall = readings.memory_stall;
|
|
163
|
+
const count = streaks.memory_stall ?? 0;
|
|
164
|
+
if (stall.avg10 >= policy.memory_stall.critical_avg10) {
|
|
165
|
+
reasons.push({
|
|
166
|
+
code: "memory_full_stall_critical",
|
|
167
|
+
dimension: "memory_stall",
|
|
168
|
+
summary: `All non-idle work stalled on memory for ${percent(stall.avg10)} of the last ten seconds, at or above the critical threshold of ${percent(policy.memory_stall.critical_avg10)}.`,
|
|
169
|
+
observed_value: stall.avg10,
|
|
170
|
+
threshold_value: policy.memory_stall.critical_avg10,
|
|
171
|
+
unit: "percent",
|
|
172
|
+
sample_count: count,
|
|
173
|
+
contributes_to: "critical",
|
|
174
|
+
});
|
|
175
|
+
} else if (
|
|
176
|
+
stall.avg10 >= policy.memory_stall.elevated_avg10 &&
|
|
177
|
+
stall.avg10 > stall.avg60 &&
|
|
178
|
+
count >= policy.memory_stall.elevated_samples
|
|
179
|
+
) {
|
|
180
|
+
reasons.push({
|
|
181
|
+
code: "memory_full_stall_elevated",
|
|
182
|
+
dimension: "memory_stall",
|
|
183
|
+
summary: `Memory stalls are rising across ${samples(count)}, now ${percent(stall.avg10)} over ten seconds against ${percent(stall.avg60)} over a minute, at or above the elevated threshold of ${percent(policy.memory_stall.elevated_avg10)}.`,
|
|
184
|
+
observed_value: stall.avg10,
|
|
185
|
+
threshold_value: policy.memory_stall.elevated_avg10,
|
|
186
|
+
unit: "percent",
|
|
187
|
+
sample_count: count,
|
|
188
|
+
contributes_to: "elevated",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (readings.io_stall) {
|
|
194
|
+
const stall = readings.io_stall;
|
|
195
|
+
const count = streaks.io_stall ?? 0;
|
|
196
|
+
if (stall.avg10 >= policy.io_stall.critical_avg10) {
|
|
197
|
+
reasons.push({
|
|
198
|
+
code: "io_full_stall_critical",
|
|
199
|
+
dimension: "io_stall",
|
|
200
|
+
summary: `All non-idle work stalled on input and output for ${percent(stall.avg10)} of the last ten seconds, at or above the critical threshold of ${percent(policy.io_stall.critical_avg10)}.`,
|
|
201
|
+
observed_value: stall.avg10,
|
|
202
|
+
threshold_value: policy.io_stall.critical_avg10,
|
|
203
|
+
unit: "percent",
|
|
204
|
+
sample_count: count,
|
|
205
|
+
contributes_to: "critical",
|
|
206
|
+
});
|
|
207
|
+
} else if (
|
|
208
|
+
stall.avg10 >= policy.io_stall.elevated_avg10 &&
|
|
209
|
+
stall.avg10 > stall.avg60 &&
|
|
210
|
+
count >= policy.io_stall.elevated_samples
|
|
211
|
+
) {
|
|
212
|
+
reasons.push({
|
|
213
|
+
code: "io_full_stall_elevated",
|
|
214
|
+
dimension: "io_stall",
|
|
215
|
+
summary: `Input and output stalls are rising across ${samples(count)}, now ${percent(stall.avg10)} over ten seconds against ${percent(stall.avg60)} over a minute, at or above the elevated threshold of ${percent(policy.io_stall.elevated_avg10)}.`,
|
|
216
|
+
observed_value: stall.avg10,
|
|
217
|
+
threshold_value: policy.io_stall.elevated_avg10,
|
|
218
|
+
unit: "percent",
|
|
219
|
+
sample_count: count,
|
|
220
|
+
contributes_to: "elevated",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (oom.reason) reasons.push(oom.reason);
|
|
226
|
+
|
|
227
|
+
if (readings.swap_out_bytes_per_second !== null) {
|
|
228
|
+
const rate = readings.swap_out_bytes_per_second;
|
|
229
|
+
const count = streaks.swap_activity ?? 0;
|
|
230
|
+
if (
|
|
231
|
+
rate >= policy.swap_activity.critical_out_bytes_per_second &&
|
|
232
|
+
count >= policy.swap_activity.critical_samples
|
|
233
|
+
) {
|
|
234
|
+
reasons.push({
|
|
235
|
+
code: "swap_out_rate_critical",
|
|
236
|
+
dimension: "swap_activity",
|
|
237
|
+
summary: `The kernel is writing ${mib(rate)} to swap each second and has been swapping for ${samples(count)}, at or above the critical threshold of ${mib(policy.swap_activity.critical_out_bytes_per_second)} each second.`,
|
|
238
|
+
observed_value: rate,
|
|
239
|
+
threshold_value: policy.swap_activity.critical_out_bytes_per_second,
|
|
240
|
+
unit: "bytes-per-second",
|
|
241
|
+
sample_count: count,
|
|
242
|
+
contributes_to: "critical",
|
|
243
|
+
});
|
|
244
|
+
} else if (
|
|
245
|
+
rate >= policy.swap_activity.elevated_out_bytes_per_second &&
|
|
246
|
+
count >= policy.swap_activity.elevated_samples
|
|
247
|
+
) {
|
|
248
|
+
reasons.push({
|
|
249
|
+
code: "swap_out_rate_elevated",
|
|
250
|
+
dimension: "swap_activity",
|
|
251
|
+
summary: `The kernel has written to swap for ${samples(count)}, now ${mib(rate)} each second, at or above the elevated threshold of ${mib(policy.swap_activity.elevated_out_bytes_per_second)} each second.`,
|
|
252
|
+
observed_value: rate,
|
|
253
|
+
threshold_value: policy.swap_activity.elevated_out_bytes_per_second,
|
|
254
|
+
unit: "bytes-per-second",
|
|
255
|
+
sample_count: count,
|
|
256
|
+
contributes_to: "elevated",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (readings.direct_reclaim_pages_per_second !== null) {
|
|
262
|
+
const rate = readings.direct_reclaim_pages_per_second;
|
|
263
|
+
const count = streaks.direct_reclaim ?? 0;
|
|
264
|
+
if (
|
|
265
|
+
rate >= policy.direct_reclaim.elevated_pages_per_second &&
|
|
266
|
+
count >= policy.direct_reclaim.elevated_samples
|
|
267
|
+
) {
|
|
268
|
+
// Direct reclaim alone is elevated. Paired with another memory signal it
|
|
269
|
+
// is the reclaim storm the incident review calls decisive.
|
|
270
|
+
const companion = reasons.find(
|
|
271
|
+
(reason) =>
|
|
272
|
+
reason.dimension !== null && MEMORY_COMPANION_DIMENSIONS.includes(reason.dimension),
|
|
273
|
+
);
|
|
274
|
+
reasons.push(
|
|
275
|
+
companion
|
|
276
|
+
? {
|
|
277
|
+
code: "direct_reclaim_with_memory_signal",
|
|
278
|
+
dimension: "direct_reclaim",
|
|
279
|
+
summary: `Processes are reclaiming ${pages(rate)} directly each second across ${samples(count)} while another memory signal is also open, at or above the threshold of ${pages(policy.direct_reclaim.elevated_pages_per_second)} each second.`,
|
|
280
|
+
observed_value: rate,
|
|
281
|
+
threshold_value: policy.direct_reclaim.elevated_pages_per_second,
|
|
282
|
+
unit: "pages-per-second",
|
|
283
|
+
sample_count: count,
|
|
284
|
+
contributes_to: "critical",
|
|
285
|
+
}
|
|
286
|
+
: {
|
|
287
|
+
code: "direct_reclaim_elevated",
|
|
288
|
+
dimension: "direct_reclaim",
|
|
289
|
+
summary: `Processes are reclaiming ${pages(rate)} directly each second across ${samples(count)}, at or above the elevated threshold of ${pages(policy.direct_reclaim.elevated_pages_per_second)} each second.`,
|
|
290
|
+
observed_value: rate,
|
|
291
|
+
threshold_value: policy.direct_reclaim.elevated_pages_per_second,
|
|
292
|
+
unit: "pages-per-second",
|
|
293
|
+
sample_count: count,
|
|
294
|
+
contributes_to: "elevated",
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (readings.memory_available) {
|
|
301
|
+
const capacity = readings.memory_available;
|
|
302
|
+
const count = streaks.memory_available ?? 0;
|
|
303
|
+
if (
|
|
304
|
+
capacity.available_percent < policy.memory_available.critical_available_percent &&
|
|
305
|
+
capacity.swap_used_percent !== null &&
|
|
306
|
+
capacity.swap_used_percent >= policy.memory_available.critical_swap_used_percent
|
|
307
|
+
) {
|
|
308
|
+
reasons.push({
|
|
309
|
+
code: "memory_and_swap_exhausted",
|
|
310
|
+
dimension: "memory_available",
|
|
311
|
+
summary: `Only ${percent(capacity.available_percent)} of memory is available and swap is ${percent(capacity.swap_used_percent)} used, so both memory tiers are exhausted against the thresholds of ${percent(policy.memory_available.critical_available_percent)} available and ${percent(policy.memory_available.critical_swap_used_percent)} swap used.`,
|
|
312
|
+
observed_value: capacity.available_percent,
|
|
313
|
+
threshold_value: policy.memory_available.critical_available_percent,
|
|
314
|
+
unit: "percent",
|
|
315
|
+
sample_count: count,
|
|
316
|
+
contributes_to: "critical",
|
|
317
|
+
});
|
|
318
|
+
} else if (
|
|
319
|
+
capacity.available_percent < policy.memory_available.elevated_available_percent &&
|
|
320
|
+
count >= policy.memory_available.elevated_samples
|
|
321
|
+
) {
|
|
322
|
+
reasons.push({
|
|
323
|
+
code: "memory_available_low",
|
|
324
|
+
dimension: "memory_available",
|
|
325
|
+
summary: `Available memory has stayed under the elevated threshold of ${percent(policy.memory_available.elevated_available_percent)} for ${samples(count)} and is now ${percent(capacity.available_percent)}.`,
|
|
326
|
+
observed_value: capacity.available_percent,
|
|
327
|
+
threshold_value: policy.memory_available.elevated_available_percent,
|
|
328
|
+
unit: "percent",
|
|
329
|
+
sample_count: count,
|
|
330
|
+
contributes_to: "elevated",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (readings.cpu_stall !== null) {
|
|
336
|
+
const value = readings.cpu_stall;
|
|
337
|
+
const count = streaks.cpu_stall ?? 0;
|
|
338
|
+
if (
|
|
339
|
+
value >= policy.cpu_stall.elevated_some_avg60 &&
|
|
340
|
+
count >= policy.cpu_stall.elevated_samples
|
|
341
|
+
) {
|
|
342
|
+
reasons.push({
|
|
343
|
+
code: "cpu_stall_elevated",
|
|
344
|
+
dimension: "cpu_stall",
|
|
345
|
+
summary: `Work has waited on the processor for ${percent(value)} of the last minute across ${samples(count)}, at or above the elevated threshold of ${percent(policy.cpu_stall.elevated_some_avg60)}.`,
|
|
346
|
+
observed_value: value,
|
|
347
|
+
threshold_value: policy.cpu_stall.elevated_some_avg60,
|
|
348
|
+
unit: "percent",
|
|
349
|
+
sample_count: count,
|
|
350
|
+
contributes_to: "elevated",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (readings.disk_available) {
|
|
356
|
+
const disk = readings.disk_available;
|
|
357
|
+
const count = streaks.disk_available ?? 0;
|
|
358
|
+
const criticalByBytes = disk.available_bytes < policy.disk.critical_available_bytes;
|
|
359
|
+
const criticalByPercent =
|
|
360
|
+
disk.used_percent !== null && disk.used_percent >= policy.disk.critical_used_percent;
|
|
361
|
+
const elevatedByBytes = disk.available_bytes < policy.disk.elevated_available_bytes;
|
|
362
|
+
const elevatedByPercent =
|
|
363
|
+
disk.used_percent !== null && disk.used_percent >= policy.disk.elevated_used_percent;
|
|
364
|
+
if (criticalByBytes || criticalByPercent) {
|
|
365
|
+
reasons.push({
|
|
366
|
+
code: "disk_space_critical",
|
|
367
|
+
dimension: "disk_available",
|
|
368
|
+
summary: `Filesystem ${disk.path} has ${gib(disk.available_bytes)} available${disk.used_percent === null ? "" : ` and is ${percent(disk.used_percent)} used`}, past the critical thresholds of ${gib(policy.disk.critical_available_bytes)} available and ${percent(policy.disk.critical_used_percent)} used.`,
|
|
369
|
+
observed_value: disk.available_bytes,
|
|
370
|
+
threshold_value: policy.disk.critical_available_bytes,
|
|
371
|
+
unit: "bytes",
|
|
372
|
+
sample_count: count,
|
|
373
|
+
contributes_to: "critical",
|
|
374
|
+
});
|
|
375
|
+
} else if (elevatedByBytes || elevatedByPercent) {
|
|
376
|
+
reasons.push({
|
|
377
|
+
code: "disk_space_low",
|
|
378
|
+
dimension: "disk_available",
|
|
379
|
+
summary: `Filesystem ${disk.path} has ${gib(disk.available_bytes)} available${disk.used_percent === null ? "" : ` and is ${percent(disk.used_percent)} used`}, past the elevated thresholds of ${gib(policy.disk.elevated_available_bytes)} available and ${percent(policy.disk.elevated_used_percent)} used.`,
|
|
380
|
+
observed_value: disk.available_bytes,
|
|
381
|
+
threshold_value: policy.disk.elevated_available_bytes,
|
|
382
|
+
unit: "bytes",
|
|
383
|
+
sample_count: count,
|
|
384
|
+
contributes_to: "elevated",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The Windows host is assessed separately so it can raise the combined state
|
|
390
|
+
// without ever masking a guest signal.
|
|
391
|
+
if (readings.host_memory) {
|
|
392
|
+
const host = readings.host_memory;
|
|
393
|
+
const count = streaks.host_memory ?? 0;
|
|
394
|
+
const hostCritical =
|
|
395
|
+
host.available_percent < policy.memory_available.critical_available_percent &&
|
|
396
|
+
host.swap_used_percent !== null &&
|
|
397
|
+
host.swap_used_percent >= policy.memory_available.critical_swap_used_percent;
|
|
398
|
+
const hostElevated =
|
|
399
|
+
host.available_percent < policy.memory_available.elevated_available_percent;
|
|
400
|
+
if (hostCritical || hostElevated) {
|
|
401
|
+
reasons.push({
|
|
402
|
+
code: "host_memory_pressure",
|
|
403
|
+
dimension: "host_memory",
|
|
404
|
+
summary: `The Windows host has ${percent(host.available_percent)} of its memory available${host.swap_used_percent === null ? "" : ` with its page file ${percent(host.swap_used_percent)} used`}, past the threshold of ${percent(hostCritical ? policy.memory_available.critical_available_percent : policy.memory_available.elevated_available_percent)} available.`,
|
|
405
|
+
observed_value: host.available_percent,
|
|
406
|
+
threshold_value: hostCritical
|
|
407
|
+
? policy.memory_available.critical_available_percent
|
|
408
|
+
: policy.memory_available.elevated_available_percent,
|
|
409
|
+
unit: "percent",
|
|
410
|
+
sample_count: count,
|
|
411
|
+
contributes_to: hostCritical ? "critical" : "elevated",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const guestReasons = reasons.filter((reason) => reason.dimension !== "host_memory");
|
|
417
|
+
const hostReasons = reasons.filter((reason) => reason.dimension === "host_memory");
|
|
418
|
+
const guestRaw = worstState(guestReasons);
|
|
419
|
+
const hostRaw = worstState(hostReasons);
|
|
420
|
+
const rawState = STATE_RANK[hostRaw] > STATE_RANK[guestRaw] ? hostRaw : guestRaw;
|
|
421
|
+
|
|
422
|
+
// A clear sample has no dimension at or above its entry threshold. A hot
|
|
423
|
+
// reading that has not yet completed its entry streak is not clear, so the
|
|
424
|
+
// dwell cannot be shortened by a single sample of relief.
|
|
425
|
+
const exitClear =
|
|
426
|
+
rawState === "normal" && !Object.values(hot).some(Boolean) && !oom.hold_active && !oom.new_kill;
|
|
427
|
+
const transition = applyHysteresis(prior.state, rawState, exitClear, prior);
|
|
428
|
+
|
|
429
|
+
if (STATE_RANK[transition.state] > STATE_RANK[rawState]) {
|
|
430
|
+
const required =
|
|
431
|
+
transition.state === "critical"
|
|
432
|
+
? policy.recovery.critical_exit_samples
|
|
433
|
+
: policy.recovery.elevated_exit_samples;
|
|
434
|
+
reasons.push({
|
|
435
|
+
code: "recovering_within_dwell",
|
|
436
|
+
dimension: null,
|
|
437
|
+
summary: `Current evidence reads ${transition.state === "critical" ? "below critical" : "clear"}, and the state stays ${transition.state} until ${samples(required)} in a row are clear. ${transition.consecutive_clear_samples} of ${required} have been seen.`,
|
|
438
|
+
observed_value: transition.consecutive_clear_samples,
|
|
439
|
+
threshold_value: required,
|
|
440
|
+
unit: "count",
|
|
441
|
+
sample_count: transition.consecutive_clear_samples,
|
|
442
|
+
contributes_to: transition.state,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const evidence = buildEvidence(readings, streaks);
|
|
447
|
+
const evidenceState = evidenceStateOf(evidence);
|
|
448
|
+
if (evidenceState !== "complete") {
|
|
449
|
+
reasons.push({
|
|
450
|
+
code: "evidence_unavailable",
|
|
451
|
+
dimension: null,
|
|
452
|
+
summary:
|
|
453
|
+
evidenceState === "unavailable"
|
|
454
|
+
? "No contention dimension is measurable on this platform, so nothing here is evidence of a healthy machine."
|
|
455
|
+
: `${evidence.filter((entry) => entry.state === "unavailable").length} of ${evidence.length} contention dimensions are unavailable, so this reading rests on partial evidence.`,
|
|
456
|
+
observed_value: evidence.filter((entry) => entry.state === "unavailable").length,
|
|
457
|
+
threshold_value: null,
|
|
458
|
+
unit: "count",
|
|
459
|
+
sample_count: 0,
|
|
460
|
+
contributes_to: "unknown",
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (reasons.length === 0) {
|
|
464
|
+
reasons.push({
|
|
465
|
+
code: "no_contention_evidence",
|
|
466
|
+
dimension: null,
|
|
467
|
+
summary: "Every measurable contention dimension is below its elevated threshold.",
|
|
468
|
+
observed_value: null,
|
|
469
|
+
threshold_value: null,
|
|
470
|
+
unit: null,
|
|
471
|
+
sample_count: transition.consecutive_clear_samples,
|
|
472
|
+
contributes_to: "normal",
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const ordered = orderReasons(reasons);
|
|
477
|
+
const dominant = ordered.find(
|
|
478
|
+
(reason) => reason.dimension !== null && reason.contributes_to === transition.state,
|
|
479
|
+
);
|
|
480
|
+
const limiting = limitingResource(transition.state, dominant?.dimension ?? null);
|
|
481
|
+
const scope =
|
|
482
|
+
STATE_RANK[hostRaw] > STATE_RANK[guestRaw] ? "windows-host" : baseScope(snapshot.namespace);
|
|
483
|
+
const contributors = buildContributors(input.findings);
|
|
484
|
+
const action = recommendedAction(transition.state);
|
|
485
|
+
const unattributed = unattributedMemoryPercent(snapshot);
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
schema_version: PRESSURE_ASSESSMENT_SCHEMA_VERSION,
|
|
489
|
+
observer_only: true,
|
|
490
|
+
state: transition.state,
|
|
491
|
+
scope,
|
|
492
|
+
limiting_resource: limiting,
|
|
493
|
+
trend: computeTrend(input.history, readings, limiting),
|
|
494
|
+
observed_at: snapshot.sampled_at,
|
|
495
|
+
sample_age_ms: sampleAgeMs,
|
|
496
|
+
evidence_state: evidenceState,
|
|
497
|
+
evidence,
|
|
498
|
+
reasons: ordered.slice(0, policy.limits.max_reasons),
|
|
499
|
+
contributors: contributors.kept,
|
|
500
|
+
omitted_contributor_count: contributors.omitted,
|
|
501
|
+
unattributed_memory_percent: unattributed,
|
|
502
|
+
recommended_action: action,
|
|
503
|
+
summary: buildSummary({
|
|
504
|
+
state: transition.state,
|
|
505
|
+
scope,
|
|
506
|
+
limiting,
|
|
507
|
+
action,
|
|
508
|
+
dwell:
|
|
509
|
+
dominant === undefined && STATE_RANK[transition.state] > STATE_RANK[rawState]
|
|
510
|
+
? {
|
|
511
|
+
clear: transition.consecutive_clear_samples,
|
|
512
|
+
required:
|
|
513
|
+
transition.state === "critical"
|
|
514
|
+
? policy.recovery.critical_exit_samples
|
|
515
|
+
: policy.recovery.elevated_exit_samples,
|
|
516
|
+
}
|
|
517
|
+
: null,
|
|
518
|
+
evidenceState,
|
|
519
|
+
findingsCapabilityState: input.findings_capability.state,
|
|
520
|
+
unattributed,
|
|
521
|
+
residualSwapPercent: readings.memory_available?.swap_used_percent ?? null,
|
|
522
|
+
swapOutBytesPerSecond: readings.swap_out_bytes_per_second,
|
|
523
|
+
}),
|
|
524
|
+
guidance: buildGuidance(transition.state, limiting),
|
|
525
|
+
hysteresis: {
|
|
526
|
+
state: transition.state,
|
|
527
|
+
state_since:
|
|
528
|
+
transition.state === prior.state && input.prior?.state_since
|
|
529
|
+
? input.prior.state_since
|
|
530
|
+
: new Date(input.now_ms).toISOString(),
|
|
531
|
+
consecutive_clear_samples: transition.consecutive_clear_samples,
|
|
532
|
+
dimension_streaks: streaks,
|
|
533
|
+
oom_baseline_total_kills: oom.baseline_total_kills,
|
|
534
|
+
oom_hold_until: oom.hold_until,
|
|
535
|
+
observer_generation: input.observer_generation,
|
|
536
|
+
},
|
|
537
|
+
policy_version: policy.policy_version,
|
|
538
|
+
};
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Carry the prior state forward. An observer generation change restarts every
|
|
543
|
+
* streak and the OOM baseline, because PSI averages and kernel counters restart
|
|
544
|
+
* with the process that reads them. The state itself is carried so a restart
|
|
545
|
+
* cannot turn a known critical machine into a normal one in a single sample.
|
|
546
|
+
*/
|
|
547
|
+
function carryPrior(
|
|
548
|
+
prior: PressureHysteresisState | null,
|
|
549
|
+
observerGeneration: string,
|
|
550
|
+
): PressureHysteresisState {
|
|
551
|
+
if (!prior) {
|
|
552
|
+
return {
|
|
553
|
+
state: "unknown",
|
|
554
|
+
state_since: "",
|
|
555
|
+
consecutive_clear_samples: 0,
|
|
556
|
+
dimension_streaks: {},
|
|
557
|
+
oom_baseline_total_kills: null,
|
|
558
|
+
oom_hold_until: null,
|
|
559
|
+
observer_generation: observerGeneration,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
if (prior.observer_generation !== observerGeneration) {
|
|
563
|
+
return {
|
|
564
|
+
...prior,
|
|
565
|
+
consecutive_clear_samples: 0,
|
|
566
|
+
dimension_streaks: {},
|
|
567
|
+
oom_baseline_total_kills: null,
|
|
568
|
+
oom_hold_until: null,
|
|
569
|
+
observer_generation: observerGeneration,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
return prior;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function sampleAge(snapshot: ResourceSnapshot, nowMs: number): number | null {
|
|
576
|
+
const sampledMs = Date.parse(snapshot.sampled_at);
|
|
577
|
+
if (!Number.isFinite(sampledMs) || !Number.isFinite(nowMs)) return null;
|
|
578
|
+
const age = Math.round(nowMs - sampledMs);
|
|
579
|
+
// The observer samples and then assesses, so a sample time a few milliseconds
|
|
580
|
+
// ahead of the assessment clock is ordinary. Read that as zero. A larger jump
|
|
581
|
+
// means the clock cannot be trusted; report an unreadable age rather than a
|
|
582
|
+
// negative one, so the published record stays valid and says why it is unknown.
|
|
583
|
+
if (age < 0) {
|
|
584
|
+
return age >= -PRESSURE_POLICY.sample_future_tolerance_ms ? 0 : null;
|
|
585
|
+
}
|
|
586
|
+
return age;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function stalenessReason(ageMs: number | null): { code: "snapshot_stale"; summary: string } | null {
|
|
590
|
+
if (ageMs === null) {
|
|
591
|
+
return {
|
|
592
|
+
code: "snapshot_stale",
|
|
593
|
+
summary: "The resource snapshot carries no readable sample time, so its age cannot be shown.",
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (ageMs < 0) {
|
|
597
|
+
return {
|
|
598
|
+
code: "snapshot_stale",
|
|
599
|
+
summary: `The resource snapshot is dated ${seconds(-ageMs)} in the future, so its age cannot be trusted.`,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const limit = PRESSURE_POLICY.sample_staleness_ms;
|
|
603
|
+
if (ageMs > limit) {
|
|
604
|
+
return {
|
|
605
|
+
code: "snapshot_stale",
|
|
606
|
+
summary: `The resource snapshot is ${seconds(ageMs)} old, past the freshness limit of ${seconds(limit)}.`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
if (ageMs < -limit) {
|
|
610
|
+
return {
|
|
611
|
+
code: "snapshot_stale",
|
|
612
|
+
summary: `The resource snapshot is dated ${seconds(-ageMs)} in the future, so the clocks disagree by more than the freshness limit of ${seconds(limit)}.`,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* No snapshot, an unreadable sample time, or a stale sample means the machine
|
|
620
|
+
* state is unknown. Contributors are withheld here, because an attribution
|
|
621
|
+
* finding is never evidence about the machine and must not read as one when
|
|
622
|
+
* there is no contention measurement beside it.
|
|
623
|
+
*/
|
|
624
|
+
function unknownAssessment(
|
|
625
|
+
input: AssessPressureInput,
|
|
626
|
+
prior: PressureHysteresisState,
|
|
627
|
+
sampleAgeMs: number | null,
|
|
628
|
+
gap: { code: "snapshot_stale" | "evidence_unavailable"; summary: string },
|
|
629
|
+
): PressureAssessment {
|
|
630
|
+
const snapshot = input.snapshot;
|
|
631
|
+
const dimensions = applicableDimensions(Boolean(snapshot?.host));
|
|
632
|
+
const reasonCode = gap.code;
|
|
633
|
+
const summary = gap.summary;
|
|
634
|
+
const observedAt = snapshot?.sampled_at ?? new Date(input.now_ms).toISOString();
|
|
635
|
+
return {
|
|
636
|
+
schema_version: PRESSURE_ASSESSMENT_SCHEMA_VERSION,
|
|
637
|
+
observer_only: true,
|
|
638
|
+
state: "unknown",
|
|
639
|
+
scope: baseScope(snapshot?.namespace),
|
|
640
|
+
limiting_resource: "unknown",
|
|
641
|
+
trend: "unknown",
|
|
642
|
+
observed_at: observedAt,
|
|
643
|
+
sample_age_ms: sampleAgeMs,
|
|
644
|
+
evidence_state: "unavailable",
|
|
645
|
+
evidence: dimensions.map((dimension) => ({
|
|
646
|
+
dimension,
|
|
647
|
+
state: "unavailable" as const,
|
|
648
|
+
observed_value: null,
|
|
649
|
+
unit: null,
|
|
650
|
+
sample_count: 0,
|
|
651
|
+
reason_code: reasonCode,
|
|
652
|
+
})),
|
|
653
|
+
reasons: [
|
|
654
|
+
{
|
|
655
|
+
code: reasonCode,
|
|
656
|
+
dimension: null,
|
|
657
|
+
summary,
|
|
658
|
+
observed_value: sampleAgeMs,
|
|
659
|
+
threshold_value: PRESSURE_POLICY.sample_staleness_ms,
|
|
660
|
+
unit: "milliseconds",
|
|
661
|
+
sample_count: 0,
|
|
662
|
+
contributes_to: "unknown",
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
contributors: [],
|
|
666
|
+
omitted_contributor_count: 0,
|
|
667
|
+
unattributed_memory_percent: null,
|
|
668
|
+
recommended_action: "unknown",
|
|
669
|
+
summary: `${summary} No owner is named and no recommendation is available until a fresh snapshot arrives.`,
|
|
670
|
+
guidance: WORKLOAD_CLASSES.map((workloadClass) => ({
|
|
671
|
+
workload_class: workloadClass,
|
|
672
|
+
recommendation: "unknown" as const,
|
|
673
|
+
summary: "No guidance is available until a fresh resource snapshot arrives.",
|
|
674
|
+
})),
|
|
675
|
+
hysteresis: {
|
|
676
|
+
state: "unknown",
|
|
677
|
+
state_since:
|
|
678
|
+
prior.state === "unknown" && input.prior?.state_since
|
|
679
|
+
? input.prior.state_since
|
|
680
|
+
: new Date(input.now_ms).toISOString(),
|
|
681
|
+
consecutive_clear_samples: 0,
|
|
682
|
+
dimension_streaks: {},
|
|
683
|
+
// A stale sample must not discard a kill that is still holding critical.
|
|
684
|
+
oom_baseline_total_kills: prior.oom_baseline_total_kills,
|
|
685
|
+
oom_hold_until: prior.oom_hold_until,
|
|
686
|
+
observer_generation: input.observer_generation,
|
|
687
|
+
},
|
|
688
|
+
policy_version: PRESSURE_POLICY.policy_version,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function applicableDimensions(hostPresent: boolean): readonly PressureDimension[] {
|
|
693
|
+
return hostPresent
|
|
694
|
+
? DIMENSION_ORDER
|
|
695
|
+
: DIMENSION_ORDER.filter((dimension) => dimension !== "host_memory");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function readSnapshot(snapshot: ResourceSnapshot, nowMs: number): Readings {
|
|
699
|
+
const unavailable: Partial<Record<PressureDimension, string>> = {};
|
|
700
|
+
const pressure = snapshot.pressure;
|
|
701
|
+
const pressureUsable = pressure?.state === "supported" || pressure?.state === "partial";
|
|
702
|
+
const memoryStall = pressureUsable && pressure.memory_full ? pressure.memory_full : null;
|
|
703
|
+
const ioStall = pressureUsable && pressure.io_full ? pressure.io_full : null;
|
|
704
|
+
const cpuStall = pressureUsable && pressure.cpu ? pressure.cpu.avg60 : null;
|
|
705
|
+
if (!memoryStall)
|
|
706
|
+
unavailable.memory_stall = pressureUsable ? "memory_full_missing" : "psi_unavailable";
|
|
707
|
+
if (!ioStall) unavailable.io_stall = pressureUsable ? "io_full_missing" : "psi_unavailable";
|
|
708
|
+
if (cpuStall === null)
|
|
709
|
+
unavailable.cpu_stall = pressureUsable ? "cpu_some_missing" : "psi_unavailable";
|
|
710
|
+
|
|
711
|
+
const oom = snapshot.oom;
|
|
712
|
+
const oomTotal = oom?.state === "supported" && oom.total_kills !== null ? oom.total_kills : null;
|
|
713
|
+
if (oomTotal === null) unavailable.oom_kills = "oom_counter_unavailable";
|
|
714
|
+
|
|
715
|
+
const vmstat = snapshot.vmstat;
|
|
716
|
+
const vmstatUsable = vmstat?.state === "supported" || vmstat?.state === "partial";
|
|
717
|
+
const swapOut = vmstatUsable ? vmstat.swap_out_bytes_per_second : null;
|
|
718
|
+
const reclaim = vmstatUsable ? vmstat.direct_reclaim_pages_per_second : null;
|
|
719
|
+
if (swapOut === null)
|
|
720
|
+
unavailable.swap_activity = vmstat?.counters_reset
|
|
721
|
+
? "reclaim_baseline_restarted"
|
|
722
|
+
: "vmstat_unavailable";
|
|
723
|
+
if (reclaim === null)
|
|
724
|
+
unavailable.direct_reclaim = vmstat?.counters_reset
|
|
725
|
+
? "reclaim_baseline_restarted"
|
|
726
|
+
: "vmstat_unavailable";
|
|
727
|
+
|
|
728
|
+
const machine = snapshot.machine;
|
|
729
|
+
let memoryAvailable: CapacityReading | null = null;
|
|
730
|
+
if (
|
|
731
|
+
machine.memory_total_bytes !== null &&
|
|
732
|
+
machine.memory_total_bytes > 0 &&
|
|
733
|
+
machine.memory_available_bytes !== null
|
|
734
|
+
) {
|
|
735
|
+
memoryAvailable = {
|
|
736
|
+
available_percent: round2(
|
|
737
|
+
(machine.memory_available_bytes / machine.memory_total_bytes) * 100,
|
|
738
|
+
),
|
|
739
|
+
swap_used_percent: swapUsedPercent(machine.swap_total_bytes, machine.swap_used_bytes),
|
|
740
|
+
};
|
|
741
|
+
} else {
|
|
742
|
+
unavailable.memory_available = "machine_memory_unavailable";
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const disks = (snapshot.disks ?? []).filter(
|
|
746
|
+
(disk) => disk.state === "supported" && disk.available_bytes !== null,
|
|
747
|
+
);
|
|
748
|
+
let worstDisk: DiskReading | null = null;
|
|
749
|
+
for (const disk of disks) {
|
|
750
|
+
const reading: DiskReading = {
|
|
751
|
+
path: disk.path,
|
|
752
|
+
available_bytes: disk.available_bytes!,
|
|
753
|
+
used_percent: disk.used_percent,
|
|
754
|
+
};
|
|
755
|
+
if (!worstDisk || reading.available_bytes < worstDisk.available_bytes) worstDisk = reading;
|
|
756
|
+
}
|
|
757
|
+
if (!worstDisk) unavailable.disk_available = "disk_capacity_unavailable";
|
|
758
|
+
|
|
759
|
+
const host = snapshot.host;
|
|
760
|
+
const hostApplicable = Boolean(host);
|
|
761
|
+
// The host block is collected on its own clock, so it is held to the same
|
|
762
|
+
// freshness limit. A stale host reading must not raise the combined state.
|
|
763
|
+
const hostAgeMs = host ? nowMs - Date.parse(host.sampled_at) : null;
|
|
764
|
+
const hostFresh =
|
|
765
|
+
hostAgeMs !== null &&
|
|
766
|
+
Number.isFinite(hostAgeMs) &&
|
|
767
|
+
Math.abs(hostAgeMs) <= PRESSURE_POLICY.sample_staleness_ms;
|
|
768
|
+
let hostMemory: CapacityReading | null = null;
|
|
769
|
+
if (
|
|
770
|
+
host &&
|
|
771
|
+
hostFresh &&
|
|
772
|
+
(host.state === "supported" || host.state === "partial") &&
|
|
773
|
+
host.machine &&
|
|
774
|
+
host.machine.memory_percent !== null
|
|
775
|
+
) {
|
|
776
|
+
hostMemory = {
|
|
777
|
+
available_percent: round2(100 - host.machine.memory_percent),
|
|
778
|
+
swap_used_percent: swapUsedPercent(
|
|
779
|
+
host.machine.swap_total_bytes,
|
|
780
|
+
host.machine.swap_used_bytes,
|
|
781
|
+
),
|
|
782
|
+
};
|
|
783
|
+
} else if (hostApplicable) {
|
|
784
|
+
unavailable.host_memory = hostFresh ? "host_memory_unavailable" : "host_sample_stale";
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
memory_stall: memoryStall ? { avg10: memoryStall.avg10, avg60: memoryStall.avg60 } : null,
|
|
789
|
+
io_stall: ioStall ? { avg10: ioStall.avg10, avg60: ioStall.avg60 } : null,
|
|
790
|
+
cpu_stall: cpuStall,
|
|
791
|
+
oom_total_kills: oomTotal,
|
|
792
|
+
swap_out_bytes_per_second: swapOut,
|
|
793
|
+
direct_reclaim_pages_per_second: reclaim,
|
|
794
|
+
memory_available: memoryAvailable,
|
|
795
|
+
disk_available: worstDisk,
|
|
796
|
+
host_memory: hostMemory,
|
|
797
|
+
unavailable_reasons: unavailable,
|
|
798
|
+
host_applicable: hostApplicable,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function swapUsedPercent(total: number | null, used: number | null): number | null {
|
|
803
|
+
if (total === null || used === null || total <= 0) return null;
|
|
804
|
+
return round2((used / total) * 100);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Whether each dimension currently sits on the hot side of its elevated threshold. */
|
|
808
|
+
function hotDimensions(readings: Readings): Partial<Record<PressureDimension, boolean>> {
|
|
809
|
+
const policy = PRESSURE_POLICY;
|
|
810
|
+
const hot: Partial<Record<PressureDimension, boolean>> = {};
|
|
811
|
+
if (readings.memory_stall)
|
|
812
|
+
hot.memory_stall = readings.memory_stall.avg10 >= policy.memory_stall.elevated_avg10;
|
|
813
|
+
if (readings.io_stall) hot.io_stall = readings.io_stall.avg10 >= policy.io_stall.elevated_avg10;
|
|
814
|
+
if (readings.cpu_stall !== null)
|
|
815
|
+
hot.cpu_stall = readings.cpu_stall >= policy.cpu_stall.elevated_some_avg60;
|
|
816
|
+
if (readings.swap_out_bytes_per_second !== null)
|
|
817
|
+
hot.swap_activity =
|
|
818
|
+
readings.swap_out_bytes_per_second >= policy.swap_activity.elevated_out_bytes_per_second;
|
|
819
|
+
if (readings.direct_reclaim_pages_per_second !== null)
|
|
820
|
+
hot.direct_reclaim =
|
|
821
|
+
readings.direct_reclaim_pages_per_second >= policy.direct_reclaim.elevated_pages_per_second;
|
|
822
|
+
if (readings.memory_available)
|
|
823
|
+
hot.memory_available =
|
|
824
|
+
readings.memory_available.available_percent <
|
|
825
|
+
policy.memory_available.elevated_available_percent;
|
|
826
|
+
if (readings.disk_available)
|
|
827
|
+
hot.disk_available =
|
|
828
|
+
readings.disk_available.available_bytes < policy.disk.elevated_available_bytes ||
|
|
829
|
+
(readings.disk_available.used_percent !== null &&
|
|
830
|
+
readings.disk_available.used_percent >= policy.disk.elevated_used_percent);
|
|
831
|
+
if (readings.host_memory)
|
|
832
|
+
hot.host_memory =
|
|
833
|
+
readings.host_memory.available_percent < policy.memory_available.elevated_available_percent;
|
|
834
|
+
return hot;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function advanceStreaks(
|
|
838
|
+
prior: Readonly<Partial<Record<PressureDimension, number>>>,
|
|
839
|
+
hot: Partial<Record<PressureDimension, boolean>>,
|
|
840
|
+
readings: Readings,
|
|
841
|
+
): Partial<Record<PressureDimension, number>> {
|
|
842
|
+
const next: Partial<Record<PressureDimension, number>> = {};
|
|
843
|
+
for (const dimension of applicableDimensions(readings.host_applicable)) {
|
|
844
|
+
if (dimension === "oom_kills") continue;
|
|
845
|
+
if (!hot[dimension]) continue;
|
|
846
|
+
next[dimension] = (prior[dimension] ?? 0) + 1;
|
|
847
|
+
}
|
|
848
|
+
return next;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Only an increase over the carried baseline is a new kill. A historical total
|
|
853
|
+
* opens nothing, and a total below the baseline is a counter reset that starts
|
|
854
|
+
* a new baseline rather than a critical state.
|
|
855
|
+
*/
|
|
856
|
+
function evaluateOom(
|
|
857
|
+
totalKills: number | null,
|
|
858
|
+
prior: PressureHysteresisState,
|
|
859
|
+
nowMs: number,
|
|
860
|
+
): {
|
|
861
|
+
baseline_total_kills: number | null;
|
|
862
|
+
hold_until: string | null;
|
|
863
|
+
hold_active: boolean;
|
|
864
|
+
new_kill: boolean;
|
|
865
|
+
reason: PressureReason | null;
|
|
866
|
+
} {
|
|
867
|
+
const holdMs = PRESSURE_POLICY.oom.critical_hold_ms;
|
|
868
|
+
const priorHoldMs = prior.oom_hold_until === null ? null : Date.parse(prior.oom_hold_until);
|
|
869
|
+
const holdRemaining =
|
|
870
|
+
priorHoldMs !== null && Number.isFinite(priorHoldMs) ? priorHoldMs - nowMs : null;
|
|
871
|
+
const holdCarried = holdRemaining !== null && holdRemaining > 0;
|
|
872
|
+
if (totalKills === null) {
|
|
873
|
+
return {
|
|
874
|
+
baseline_total_kills: prior.oom_baseline_total_kills,
|
|
875
|
+
hold_until: holdCarried ? prior.oom_hold_until : null,
|
|
876
|
+
hold_active: holdCarried,
|
|
877
|
+
new_kill: false,
|
|
878
|
+
reason: holdCarried ? oomHoldReason(holdRemaining!, holdMs) : null,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
const baseline = prior.oom_baseline_total_kills;
|
|
882
|
+
if (baseline === null) {
|
|
883
|
+
// First reading. A historical total is a baseline, never an incident.
|
|
884
|
+
return {
|
|
885
|
+
baseline_total_kills: totalKills,
|
|
886
|
+
hold_until: holdCarried ? prior.oom_hold_until : null,
|
|
887
|
+
hold_active: holdCarried,
|
|
888
|
+
new_kill: false,
|
|
889
|
+
reason: holdCarried ? oomHoldReason(holdRemaining!, holdMs) : null,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
if (totalKills < baseline) {
|
|
893
|
+
return {
|
|
894
|
+
baseline_total_kills: totalKills,
|
|
895
|
+
hold_until: holdCarried ? prior.oom_hold_until : null,
|
|
896
|
+
hold_active: holdCarried,
|
|
897
|
+
new_kill: false,
|
|
898
|
+
reason: holdCarried ? oomHoldReason(holdRemaining!, holdMs) : null,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
if (totalKills > baseline) {
|
|
902
|
+
const increase = totalKills - baseline;
|
|
903
|
+
return {
|
|
904
|
+
baseline_total_kills: totalKills,
|
|
905
|
+
hold_until: new Date(nowMs + holdMs).toISOString(),
|
|
906
|
+
hold_active: true,
|
|
907
|
+
new_kill: true,
|
|
908
|
+
reason: {
|
|
909
|
+
code: "new_oom_kill",
|
|
910
|
+
dimension: "oom_kills",
|
|
911
|
+
summary: `The kernel killed ${increase === 1 ? "one process" : `${increase} processes`} for memory since the previous sample, and critical is held for ${seconds(holdMs)} after a kill. Work may have been lost.`,
|
|
912
|
+
observed_value: increase,
|
|
913
|
+
threshold_value: 0,
|
|
914
|
+
unit: "count",
|
|
915
|
+
sample_count: 1,
|
|
916
|
+
contributes_to: "critical",
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
baseline_total_kills: baseline,
|
|
922
|
+
hold_until: holdCarried ? prior.oom_hold_until : null,
|
|
923
|
+
hold_active: holdCarried,
|
|
924
|
+
new_kill: false,
|
|
925
|
+
reason: holdCarried ? oomHoldReason(holdRemaining!, holdMs) : null,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function oomHoldReason(remainingMs: number, holdMs: number): PressureReason {
|
|
930
|
+
return {
|
|
931
|
+
code: "new_oom_kill",
|
|
932
|
+
dimension: "oom_kills",
|
|
933
|
+
summary: `A recent kernel memory kill still holds the critical state for another ${seconds(remainingMs)} of its ${seconds(holdMs)} window.`,
|
|
934
|
+
observed_value: Math.round(remainingMs),
|
|
935
|
+
threshold_value: holdMs,
|
|
936
|
+
unit: "milliseconds",
|
|
937
|
+
sample_count: 0,
|
|
938
|
+
contributes_to: "critical",
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function applyHysteresis(
|
|
943
|
+
priorState: PressureState,
|
|
944
|
+
rawState: PressureState,
|
|
945
|
+
exitClear: boolean,
|
|
946
|
+
prior: PressureHysteresisState,
|
|
947
|
+
): { state: PressureState; consecutive_clear_samples: number } {
|
|
948
|
+
const policy = PRESSURE_POLICY.recovery;
|
|
949
|
+
const clear = exitClear ? prior.consecutive_clear_samples + 1 : 0;
|
|
950
|
+
if (rawState === "critical") return { state: "critical", consecutive_clear_samples: 0 };
|
|
951
|
+
if (priorState === "critical") {
|
|
952
|
+
return clear >= policy.critical_exit_samples
|
|
953
|
+
? { state: "elevated", consecutive_clear_samples: 0 }
|
|
954
|
+
: { state: "critical", consecutive_clear_samples: clear };
|
|
955
|
+
}
|
|
956
|
+
if (rawState === "elevated") return { state: "elevated", consecutive_clear_samples: 0 };
|
|
957
|
+
if (priorState === "elevated") {
|
|
958
|
+
return clear >= policy.elevated_exit_samples
|
|
959
|
+
? { state: "normal", consecutive_clear_samples: 0 }
|
|
960
|
+
: { state: "elevated", consecutive_clear_samples: clear };
|
|
961
|
+
}
|
|
962
|
+
return { state: "normal", consecutive_clear_samples: clear };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function buildEvidence(
|
|
966
|
+
readings: Readings,
|
|
967
|
+
streaks: Partial<Record<PressureDimension, number>>,
|
|
968
|
+
): readonly PressureEvidenceDimension[] {
|
|
969
|
+
const entries: PressureEvidenceDimension[] = [];
|
|
970
|
+
for (const dimension of applicableDimensions(readings.host_applicable)) {
|
|
971
|
+
const measured = measuredValue(readings, dimension);
|
|
972
|
+
entries.push(
|
|
973
|
+
measured === null
|
|
974
|
+
? {
|
|
975
|
+
dimension,
|
|
976
|
+
state: "unavailable",
|
|
977
|
+
observed_value: null,
|
|
978
|
+
unit: null,
|
|
979
|
+
sample_count: 0,
|
|
980
|
+
reason_code: readings.unavailable_reasons[dimension] ?? "dimension_unavailable",
|
|
981
|
+
}
|
|
982
|
+
: {
|
|
983
|
+
dimension,
|
|
984
|
+
state: "supported",
|
|
985
|
+
observed_value: measured.value,
|
|
986
|
+
unit: measured.unit,
|
|
987
|
+
sample_count: streaks[dimension] ?? 0,
|
|
988
|
+
},
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return entries.slice(0, PRESSURE_POLICY.limits.max_evidence);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function measuredValue(
|
|
995
|
+
readings: Readings,
|
|
996
|
+
dimension: PressureDimension,
|
|
997
|
+
): { value: number; unit: PressureEvidenceDimension["unit"] } | null {
|
|
998
|
+
switch (dimension) {
|
|
999
|
+
case "memory_stall":
|
|
1000
|
+
return readings.memory_stall && { value: readings.memory_stall.avg10, unit: "percent" };
|
|
1001
|
+
case "io_stall":
|
|
1002
|
+
return readings.io_stall && { value: readings.io_stall.avg10, unit: "percent" };
|
|
1003
|
+
case "cpu_stall":
|
|
1004
|
+
return readings.cpu_stall === null ? null : { value: readings.cpu_stall, unit: "percent" };
|
|
1005
|
+
case "oom_kills":
|
|
1006
|
+
return readings.oom_total_kills === null
|
|
1007
|
+
? null
|
|
1008
|
+
: { value: readings.oom_total_kills, unit: "count" };
|
|
1009
|
+
case "swap_activity":
|
|
1010
|
+
return readings.swap_out_bytes_per_second === null
|
|
1011
|
+
? null
|
|
1012
|
+
: { value: readings.swap_out_bytes_per_second, unit: "bytes-per-second" };
|
|
1013
|
+
case "direct_reclaim":
|
|
1014
|
+
return readings.direct_reclaim_pages_per_second === null
|
|
1015
|
+
? null
|
|
1016
|
+
: { value: readings.direct_reclaim_pages_per_second, unit: "pages-per-second" };
|
|
1017
|
+
case "memory_available":
|
|
1018
|
+
return (
|
|
1019
|
+
readings.memory_available && {
|
|
1020
|
+
value: readings.memory_available.available_percent,
|
|
1021
|
+
unit: "percent",
|
|
1022
|
+
}
|
|
1023
|
+
);
|
|
1024
|
+
case "disk_available":
|
|
1025
|
+
return (
|
|
1026
|
+
readings.disk_available && {
|
|
1027
|
+
value: readings.disk_available.available_bytes,
|
|
1028
|
+
unit: "bytes",
|
|
1029
|
+
}
|
|
1030
|
+
);
|
|
1031
|
+
case "host_memory":
|
|
1032
|
+
return (
|
|
1033
|
+
readings.host_memory && { value: readings.host_memory.available_percent, unit: "percent" }
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function evidenceStateOf(
|
|
1039
|
+
evidence: readonly PressureEvidenceDimension[],
|
|
1040
|
+
): PressureAssessment["evidence_state"] {
|
|
1041
|
+
const supported = evidence.filter((entry) => entry.state === "supported").length;
|
|
1042
|
+
if (supported === 0) return "unavailable";
|
|
1043
|
+
return supported === evidence.length ? "complete" : "partial";
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function worstState(reasons: readonly PressureReason[]): PressureState {
|
|
1047
|
+
let worst: PressureState = "normal";
|
|
1048
|
+
for (const reason of reasons) {
|
|
1049
|
+
if (reason.contributes_to === "unknown") continue;
|
|
1050
|
+
if (STATE_RANK[reason.contributes_to] > STATE_RANK[worst]) worst = reason.contributes_to;
|
|
1051
|
+
}
|
|
1052
|
+
return worst;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function orderReasons(reasons: readonly PressureReason[]): PressureReason[] {
|
|
1056
|
+
return [...reasons].sort((left, right) => {
|
|
1057
|
+
const byState = STATE_RANK[right.contributes_to] - STATE_RANK[left.contributes_to];
|
|
1058
|
+
if (byState !== 0) return byState;
|
|
1059
|
+
return dimensionRank(left.dimension) - dimensionRank(right.dimension);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function dimensionRank(dimension: PressureDimension | null): number {
|
|
1064
|
+
if (dimension === null) return DIMENSION_ORDER.length;
|
|
1065
|
+
return DIMENSION_ORDER.indexOf(dimension);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function limitingResource(
|
|
1069
|
+
state: PressureState,
|
|
1070
|
+
dimension: PressureDimension | null,
|
|
1071
|
+
): PressureLimitingResource {
|
|
1072
|
+
if (state === "unknown") return "unknown";
|
|
1073
|
+
if (state === "normal") return "none";
|
|
1074
|
+
return dimension === null ? "unknown" : DIMENSION_RESOURCE[dimension];
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function baseScope(namespace: ResourceSnapshot["namespace"] | undefined): PressureScope {
|
|
1078
|
+
return namespace === "wsl" ? "guest" : "native";
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function recommendedAction(state: PressureState): PressureRecommendedAction {
|
|
1082
|
+
if (state === "critical") return "avoid-new-heavy-work";
|
|
1083
|
+
if (state === "elevated") return "limit-heavy-work";
|
|
1084
|
+
if (state === "normal") return "proceed";
|
|
1085
|
+
return "unknown";
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Every opened finding is carried, including contention-class ones. An owner is
|
|
1090
|
+
* named only when the finding's attribution is exact, so a guess never reaches
|
|
1091
|
+
* an operator as a name.
|
|
1092
|
+
*/
|
|
1093
|
+
function buildContributors(findings: readonly PressureFindingInput[]): {
|
|
1094
|
+
kept: readonly PressureContributor[];
|
|
1095
|
+
omitted: number;
|
|
1096
|
+
} {
|
|
1097
|
+
const opened = findings.filter((finding) => finding.state === "opened");
|
|
1098
|
+
const sorted = [...opened].sort((left, right) => {
|
|
1099
|
+
const bySeverity = severityRank(right.severity) - severityRank(left.severity);
|
|
1100
|
+
if (bySeverity !== 0) return bySeverity;
|
|
1101
|
+
const byClass = classRank(left.finding_class) - classRank(right.finding_class);
|
|
1102
|
+
if (byClass !== 0) return byClass;
|
|
1103
|
+
const byKind = left.finding_kind.localeCompare(right.finding_kind);
|
|
1104
|
+
return byKind !== 0 ? byKind : left.id.localeCompare(right.id);
|
|
1105
|
+
});
|
|
1106
|
+
const limit = PRESSURE_POLICY.limits.max_contributors;
|
|
1107
|
+
const kept = sorted.slice(0, limit).map((finding): PressureContributor => {
|
|
1108
|
+
const attributed = finding.attribution?.state === "attributed";
|
|
1109
|
+
const owner =
|
|
1110
|
+
attributed && finding.attribution?.owner_kind && finding.attribution?.owner_id
|
|
1111
|
+
? { owner_kind: finding.attribution.owner_kind, owner_id: finding.attribution.owner_id }
|
|
1112
|
+
: {};
|
|
1113
|
+
return {
|
|
1114
|
+
finding_id: finding.id,
|
|
1115
|
+
finding_kind: finding.finding_kind,
|
|
1116
|
+
finding_class: finding.finding_class,
|
|
1117
|
+
severity: finding.severity,
|
|
1118
|
+
summary: finding.summary,
|
|
1119
|
+
scope_kind: finding.scope_kind,
|
|
1120
|
+
scope_id: finding.scope_id,
|
|
1121
|
+
occurrence_count: finding.occurrence_count,
|
|
1122
|
+
attribution_state: finding.attribution?.state ?? "unknown",
|
|
1123
|
+
attribution_confidence: attributed ? "exact" : "none",
|
|
1124
|
+
...owner,
|
|
1125
|
+
...(finding.workload_context
|
|
1126
|
+
? { workload_relationship: finding.workload_context.relationship }
|
|
1127
|
+
: {}),
|
|
1128
|
+
};
|
|
1129
|
+
});
|
|
1130
|
+
return { kept, omitted: Math.max(0, sorted.length - kept.length) };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function severityRank(severity: PressureContributor["severity"]): number {
|
|
1134
|
+
return severity === "critical" ? 3 : severity === "warning" ? 2 : 1;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function classRank(findingClass: PressureContributor["finding_class"]): number {
|
|
1138
|
+
return findingClass === "contention" ? 0 : findingClass === "attribution" ? 1 : 2;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function unattributedMemoryPercent(snapshot: ResourceSnapshot): number | null {
|
|
1142
|
+
const total = snapshot.machine.memory_total_bytes;
|
|
1143
|
+
if (total === null || total <= 0) return null;
|
|
1144
|
+
const rss = snapshot.groups
|
|
1145
|
+
.filter((group) => group.kind === "unattributed")
|
|
1146
|
+
.reduce((sum, group) => sum + group.rss_bytes, 0);
|
|
1147
|
+
return round2((rss / total) * 100);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Trend follows one field for the whole window, chosen from what the current
|
|
1152
|
+
* sample can measure, so the series never mixes units. It compares the newest
|
|
1153
|
+
* reading with the oldest in the bounded window.
|
|
1154
|
+
*/
|
|
1155
|
+
function computeTrend(
|
|
1156
|
+
history: readonly PressureHistorySample[],
|
|
1157
|
+
readings: Readings,
|
|
1158
|
+
limiting: PressureLimitingResource,
|
|
1159
|
+
): PressureTrend {
|
|
1160
|
+
const window = history.slice(-PRESSURE_POLICY.max_history_samples);
|
|
1161
|
+
const candidates: {
|
|
1162
|
+
current: number | null;
|
|
1163
|
+
select: (sample: PressureHistorySample) => number | null;
|
|
1164
|
+
}[] =
|
|
1165
|
+
limiting === "cpu"
|
|
1166
|
+
? [{ current: readings.cpu_stall, select: (sample) => sample.cpu_some_avg60 }]
|
|
1167
|
+
: limiting === "io"
|
|
1168
|
+
? [{ current: readings.io_stall?.avg10 ?? null, select: (s) => s.io_full_avg10 }]
|
|
1169
|
+
: [
|
|
1170
|
+
{ current: readings.memory_stall?.avg10 ?? null, select: (s) => s.memory_full_avg10 },
|
|
1171
|
+
{
|
|
1172
|
+
current: readings.swap_out_bytes_per_second,
|
|
1173
|
+
select: (s) => s.swap_out_bytes_per_second,
|
|
1174
|
+
},
|
|
1175
|
+
];
|
|
1176
|
+
for (const candidate of candidates) {
|
|
1177
|
+
if (candidate.current === null) continue;
|
|
1178
|
+
const series = [
|
|
1179
|
+
...window.map(candidate.select).filter((value): value is number => value !== null),
|
|
1180
|
+
candidate.current,
|
|
1181
|
+
];
|
|
1182
|
+
if (series.length < 2) continue;
|
|
1183
|
+
const oldest = series[0]!;
|
|
1184
|
+
const newest = series[series.length - 1]!;
|
|
1185
|
+
if (newest > oldest) return "rising";
|
|
1186
|
+
if (newest < oldest) return "falling";
|
|
1187
|
+
return "steady";
|
|
1188
|
+
}
|
|
1189
|
+
return "unknown";
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function buildSummary(view: {
|
|
1193
|
+
state: PressureState;
|
|
1194
|
+
scope: PressureScope;
|
|
1195
|
+
limiting: PressureLimitingResource;
|
|
1196
|
+
action: PressureRecommendedAction;
|
|
1197
|
+
/** Set when no dimension is hot and only the recovery dwell holds the state. */
|
|
1198
|
+
dwell: { clear: number; required: number } | null;
|
|
1199
|
+
evidenceState: PressureAssessment["evidence_state"];
|
|
1200
|
+
findingsCapabilityState: string;
|
|
1201
|
+
unattributed: number | null;
|
|
1202
|
+
residualSwapPercent: number | null;
|
|
1203
|
+
swapOutBytesPerSecond: number | null;
|
|
1204
|
+
}): string {
|
|
1205
|
+
const subject =
|
|
1206
|
+
view.scope === "guest"
|
|
1207
|
+
? "The Linux guest"
|
|
1208
|
+
: view.scope === "windows-host"
|
|
1209
|
+
? "The Windows host"
|
|
1210
|
+
: "This machine";
|
|
1211
|
+
const resource =
|
|
1212
|
+
view.limiting === "memory"
|
|
1213
|
+
? "memory"
|
|
1214
|
+
: view.limiting === "cpu"
|
|
1215
|
+
? "processor"
|
|
1216
|
+
: view.limiting === "io"
|
|
1217
|
+
? "input and output"
|
|
1218
|
+
: view.limiting === "storage"
|
|
1219
|
+
? "storage"
|
|
1220
|
+
: "resource";
|
|
1221
|
+
// A dwell-held state must say so. Reporting it as live contention sends
|
|
1222
|
+
// every reader looking for a cause that the evidence no longer shows.
|
|
1223
|
+
const stateClause =
|
|
1224
|
+
view.state === "normal"
|
|
1225
|
+
? `${subject} shows no contention evidence.`
|
|
1226
|
+
: view.dwell
|
|
1227
|
+
? `${subject} reads clear of contention and is leaving the ${view.state} state once ${view.dwell.required} clear samples in a row are seen, ${view.dwell.clear} so far.`
|
|
1228
|
+
: `${subject} is under ${view.state} ${resource} contention.`;
|
|
1229
|
+
const actionClause =
|
|
1230
|
+
view.action === "proceed"
|
|
1231
|
+
? "Ordinary work can proceed."
|
|
1232
|
+
: view.dwell
|
|
1233
|
+
? view.action === "limit-heavy-work"
|
|
1234
|
+
? "Let running heavy work finish and start new heavy work once the state clears."
|
|
1235
|
+
: "Do not start new heavy work until the state clears."
|
|
1236
|
+
: view.action === "limit-heavy-work"
|
|
1237
|
+
? "Limit heavy work until the evidence clears."
|
|
1238
|
+
: "Do not start new heavy work until the evidence clears.";
|
|
1239
|
+
const parts = [stateClause, actionClause];
|
|
1240
|
+
if (view.evidenceState === "partial")
|
|
1241
|
+
parts.push("Some dimensions are unavailable, so this reading rests on partial evidence.");
|
|
1242
|
+
if (view.evidenceState === "unavailable")
|
|
1243
|
+
parts.push("No contention dimension is measurable here, so treat the reading as uninformed.");
|
|
1244
|
+
if (view.findingsCapabilityState !== "supported")
|
|
1245
|
+
parts.push(
|
|
1246
|
+
`Contributor evidence is incomplete because the findings source reports ${view.findingsCapabilityState}.`,
|
|
1247
|
+
);
|
|
1248
|
+
// Residual swap occupancy is reported as a fact and is never a signal on its
|
|
1249
|
+
// own, because pages stay in swap long after the reclaim that wrote them.
|
|
1250
|
+
if (
|
|
1251
|
+
view.residualSwapPercent !== null &&
|
|
1252
|
+
view.residualSwapPercent > 0 &&
|
|
1253
|
+
view.swapOutBytesPerSecond !== null &&
|
|
1254
|
+
view.swapOutBytesPerSecond < PRESSURE_POLICY.swap_activity.elevated_out_bytes_per_second
|
|
1255
|
+
)
|
|
1256
|
+
parts.push(
|
|
1257
|
+
`Swap still holds ${percent(view.residualSwapPercent)} of its pages from earlier reclaim, and the kernel is writing ${mib(view.swapOutBytesPerSecond)} to swap each second now.`,
|
|
1258
|
+
);
|
|
1259
|
+
if (view.unattributed !== null && view.unattributed > 0)
|
|
1260
|
+
parts.push(
|
|
1261
|
+
`Processes with no validated owner hold ${percent(view.unattributed)} of machine memory.`,
|
|
1262
|
+
);
|
|
1263
|
+
return parts.join(" ");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Guidance differs by the resource a workload consumes, and it never states a
|
|
1268
|
+
* safe number of agents, because no measurement here supports that claim.
|
|
1269
|
+
*/
|
|
1270
|
+
function buildGuidance(
|
|
1271
|
+
state: PressureState,
|
|
1272
|
+
limiting: PressureLimitingResource,
|
|
1273
|
+
): readonly PressureWorkloadGuidance[] {
|
|
1274
|
+
const matching: PressureWorkloadClass | null =
|
|
1275
|
+
limiting === "cpu"
|
|
1276
|
+
? "cpu-heavy"
|
|
1277
|
+
: limiting === "memory"
|
|
1278
|
+
? "memory-heavy"
|
|
1279
|
+
: limiting === "storage" || limiting === "io"
|
|
1280
|
+
? "storage-heavy"
|
|
1281
|
+
: null;
|
|
1282
|
+
return WORKLOAD_CLASSES.map((workloadClass): PressureWorkloadGuidance => {
|
|
1283
|
+
if (workloadClass === "lightweight") {
|
|
1284
|
+
return {
|
|
1285
|
+
workload_class: workloadClass,
|
|
1286
|
+
recommendation: "proceed",
|
|
1287
|
+
summary:
|
|
1288
|
+
state === "normal"
|
|
1289
|
+
? "Reads, small edits, and status commands are safe to run now."
|
|
1290
|
+
: "Keep to reads, small edits, and status commands, and expect them to be slower than usual.",
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
const recommendation: PressureRecommendedAction =
|
|
1294
|
+
state === "normal"
|
|
1295
|
+
? "proceed"
|
|
1296
|
+
: state === "critical"
|
|
1297
|
+
? "avoid-new-heavy-work"
|
|
1298
|
+
: workloadClass === matching
|
|
1299
|
+
? "avoid-new-heavy-work"
|
|
1300
|
+
: "limit-heavy-work";
|
|
1301
|
+
return {
|
|
1302
|
+
workload_class: workloadClass,
|
|
1303
|
+
recommendation,
|
|
1304
|
+
summary: heavyGuidance(workloadClass, recommendation),
|
|
1305
|
+
};
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function heavyGuidance(
|
|
1310
|
+
workloadClass: PressureWorkloadClass,
|
|
1311
|
+
recommendation: PressureRecommendedAction,
|
|
1312
|
+
): string {
|
|
1313
|
+
if (workloadClass === "cpu-heavy") {
|
|
1314
|
+
if (recommendation === "proceed") return "Builds and test suites can start now.";
|
|
1315
|
+
if (recommendation === "limit-heavy-work")
|
|
1316
|
+
return "Let a running build or test suite finish before starting another.";
|
|
1317
|
+
return "Do not start another build or test suite until the evidence clears.";
|
|
1318
|
+
}
|
|
1319
|
+
if (workloadClass === "memory-heavy") {
|
|
1320
|
+
if (recommendation === "proceed") return "Browser captures and page QA runs can start now.";
|
|
1321
|
+
if (recommendation === "limit-heavy-work")
|
|
1322
|
+
return "Let a running browser capture or QA run finish before starting another.";
|
|
1323
|
+
return "Do not start another browser capture or QA run until the evidence clears.";
|
|
1324
|
+
}
|
|
1325
|
+
if (recommendation === "proceed") return "Large writes, exports, and bundles can start now.";
|
|
1326
|
+
if (recommendation === "limit-heavy-work")
|
|
1327
|
+
return "Keep writes small and let a running export or bundle finish first.";
|
|
1328
|
+
return "Do not start a large write, export, or bundle until free space recovers.";
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function percent(value: number): string {
|
|
1332
|
+
return `${trim(value)} percent`;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function mib(value: number): string {
|
|
1336
|
+
return `${trim(value / BYTES_PER_MIB)} MiB`;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function gib(value: number): string {
|
|
1340
|
+
return `${trim(value / BYTES_PER_GIB)} GiB`;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function pages(value: number): string {
|
|
1344
|
+
return `${trim(value)} pages`;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function seconds(value: number): string {
|
|
1348
|
+
return `${trim(value / 1_000)} seconds`;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function samples(count: number): string {
|
|
1352
|
+
return count === 1 ? "1 sample" : `${count} samples`;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function trim(value: number): string {
|
|
1356
|
+
return String(Math.round(value * 10) / 10);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function round2(value: number): number {
|
|
1360
|
+
return Math.round(value * 100) / 100;
|
|
1361
|
+
}
|