Disk-pressure investigation could not run unattended: every filesystem read fell back to manual operator commands #26
Labels
No labels
burndown-2026-08
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/advocate
role/director
role/exec
role/frontend
role/gamedev
role/human
role/platform
role/qa
role/science
role/sysadmin
state
ambient
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/node-stats-mcp#26
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
During a disk-pressure investigation on kai-server (2026-08-17, root hit 90.4%), the agent had to ask Kai to hand-run eight read-only filesystem commands in her terminal. Every one of them was an ordinary read that the tooling is supposed to cover. This cost real operator time in the middle of a live incident.
Relief itself succeeded (90.4% to 83.6%, 35.3 GB reclaimed by restarting two runner pods to release
emptyDirscratch). This issue is only about why the diagnosis needed a human at the keyboard.Every manual command that was requested
bash infrastructure/scripts/forgejo-storage-measure.sh- guarded surface exposes nokubectl exec, and the skill explicitly routes this to an attended operator.sudo du -sh /var/lib/rancher/k3s/storage/pvc-e0167dde-..._forgejo_forgejo-data/data/attachments- no attachments domain in the bounded report, andstat_pathrefused the path.sudo du -x --max-depth=1 /- node-statsrootprofile never completes.sudo du -xh --max-depth=2 /var --exclude='/var/lib/kubelet/pods'- same.sudo du -xh --max-depth=1 /var/lib/kubelet- same, plus path outside the readable-root allowlist.sudo du -xh --max-depth=1 /var/lib/kubelet/pods --exclude='*kubernetes.io~local-volume*'- same.kubectl -n forgejo rollout restart statefulset/forgejo-runner-deploy-sirens-discord-ops- blocked by the Claude Code classifier despite aosguard granting therolloutverb.kubectl -n forgejo rollout restart statefulset/forgejo-runner-deploy-ward-mcp- same.Also offered but not run, for the same class of reason:
kubectl delete pvc docker-lib-forgejo-runner-build-0(aosguard exposes nodeleteverb) and two read-onlypsqlqueries against theattachmenttable (noexec).Root causes
A. node-stats-mcp is OOMKilled on the wide profiles
The
root,var, andvar-libprofiles never return. The container dies partway through, every time.Restart timestamps matched the scan restarts exactly (05:00:54, 05:07:04, 05:10:51), each surviving about four minutes. The
rootprofile is configured formax_entries: 5000000; the tree holds millions of inodes and the scan accumulates them in memory. 512Mi does not fit that budget.Bounded profiles are fine -
k3s-storagecompleted 908,948 entries in 139s. The failure is specific to the wide profiles and it is deterministic, not flaky.Fix is either raising the limit to fit the configured budget or lowering the wide profiles' entry cap so the two agree. Right now the config promises a scan the container cannot survive.
B. readable-root allowlist is too narrow
stat_pathrejected/var/lib/kubeletand/usr/share/ollama/.ollama/modelsas outside the allowlist./var/lib/kubeletturned out to hold the actual cause of the incident.C. No pod-ephemeral domain in the bounded report - this is the big one
kai-disk-pressure-reportcovers containerd, local-path PVCs, logs, journald, game saves, and kai home. Those summed to 287 GB against 456 GB used.The gap was pod ephemeral storage under
/var/lib/kubelet/pods: 62 GiB ofemptyDirand writable container layers, invisible to every domain. That is where the incident actually lived, and releasing it is what resolved it.The tool built to find disk pressure could not see the thing causing the disk pressure. A
pod_ephemeral_bytesdomain would have made this a two-minute diagnosis.D. No attachments split in forgejo-storage-measure.sh
The script measures packages exhaustively (blob references, ownership, age buckets, cleanup rules) but reports
data/attachmentsas a bareduline. Attachments turned out to be 38 GB with no ownership attribution available. It needs the same treatment: a query againstattachmentjoined torepository, split by release vs issue, with age buckets.E. Bind-mount double counting has no guardrail
du -xdoes not exclude kubelet's bind mounts of local-path PVCs, because they are the same filesystem. A plaindu -x /reported 567 GiB on a filesystem holding 401 GiB. The MCP dedupes and reportsdeduplicated_entries; rawdudoes not, and the runbook does not warn about it. Anyone reading that output at face value goes hunting for 166 GiB that does not exist. Nearly happened here.F. Harness and policy disagree on rollout restart
aosguard grants
rollout, and the Claude Code classifier blocked it anyway. Either the action is permitted or it is not, and right now the agent gets to the point of executing before finding out.G. aosguard exposes no delete verb
May well be intentional. Worth stating explicitly in the runbook so an agent proposes the operator path from the start instead of discovering it mid-incident.
Proposed work
node-stats-mcpmemory limit to fit the configured scan budget, or lowermax_entrieson the wide profiles so config and limit agree (deploy)/var/lib/kubeletto the readable-root allowlist (node-stats-mcp)pod_ephemeral_bytesdomain tokai-disk-pressure-report, excludingkubernetes.io~local-volumebind mounts (infrastructure)forgejo-storage-measure.shwith DB-backed ownership (infrastructure)--excludeincantation (agentic-os-kai)rollout restartdeleteboundary in the skill so the operator handoff is proposed upfrontSeparate follow-ups from the same incident
Filed here for traceability, not part of this issue's scope:
coilyco.io/runner-scratch: recyclelabel onforgejo-runner-deploy-sirens-discord-opsandforgejo-runner-tap-writer-scoped. The unlabelled runner reached 24 GiB of scratch over 3d8h while its recycled peers sat at 4.6 to 9 GiB after 13h. The reclaimed space will rebuild within days without this. Two-line change indeploy.agentic-os. Roughly 3 GB/day with nothing retiring anything.Two PRs cover the checkbox list. Both are open for review, neither is merged.
node-stats-mcpPR #27 - root causes A, B, and C.infrastructurePR #859 - root causes C, D, E, F, and G.A - the OOM is structural, not a budget mismatch
_scan_treekept every scanned file inode in its dedup set, so the set grew with the tree rather than with hard links. Measured: 162 MB per million(dev, ino)tuples, so the 5,000,000-entry budget needs about 810 MB for that set alone, before any traversal state, against a 512Mi limit.The fix removes the term instead of re-tuning around it. Only a multiply-linked file can be reached twice, and same-filesystem bind and subtree mounts are already excluded upstream, so inodes are tracked only when
st_nlink > 1. That is the linedudraws and it leaves the documented hard-link contract unchanged. Live evidence for how little that costs: the currentk3s-storagesnapshot reportsdeduplicated_entries: 202across 344,464 entries, all of it in the Docker layer store. The packed inode key and astrtraversal stack take the rest.So neither proposed lever is needed: the limit stays 512Mi and
max_entriesstays 5,000,000, because the scan now fits. If a liverootscan still dies, that is new evidence and a different fix.B - I did not add
/var/lib/kubeletto the readable roots, and I recommend against itNODE_STATS_READABLE_ROOTSgatesread_text_headas well asstat_path, through the shared_resolve_readable./var/lib/kubelet/pods/*/volumes/kubernetes.io~secret/and~projected/hold decrypted Kubernetes Secrets and service-account tokens as plaintext files, so granting that root would make every mounted secret on the node readable through an MCP tool call. That contradicts this repo's ownAGENTS.mdsafety rule.The
pod-ephemeralprofile answers what the incident actually needed - byte attribution under that tree - with no file-read grant. If path-levelstatthere is still wanted, the clean shape is a separate stat-only allowlist rather than widening this one. Worth its own issue if you want it.C - covered on both sides
node-stats-mcpgains apod-ephemeralprofile at/var/lib/kubelet/pods; mount exclusion already drops the bind-mounted local-path PVCs, pinned by a new test. Nodeploychange: it sets noNODE_STATS_HOST_USAGE_PROFILESoverride, so the new default ships with the image.kai-disk-pressure-reportgainspod_ephemeral_byteswith the*kubernetes.io~local-volume*and*volume-subpaths*excludes, and--detailedresolves each pod UID to its namespace and pod name.D, E, G - as specified
Attachment reports join
attachmenttorepository, split by release, issue, and unattached, with 7, 30, and 90-day age buckets, plus orphaned-row and on-disk file counts to reconcile against each other. Column names were checked against Forgejo'smodels/repo/attachment.gorather than recalled. A newraw-du-guardrails.mdreference carries the 567-GiB-on-a-401-GiB-filesystem story, the correct exclude incantation, and the no-delete/ no-execboundaries.F - the premise is wrong, and the correction is the useful part
There is no classifier disagreeing with aosguard.
Bash(kubectl *)is a deliberate fleet-wide deny, installed byagentic-os/scripts/apply-base-claude-settings.pyon the stated ground that live-infrastructure CLIs belong to a guarded verb.aosguard ops kubectl rollout restartdoes not match that rule and was available the whole time. The incident agent reached for barekubectland read a designed deny as a surprise. That is documented now, so the next agent picks the guarded verb first.Actually changing the deny would be a live-system permission decision, so it stays yours rather than mine.
Not touched
The two follow-ups in the issue body are still open: the missing
coilyco.io/runner-scratch: recyclelabel on two runners, and the zero Forgejo package cleanup rules. Both aredeployand Forgejo-admin work, out of this issue's stated scope.Correcting my own work on item D. Kai's read is right: adding attachment queries to
forgejo-storage-measure.shimproved what a human sees when they run it and did nothing about the fact that a human has to run it. That script needskubectl exec, and no amount of extra SQL in it changes that. Measured against this issue's title, that part was worth close to nothing.The reason it stayed manual turned out to be smaller than "no
exec". Forgejo's data sits on a local-path PVC on the host, in the same tree the MCP already walks and already totals at 123 GB. The only thing standing between an agent and thedata/git/packagessplit was reporting granularity:scan_usage_profilereported immediate children with recursive totals and nothing below that, so a per-levelduwas the only way down._scan_pressure_childrenhas the same one-level shape.So
max_depthis now onUsageProfile, andk3s-storageandpod-ephemeralship at depth 3. That reaches claim ->data->attachmentsand pod ->volumes-> volume type, with no pod access.Depth changes reporting, never the walk.
_scan_nodereuses the same flat_scan_treefor everything below the reported levels and absorbs its totals, so the numbers cannot drift between views. Verified on a fixture:A regression test scans one tree at both depths and asserts identical bytes and entry counts, because a depth that moved a byte would make both views untrustworthy.
The eight commands, honestly
forgejo-storage-measure.sh- byte half unattended, ownership half still attended. Which repository, which release or issue, and which age bucket own the bytes lives only in PostgreSQL. The attachment SQL is now correctly framed as the attended half rather than as a fix.du -sh .../data/attachments- unattended,k3s-storagedepth 3.du -x --max-depth=1 /- unattended, therootprofile completes now.du -xh --max-depth=2 /var- unattended by chainingvarintovar-lib.du -xh --max-depth=1 /var/lib/kubelet- unattended,var-libpluspod-ephemeral.du -xh --max-depth=1 /var/lib/kubelet/pods --exclude=...- unattended,pod-ephemeralwith mount exclusion doing the dedup.kubectl rollout restart- operator, by design. A deliberate fleet-wide deny, not a gap.Five of eight fully unattended, one halved, two operator-only on purpose.
The remaining honest gap is attachment and package ownership. Forgejo's API exposes per-release and per-issue assets, so an aosguard verb could reconstruct it without
exec, at the cost of walking repositories. That is agentic-os work and a different issue if you want it.Both PRs updated in place: node-stats-mcp#27, infrastructure#859. 47 tests pass, lint and precommit clean on both.
Both merges rolled to kai-server, so the claims are now measured rather than argued.
A - the
rootprofile completesIt used to die at roughly four minutes with exit 137. It now finishes in 102 seconds and reports a complete total rather than a lower bound.
The dedup set holds 109,290 inodes against 2,683,740 entries scanned - four percent of what it used to carry. At the old 162 MB per million that set alone would have been about 435 MB on this tree, against a 512Mi limit, which is the whole story of the OOM. Container RSS mid-scan measured 292 MB, 57 percent of the limit.
deduplicated_entries=280,503against 109,290 tracked inodes means each hard-linked inode was reached about 3.5 times and counted once. The dedup is doing more work than before, not less, so thest_nlink > 1narrowing cost no accuracy.C and the depth work - the Forgejo split, unattended
One tool call, 27 seconds, no
kubectl exec:Attachments are 40.0 GB, up from the 38 GB in this issue's body, and
data/is essentially nothing but attachments. Packages are 80.6 GB, up from 74 GB, which tracks the "roughly 3 GB/day with nothing retiring anything" note.A gap this verification exposed
The
rootbreakdown's top children are:/swapfileis 32 GB and no domain inkai-disk-pressure-reportnames it. It is the third-largest consumer of the root filesystem, it is permanently allocated, and it is exactly the same class of blind spot as thepod_ephemeral_bytesgap this issue was filed about: a large, real, unattributed consumer that the bounded report silently omits from its sum. Anyone reconciling domains againstdfis off by 32 GB before they start.Worth its own issue in
infrastructurerather than reopening scope here.Closed in the 2026-08-26 backlog burn-down (coilyco-bridge/agentic-os-kai#901).
Closing as complete. Root causes A through G landed across node-stats-mcp#27 and infrastructure#859, and the verification comment measures the results. The unattributed /swapfile gap was explicitly deferred to its own infrastructure issue.
This was
priority/P3, the default tier that unsure and unscored issues land in, so it had never been positively judged worth keeping. The 2026-08-26 burn-down ranked the whole P3 pool and cut the bottom. Closed here means not on the list, not wrong or worthless.If this is live work, reopen it. The whole set is recoverable with
state:closed label:burndown-2026-08.