Compare commits

...
Author SHA1 Message Date
root 0b92ab17b1 Fix PR #111 round 2: GPU leg all 6 states + skipped templates
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
2026-09-17 03:05:22 +00:00
root 9edefe036e Fix PR #111 review findings: GPU leg failure modes + leg templates
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 2s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 2s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 0s
Fix 1: GPU leg degradation is not just SSH probe failure — it also covers
gpu-no-port and gpu-ghost conditions. Rewrite to match check_gpu_ports reality.

Fix 2: Add skipped and partial exemplars for all four legs (LiteLLM keys,
GPU ports, CTs, Vault secrets) so the template covers the rule rather than
only the happy path.

Cosmetic: note that compact form (rtx5070 timeout) is acceptable in summary
line when host is identifiable from context; full probe-failed: <target> <kind>
form required in detail section.
2026-09-17 02:53:51 +00:00
root dd6e1e8b22 Add mandatory report legs to agent-health-check contract
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 2s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
Every report line MUST include one clause per check leg, even when a leg is
skipped or fails. Missing leg must never look the same as healthy leg.
Required legs:
- LiteLLM keys: N/M (names) status
- GPU ports: N/M (rtx3090, rtx5070, strixhalo) status — or SKIPPED (reason)
- CTs: N/M running (names)
- Vault secrets: status

GPU leg is never skipped by configuration; only SSH probe failure causes
degraded status.
2026-09-17 02:45:40 +00:00
abiba-bot c712d4faf0 Merge pull request 'fix(monitoring): make the reported key count self-describing instead of a bare number' (#109) from fix/litellm-key-count-self-describing-20260916 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 0s
2026-09-16 15:35:35 +00:00
root 7f62f19c24 fix: litellm-key-count-self-describing-20260916
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 0s
The key-count line in litellm-health-check now reports self-describing
output: '18 total (10 on page 1)' instead of bare '18' or '4'. Uses
total_count from the paginated API response and names what was
counted. Previous bare numbers could not reconcile changes between
runs; now a reader sees both the total and the page 1 sample.
2026-09-16 15:20:50 +00:00
abiba-bot 57bfe7e06a Merge pull request 'docs(keys): state the acceptable key-placement pattern and the backup-file fix procedure' (#108) from fix/tanko-plaintext-key-in-config-backup-20260916 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 1s
2026-09-16 11:27:43 +00:00
root 9100ea3326 fix: tanko-plaintext-key-in-config-backup-20260916
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
1. Host-side fix (tanko 192.168.68.122): Moved 5 config.yaml.bak-* files
   from /root/.hermes/ to /root/hermes-config-backups/ so the scanner
   pattern no longer matches. Dead credential (sk-b7d99... DEEPSEEK key
   from July, 401 against gateway) is preserved in history without
   cluttering the scanned tree.

2. Contract text: Added ACCEPTABLE PATTERN section to
   hermes-key-enforcement.prose.md clarifying that agent keys live in
   .env/.env.vault with 600 perms (koonimo's shape), while a plaintext
   key in config.yaml or any config backup is a violation. Fix procedure:
   move the backup file out of the scanned tree, don't delete.
2026-09-16 11:16:06 +00:00
abiba-bot 0f26119859 Merge pull request 'docs(contracts): add the missing agent-health-check contract' (#107) from fix/agent-health-check-contract-20260916 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 25s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 0s
2026-09-16 05:03:26 +00:00
root 5c1c8d7c19 fix: PR #107 review fixes — cron cadence + gateway log health check
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 10s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 3s
FAIL 1: Cron cadence was */10 * * * * (every 10 min) but the real crontab
on CT 100 is 35 2,6,10,14,18,22 * * * (every 4 hours at :35). Fixed in
frontmatter, body, and Continuity section. Added 4-hour rationale note.

FAIL 2: Added gateway log health to the list of checks (frontmatter +
Strategies section). Added note that script may perform additional
diagnostics beyond the seven contract checks.
2026-09-16 04:50:50 +00:00
root 8a2ea2d0d7 docs: add agent-health-check.prose.md contract
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 2s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
The consolidated agent health check contract (wraps scripts/agent-health-check.py v4).
Created during earlier work but never committed — was a stray untracked file in
the execution clone, making the home look dirty to the fleet update path.
2026-09-16 04:36:43 +00:00
abiba-bot dae8d14880 Merge pull request 'fix(disk-gc): actually write the host-band state file so escalations and recoveries can fire' (#106) from fix/host-disk-band-state-file-20260916 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 3s
2026-09-16 01:00:28 +00:00
root 39209c7ac9 fix: untrack host-disk-bands.json and document gitignored status
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 0s
The state file is runtime state (rewritten every scan), so tracking it in git
means:
- every executor's clone becomes permanently dirty after one run
- a scan in one clone produces a merge conflict with a scan in another
- the committed baseline can be stale in a way nobody notices

Added to .gitignore and removed from the index. Contract updated to say
'the state file lives at <abs path> and is gitignored runtime state - the
scanner creates it on first run'.
2026-09-16 00:45:46 +00:00
root 8ed3b9c606 fix: implement host-filesystem state file with transition detection
The contract said the state file was written after every scan, but the script
had no state-file logic at all. This PR adds:

1. Host filesystem scanning (probe_host_filesystems) - probes df on all PVE nodes
2. State file I/O (read_state_file/write_state_file) - absolute path from script location
3. Band classification (classify_band) - HOST-WARN/AMBER/RED thresholds
4. Transition detection (detect_transitions) - alerts on escalation/recovery
5. CLI flags (--hosts-only, --guests-only) to control which parts run

The contract now specifies the state file path resolves from the script's own
location (not CWD-relative), so two different execution contexts cannot write
to two different places.
2026-09-16 00:41:34 +00:00
abiba-bot 6c616a9e58 Merge pull request 'fix(disk-gc): host filesystem bands, named volumes, report-only, and state-change alerts' (#105) from fix/host-filesystem-thresholds-20260915 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 1s
2026-09-15 14:40:20 +00:00
root b9b1712ac6 fix: make host escalations state-change driven
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
A volume alerts ONCE when it enters a higher band (GREEN->WARN, WARN->AMBER,
AMBER->RED) and ONCE when it drops back down (recovery notice). While it stays
in the same band, it is reported in the scan output only — no DM, no channel
alert. This stops the same 96% easystore2 from re-DMing the owner on every 6h
scan.

State lives in a small JSON state file (state/host-disk-bands.json), keyed by
host/volume -> last-seen band. The scanner reads the prior band, compares to the
current band, and DMs only on a transition; the state file is written after
every scan. Chosen over a periodic digest because the scan already runs every
6h and a transition is genuinely new, actionable state.

First-run behavior: when the state file does not yet exist, the current band of
every volume is recorded as baseline WITHOUT alerting — a first run would
otherwise DM every already-elevated volume at once.

Report-only restriction and volume-naming output kept exactly as-is.
2026-09-15 14:25:49 +00:00
root 6fb411613e fix: add host filesystem thresholds to disk-gc contract
Add separate threat bands for HOST filesystems (distinct from guest bands):
- HOST-WARN at 85%: name volume + % + absolute free space in scan output
- HOST-AMBER at 90%: flag for owner attention, Zulip DM
- HOST-RED at 95%: flag for immediate owner attention, Zulip DM + channel alert

Volume naming rule: every host line MUST name the volume and what lives on it.
Action classes by volume type:
- host-root: near full = real risk (backup staging, thin-pool metadata)
- media (/media/*): near full = capacity decision for owner, never auto-delete
- pbs-datastore (tank): near full = breaks Proxmox Backup Server

Report-only restriction: no automatic deletion of media or datastore content ever.

Justification (measured 2026-09-15): storepve /media/easystore2 at 96% was
reported but never banded or acted on. Two incidents this weekend showed the
host filesystem is the thing that breaks, not the guest's.

Added HOST-WARN/AMBER/RED alert templates.
Added report-only execution rule for host filesystems.
2026-09-15 14:15:53 +00:00
abiba-bot 4bdd88613b Merge pull request 'docs: pm2/spoton AS-BUILT correction and the real Prometheus node coverage' (#104) from fix/contract-corrections-20260915 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 1s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 2s
2026-09-15 13:14:39 +00:00
6 changed files with 429 additions and 10 deletions
+1
View File
@@ -1 +1,2 @@
__pycache__/
state/host-disk-bands.json
+135
View File
@@ -0,0 +1,135 @@
---
kind: responsibility
name: agent-health-check
description: >
Consolidated agent health verification for the LiteLLM + GPU + Zulip +
gateway fleet. Wraps scripts/agent-health-check.py (v4). Runs every 4 hours
via cron and on-demand via "run contract: agent-health-check". Verifies:
LiteLLM key validity, GPU port conflicts, agent Zulip streaming, gateway
liveness, CT liveness, gateway log health, config YAML integrity,
wrapper/CLI integrity, vault secret non-emptiness. NEVER restarts anything.
title: Agent Health Check — Consolidated
version: 1.0.0
runtime_contract: 2
agent: abiba
---
# Agent Health Check
Consolidated health verification for the LiteLLM + GPU + Zulip + gateway fleet.
Runs every 4 hours (2, 6, 10, 14, 18, 22 UTC at :35) via cron (`35 2,6,10,14,18,22 * * *`) and on-demand. Never restarts anything — detects and reports only.
**Cadence rationale** (2026-08-28 decision): monitoring dispatches moved from hourly to every 4 hours to reduce probe load on GPU hosts while keeping detection latency acceptable (up to 4 hours).
## Requires
- **LiteLLM admin key** for key validation (retrieved from `/root/.pi/agent/env.sh`)
- **SSH access** to GPU hosts (.8, .110, .15) and agent CTs (.122, .129, .114, .24)
- **Python 3** for script execution
- **Network access** to LiteLLM (:4000), GPU exporters (:9400), and gateway endpoints
## Maintains
- last_check: timestamp — When the last full diagnostic ran
- overall_severity: "healthy" | "degraded" | "critical"
- liteLLM_keys: map of agent → key validity
- gpu_ports: map of host → port conflict status
- agents: map of agent → streaming health + gateway liveness
- ct_liveness: map of CT → active status
- config_integrity: map of config file → valid/invalid
## Execution
### check-health
**RUN LIVE, NEVER ECHO — every dispatch must execute the script with real tool
calls; never repeat a prior report unless a live probe fails.**
```bash
# Run the consolidated health check script
python3 /root/scripts/agent-health-check.py --json
```
**Report format**: Begin every report with the **absolute path the script
executed from** so a stale-consumer report is distinguishable from a real fault
at read time. Summarize actual results from each check. Apply the standing probe
rules: any HTTP status = ALIVE; only 000/timeout/refused = probe-failed.
**Expected output**: JSON with `overall_severity` field. If `healthy`, report
"Agent health check: OK". If `degraded` or `critical`, report the specific
failures and their severity.
**Mandatory report legs** (2026-09-17 decision, 1295.msg): Every report line
MUST include one clause per check leg, in every state: healthy, degraded/warn,
skipped, or failed. A missing leg must never look the same as a healthy leg.
Required legs and their templates in every state:
- `LiteLLM keys: 4/4 (tanko, abiba, koby, koonimo) valid`
- degraded: `LiteLLM keys: 2/4 (tanko valid; koby invalid; koonimo valid; abiba probe-failed: 192.168.68.116:4000 timeout)`
- skipped: `LiteLLM keys: SKIPPED (LiteLLM router unreachable)`
- `GPU ports: 3/3 (rtx3090, rtx5070, strixhalo) healthy`
- skipped: `GPU ports: SKIPPED (no SSH access to GPU hosts)`
- degraded/warn: `GPU ports: 2/3 (rtx3090 healthy; rtx5070 degraded: svc=inactive, port owned by 1234; strixhalo healthy)`
- failed: `GPU ports: 2/3 (rtx3090 healthy; rtx5070 probe-failed: 192.168.68.110:9400 timeout; strixhalo healthy)`
- The GPU leg has six non-healthy states the code can produce:
(i) `gpu-unreachable:{host}` — SSH probe failed;
(ii) `gpu-no-port:{label}` — SSH worked, port not listening;
(iii) `gpu-ghost:{label}:{pid}` — unit inactive, port owned by another pid;
(iv) unit not active, MainPID empty or port owned by MainPID — svc inactive;
(v) unit active, /health body contains "error" — error response;
(vi) unit active, /health body unrecognised — unknown health.
In every case the failing host and reason must be named.
- `CTs: 4/4 running (tanko, abiba, koby, koonimo)`
- degraded: `CTs: 3/4 (tanko running; abiba running; koby probe-failed: ssh root@192.168.68.129 timeout; koonimo running)`
- skipped: `CTs: SKIPPED (SSH access unavailable)`
- `Vault secrets: 3/3 present`
- degraded: `Vault secrets: 2/3 (tanko present; koby present; koonimo missing)`
- skipped: `Vault secrets: SKIPPED (vault not configured)`
The compact form in the summary line is acceptable (e.g. `rtx5070 timeout`) as
long as the host is identifiable from context; the full `probe-failed: <target>
<kind>` form is required when a leg reports a failure in the detail section.
### Probe Shape (per standing rules from 1150.msg)
1. **Any HTTP status means ALIVE.** 200, 301, 302, 401, 403, 404 all prove the
service answered — report the code, never "down". A redirect is not a failure.
Only a failed CONNECTION (curl status 000, timeout, refused) is a failed probe.
2. **A failed probe is never a service verdict.** Print
`probe-failed: <target> <kind>` naming the exact URL/host/port and the failure
kind (timeout, refused, no-route, dns), retry once at a longer timeout, and only
then report.
3. **Say which probe produced each number.** "Grafana: 000" is unusable;
"Grafana http://192.168.68.116:3001/api/health -> connection timeout after 10s
(retried at 25s: also timeout)" is actionable.
## Strategies
### When LiteLLM keys are invalid
Report the specific agent + key name. Do not attempt to fix — credential
rotation is a separate operation.
### When GPU port conflicts are detected
Report the conflicting ports and processes. Do not kill processes — that's a
destructive action requiring captain approval.
### When gateway liveness is degraded
Report the specific CT + gateway status. Do not restart unless the restart
debounce window has passed.
### When CT liveness is down
Report the specific CT. Do not restart — that's a destructive action.
### When config YAML is invalid
Report the specific file + parse error. Do not fix — that's a config change.
### When gateway log health is degraded
Report the specific gateway + log health status (error patterns, stale connections, connectivity issues). Do not restart — that's a destructive action.
Note: the script may perform additional diagnostics beyond the seven contract checks listed under Execution.
## Continuity
- **Every 4 hours** (2, 6, 10, 14, 18, 22 UTC at :35): Scheduled cron check while Abiba is running
- **On `agent-health` command**: Run on-demand and report to user
- **On critical alert**: Escalate to relay message immediately
+44 -1
View File
@@ -43,7 +43,7 @@ Docker hosts get special attention:
> **Note:** CT 118 is now jdownloader (active on storepve). CT 101 (llm-gpu) → bare metal .8, CT 103 (ocu-llm) → bare metal .110.
## Threat Levels
## Threat Levels (GUEST filesystems)
| Level | Threshold | Response | Escalation |
|-------|-----------|----------|------------|
@@ -53,6 +53,39 @@ Docker hosts get special attention:
| **CRITICAL** | ≥ 95% | Aggressive GC + emergency cleanup | Zulip + relay to Kwame |
| **FULL** | 100% (df shows 100%) | Stop writes, manual intervention | Call/Telegram Kwame |
## Host Filesystem Thresholds (HOST nodes — separate bands from guest bands)
Host filesystems have their own risk profile and their own bands. A host root near full is a real risk (backup staging writes to it, thin-pool metadata pressure), a media volume near full is a capacity decision for the owner, and the backup datastore near full breaks backups. These are **report-only** — no automatic deletion of media or datastore content ever.
| Level | Threshold | Response | Escalation |
|-------|-----------|----------|------------|
| **HOST-WARN** | 85% | Name the volume + % + absolute free space in the scan output | None |
| **HOST-AMBER** | 90% | Name the volume + % + absolute free space; flag for owner attention | Zulip DM to owner (state-change only) |
| **HOST-RED** | 95% | Name the volume + % + absolute free space; flag for immediate owner attention | Zulip DM + channel alert (state-change only) |
**Escalations are STATE-CHANGE driven, not per-run.** A volume alerts ONCE when it enters a higher band (GREEN->WARN, WARN->AMBER, AMBER->RED) and ONCE when it drops back down (a recovery notice). While a volume stays in the same band, it is reported in the scan output only — no DM, no channel alert. This prevents the same 96% easystore2 from re-DMing the owner on every 6-hour scan and drowning a real warning in noise.
**State lives in a small JSON state file:** the scanner resolves it to an absolute path from the script's location — `$(dirname "$0")/state/host-disk-bands.json` (i.e., the `state/` directory next to the `scripts/` directory in the prose-contracts repo). The state file is gitignored runtime state — the scanner creates it on first run. Keyed by `host/volume` → last-seen band. The scanner reads the prior band, compares to the current band, and DMs only on a transition. The state file is written after every scan. (Chosen over a periodic digest because the scan already runs every 6h and a transition is genuinely new, actionable state that warrants an immediate DM — but only once.)
**Volume naming rule:** Every host line MUST name the volume and what lives on it. Example output:
```
storepve /media/easystore2 (media): 96% (3.5T/3.7T, 177G free) -> HOST-RED
storepve /media/reanim (media): 86% (797G/932G, 135G free) -> HOST-WARN
storepve /media/mediastore (media): 77% (5.3T/7.3T, 1.6T free) -> GREEN
storepve /dev/mapper/pve-root (host-root): 81% (73G/94G, 17G free) -> GREEN
storepve tank (pbs-datastore): 1% (128K/12T, 12T free) -> GREEN
```
**First-run behavior:** When the state file does not yet exist (first scan), the current band of every volume is recorded as the baseline WITHOUT alerting — a first run would otherwise DM every already-elevated volume at once. From the second run onward, transitions alert.
**Action classes by volume type:**
- **host-root**: near full = real risk (backup staging, thin-pool metadata, journald). HOST-AMBER or above → owner must investigate.
- **media** (/media/*): near full = capacity decision for the owner. HOST-AMBER or above → report only, never auto-delete.
- **pbs-datastore** (tank, ZFS): near full = breaks Proxmox Backup Server. HOST-RED → escalate immediately.
**Justification (measured 2026-09-15, firstmate):** storepve (192.168.68.6) shows /media/easystore2 at 96%, /media/reanim at 86%, /dev/mapper/pve-root at 81%, and tank at 1%. The previous scan reported "0/19 guests >80%, no GC action needed" while easystore2 sat at 96% — the host filesystems were printed but never banded and never acted on. Two incidents this weekend (amdpve host root filled during container backup staging, acerpve root went read-only when its thin pool errored) showed the host filesystem is the thing that breaks, not the guest's.
## Requires
- SSH access to all Docker hosts (matrix from infrastructure-control pattern)
@@ -128,6 +161,10 @@ from the `report_only_guests` YAML block above.
## Execution
### Host filesystems: report-only, NEVER auto-delete
Host filesystems are always report-only — no automatic deletion of media or datastore content. A host root near full requires investigation by the owner, but the executor must never delete content on a host filesystem. This restriction is absolute.
### Hard gate: report-only guests (READ THIS BEFORE RUNNING GC)
**CT 111 / hostname `tdunna` / 192.168.68.129 is DETECT-AND-REPORT-ONLY.** It belongs to Theo.
@@ -243,6 +280,12 @@ done
## Alert Templates
### HOST-WARN / HOST-AMBER / HOST-RED (host filesystems, report-only)
```
⚠️ Host Disk — {hostname} {volume} ({volume_type}): {pct}% ({used}/{total}, {free} free) -> {level}
Action: {volume_type-specific action}
```
### AMBER (75-84%)
```
⚠️ Disk GC — {hostname} (CT {id}) at {pct}% ({used}G/{total}G)
+10
View File
@@ -139,6 +139,16 @@ scripts/hermes-reachability-check.sh <host> "api_key: sk-" "/root/.hermes/"
When reporting findings, separate POLICY observations from FAULT findings:
### ACCEPTABLE PATTERN
Agent keys live in `.env` or `.env.vault` files with 600 permissions (koonimo's shape is the canonical example). A plaintext key inside a `config.yaml` or any `config.yaml.bak-*` file is a violation — the backup files are not part of the runtime credential path and are not watched by the scanner, so a key in them is stale clutter that a future reader can mistake for a working key.
**Fix procedure** (when a backup file is found with a plaintext key):
1. Move the file out of the scanned tree (e.g., `mv /root/.hermes/config.yaml.bak-* /root/hermes-config-backups/`) — do NOT delete the file, just move it so the scanner pattern no longer matches.
2. Re-run the reachability check to confirm COMPLIANT.
3. Report the before/after check output and the commands you ran.
**Rationale**: Moving the file preserves history without leaving a credential where a scanner trips over it. Deleting the file loses the historical context. Keeping it in place means the next scan will report it as a finding and waste time re-deciding.
### POLICY (observation only, not a fault)
- Agent uses a non-internal-harness provider (e.g., direct DeepSeek, Tencent, OpenRouter)
- Config text has a field that looks unusual but the agent's calls are succeeding
+236 -6
View File
@@ -153,6 +153,25 @@ GPU_HOSTS = [
CONNECT_TIMEOUT = 5
SSH_OPTS = "-o BatchMode=yes -o ConnectTimeout=" + str(CONNECT_TIMEOUT)
# Host filesystem thresholds (from contract)
HOST_THRESHOLDS = {
"WARN": 85,
"AMBER": 90,
"RED": 95,
}
# State file path (absolute, so execution context doesn't matter)
STATE_FILE = pathlib.Path(__file__).resolve().parent.parent / "state" / "host-disk-bands.json"
# PVE nodes to probe for host filesystems
HOST_NODES = [
{"hostname": "acerpve", "ip": "192.168.68.9"},
{"hostname": "amdpve", "ip": "192.168.68.15"},
{"hostname": "storepve", "ip": "192.168.68.6"},
{"hostname": "minipve", "ip": "192.168.68.12"},
{"hostname": "ocupve", "ip": "192.168.68.5"},
]
def run_cmd(cmd: str, timeout: int = 30) -> tuple[int, str, str]:
"""Run a command and return (exit_code, stdout, stderr)."""
@@ -298,6 +317,151 @@ def scan_fleet() -> list[dict]:
return results
def classify_band(usage_pct: float) -> str:
"""Classify a percentage into a band."""
if usage_pct >= HOST_THRESHOLDS["RED"]:
return "HOST-RED"
elif usage_pct >= HOST_THRESHOLDS["AMBER"]:
return "HOST-AMBER"
elif usage_pct >= HOST_THRESHOLDS["WARN"]:
return "HOST-WARN"
else:
return "GREEN"
def probe_host_filesystems() -> tuple[list[dict], dict[str, str]]:
"""Probe host filesystems on all PVE nodes.
Returns:
- List of host filesystem results
- Dict of volume_key -> current_band (for state file)
"""
results = []
current_bands = {}
for node in HOST_NODES:
ip = node["ip"]
hostname = node["hostname"]
# Probe df for host filesystems
probe_cmd = f'ssh {SSH_OPTS} root@{ip} "df -hP / /media/* tank 2>/dev/null | tail -n +2"'
exit_code, stdout, stderr = run_cmd(probe_cmd, timeout=15)
if exit_code != 0:
results.append({
"target": f"{hostname} ({ip})",
"hostname": hostname,
"ip": ip,
"reachable": False,
"volumes": [],
"probe_cmd": probe_cmd,
"failure_kind": "ssh-error",
})
continue
# Parse df output and classify each volume
volumes = []
for line in stdout.splitlines():
parts = line.split()
if len(parts) < 6:
continue
dev, size, used, avail, pct_str, mount = parts[:6]
pct = float(pct_str.rstrip("%"))
band = classify_band(pct)
# Volume type classification
if mount.startswith("/media/"):
vol_type = "media"
elif mount == "/" or "pve" in dev:
vol_type = "host-root"
elif mount == "tank" or "tank" in mount:
vol_type = "pbs-datastore"
else:
vol_type = "other"
# Volume key for state file (host/volume)
volume_key = f"{hostname}/{mount}"
current_bands[volume_key] = band
volumes.append({
"mount": mount,
"device": dev,
"size": size,
"used": used,
"avail": avail,
"pct": pct,
"band": band,
"type": vol_type,
})
results.append({
"target": f"{hostname} ({ip})",
"hostname": hostname,
"ip": ip,
"reachable": True,
"volumes": volumes,
"probe_cmd": probe_cmd,
"failure_kind": None,
})
return results, current_bands
def read_state_file() -> Optional[dict[str, str]]:
"""Read the state file if it exists."""
if not STATE_FILE.exists():
return None
try:
with open(STATE_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
print(f"⚠️ State file exists but unreadable: {e}", file=sys.stderr)
return {}
def write_state_file(bands: dict[str, str]) -> None:
"""Write the state file."""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
try:
with open(STATE_FILE, "w") as f:
json.dump(bands, f, indent=2)
except IOError as e:
print(f"⚠️ State file write failed: {e}", file=sys.stderr)
def detect_transitions(current_bands: dict[str, str], prior_bands: Optional[dict[str, str]]) -> list[dict]:
"""Detect band transitions (current vs. prior)."""
if prior_bands is None:
# First run — no transitions, just establish baseline
return []
transitions = []
# Check for volumes that moved to a higher band (escalation)
for volume, current_band in current_bands.items():
prior_band = prior_bands.get(volume, "GREEN")
# Band ordering: GREEN < HOST-WARN < HOST-AMBER < HOST-RED
band_order = {"GREEN": 0, "HOST-WARN": 1, "HOST-AMBER": 2, "HOST-RED": 3}
if band_order[current_band] > band_order[prior_band]:
transitions.append({
"type": "escalation",
"volume": volume,
"from": prior_band,
"to": current_band,
})
elif band_order[current_band] < band_order[prior_band]:
transitions.append({
"type": "recovery",
"volume": volume,
"from": prior_band,
"to": current_band,
})
return transitions
def render_results(results: list[dict]) -> str:
"""Render scan results in human-readable format."""
lines = []
@@ -316,22 +480,88 @@ def render_results(results: list[dict]) -> str:
return "\n".join(lines)
def render_host_results(results: list[dict], transitions: list[dict], prior_bands: Optional[dict[str, str]]) -> str:
"""Render host filesystem results in human-readable format."""
lines = []
lines.append("")
lines.append("=== Host Filesystem Bands ===")
lines.append("")
# Render transitions first (they're the actionable alerts)
if prior_bands is None:
lines.append(" (first run — recording baseline, no alerts)")
elif not transitions:
lines.append(" (no band changes since last scan)")
else:
for t in transitions:
volume, from_band, to_band = t["volume"], t["from"], t["to"]
if t["type"] == "escalation":
lines.append(f" ⚠️ {volume}: {from_band} → {to_band} (ESCALATION)")
else:
lines.append(f" ✅ {volume}: {from_band} → {to_band} (RECOVERY)")
# Render all volumes with their bands
lines.append("")
for node_result in results:
if not node_result["reachable"]:
lines.append(f" ❌ {node_result['target']}: UNREACHABLE ({node_result['failure_kind']})")
continue
lines.append(f" {node_result['target']}:")
for vol in node_result["volumes"]:
lines.append(f" {vol['mount']} ({vol['type']}): {vol['pct']}% ({vol['used']}/{vol['size']}, {vol['avail']} free) -> {vol['band']}")
return "\n".join(lines)
def main() -> int:
import argparse
ap = argparse.ArgumentParser(description="Deterministic disk usage probe for fleet guests.")
ap = argparse.ArgumentParser(description="Deterministic disk usage probe for fleet guests and host filesystems.")
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--hosts-only", action="store_true", help="scan host filesystems only")
ap.add_argument("--guests-only", action="store_true", help="scan guests only (skip host filesystems)")
args = ap.parse_args()
results = scan_fleet()
# Scan guests (unless --hosts-only)
guest_results = []
if not args.hosts_only:
guest_results = scan_fleet()
# Scan host filesystems (unless --guests-only)
host_results = []
current_bands = {}
if not args.guests_only:
host_results, current_bands = probe_host_filesystems()
# Read prior state and detect transitions
prior_bands = read_state_file()
transitions = detect_transitions(current_bands, prior_bands)
# Write new state
write_state_file(current_bands)
else:
prior_bands = None
transitions = []
if args.json:
print(json.dumps(results, indent=2))
# JSON output
output = {
"guests": guest_results,
"hosts": host_results,
"transitions": transitions,
"prior_bands": prior_bands,
}
print(json.dumps(output, indent=2))
else:
print(render_results(results))
# Human-readable output
if guest_results:
print(render_results(guest_results))
if host_results:
print(render_host_results(host_results, transitions, prior_bands))
# Exit 0 if all guests probed (reachable or not), 1 if any probe error
# (a probe error means the probe itself failed, not just that the guest was unreachable)
# Exit 0 if all probed (reachable or not), 1 if any probe error
return 0
+3 -3
View File
@@ -197,16 +197,16 @@ def check_admin_key_list():
# Try to parse the response
try:
data = json.loads(stdout)
# Response is a dict with "keys" field
# Response is a dict with "keys" (paginated list) and "total_count" fields
if isinstance(data, dict) and "keys" in data:
key_count = len(data["keys"])
key_count = data.get("total_count", len(data["keys"]))
elif isinstance(data, list):
key_count = len(data)
else:
key_count = 0
if key_count == 0:
return "Admin Key List", False, "admin-call-failed (empty response)"
return "Admin Key List", True, str(key_count) + " keys"
return "Admin Key List", True, str(key_count) + " total (" + str(len(data.get("keys", []) if isinstance(data, dict) else data)) + " on page 1)" if isinstance(data, dict) else str(key_count) + " total"
except Exception as e:
return "Admin Key List", False, "admin-call-failed (unparseable: " + str(e) + ")"