From 03241ff9cf3d01f56d9e8dffee920db680a61334 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sat, 31 Oct 2026 18:00:57 +0100 Subject: [PATCH] fix(disk, ticket-114): classify pressure from available capacity --- fixos/diagnostics/disk_analyzer.py | 7 ++- project/ticket-114/README.md | 47 ++++++++++++++ project/ticket-114/intent.json | 76 +++++++++++++++++++++++ tests/unit/test_disk_analyzer_capacity.py | 66 ++++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 project/ticket-114/README.md create mode 100644 project/ticket-114/intent.json create mode 100644 tests/unit/test_disk_analyzer_capacity.py diff --git a/fixos/diagnostics/disk_analyzer.py b/fixos/diagnostics/disk_analyzer.py index 3024907..8d27afd 100644 --- a/fixos/diagnostics/disk_analyzer.py +++ b/fixos/diagnostics/disk_analyzer.py @@ -72,7 +72,12 @@ def analyze_disk_usage(self, path: str = None) -> Dict[str, Any]: total_gb = stat.total / (1024**3) used_gb = stat.used / (1024**3) free_gb = stat.free / (1024**3) - usage_percent = (used_gb / total_gb) * 100 + if stat.total <= 0: + raise ValueError("Filesystem reports no capacity") + # Reserved blocks are unavailable to ordinary processes and must + # not make their remaining capacity appear healthier. + user_capacity = stat.used + stat.free + usage_percent = 100.0 * stat.used / user_capacity if user_capacity else 100.0 large_files = self.get_large_files(path) cache_dirs = self.get_cache_dirs(path) diff --git a/project/ticket-114/README.md b/project/ticket-114/README.md new file mode 100644 index 0000000..7171a7e --- /dev/null +++ b/project/ticket-114/README.md @@ -0,0 +1,47 @@ +# Ticket 114: Classify disk pressure using capacity available to services + +- **ID**: ticket-114 +- **Owner**: agent:codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-10-31 + +## Goal and scope + +The live root filesystem has about 2.5% available capacity, but DiskAnalyzer +reports 94% usage and only warning severity because its denominator includes +capacity reserved from ordinary processes. Calculate utilization using used +bytes plus available bytes while preserving raw size fields and thresholds. + +SESSION_EXECUTION_AUTHORIZATION: the user requested continuing FixOS repairs +and merges on 2026-10-31. This bounded correction includes independent protected +publication and deployment into the two existing host installations. It grants +no filesystem-reserve change, deletion of unknown data or approval bypass. + +## Acceptance criteria + +- [x] AC-01: Reserved capacity cannot mask critical or exhausted application + capacity; unchanged ordinary and empty-filesystem cases remain explicit. +- [ ] AC-02: Affected tests, full required checks and managed governance pass. +- [ ] AC-03: Independent exact-head publication and deployed verification pass. + +## Validation + +Eight capacity cases cover reserved-block pressure, exhausted available space, +ordinary utilization, an unused filesystem, and an invalid zero-capacity +observation. Four failed against the baseline with warning/healthy severity +instead of critical. All 15 affected tests now pass, including Testless coverage +collection. Changed-file Ruff and whitespace checks passed. The complete suite, +required Python matrix, protected review and deployment are publication work. + +The initial XS contract mistakenly estimated 20 active implementation minutes; +the adopted limit is ten. The remaining two-file implementation was bounded to +ten minutes without widening its class, scope or budgets. The owner released +the earlier editing reservation and reacquired a real fenced lease bound to +the corrected intent. External controller receipts retain both plan hashes. + +The first full-suite run exposed a pre-existing live BuildKit dependency in a +proposal unit fixture. Independently reviewed ticket-115 / PR #147 fixed that +fixture. This ticket was reconciled onto its merged base with an updated intent +and a fresh controller lease; capacity implementation and regression tests +remain byte-for-byte unchanged. The resulting head requires fresh validation. diff --git a/project/ticket-114/intent.json b/project/ticket-114/intent.json new file mode 100644 index 0000000..bb4ab6b --- /dev/null +++ b/project/ticket-114/intent.json @@ -0,0 +1,76 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-114", + "summary": "Classify disk pressure using capacity available to services", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-114/**", + "fixos/diagnostics/disk_analyzer.py", + "tests/unit/test_disk_analyzer_capacity.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "1a03305fb891c0e643a57018676af1364c821ce9", + "targetBranch": "main", + "outcome": "Independently publish and deploy disk-pressure classification based on capacity available to ordinary processes, so reserved blocks cannot hide critical pressure.", + "nonGoals": [ + "No filesystem reserve changes or host data deletion", + "No cleanup scope, threshold or service-policy changes", + "No scan traversal or MCP interface changes" + ], + "complexity": "XS", + "estimatedMinutes": 10, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Compute utilization from used bytes plus bytes available to ordinary users, retaining raw capacity fields and existing severity thresholds.", + "components": [ + { + "name": "disk-analyzer", + "paths": [ + "fixos/diagnostics/disk_analyzer.py", + "tests/unit/test_disk_analyzer_capacity.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert through protected delivery and reinstall the prior reviewed wheel." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest tests/unit/test_disk_analyzer_capacity.py tests/unit/test_disk_analyzer.py -q", + "./project/governance-check.sh" + ], + "evidence": "project/ticket-114/README.md" + } + ] + } +} diff --git a/tests/unit/test_disk_analyzer_capacity.py b/tests/unit/test_disk_analyzer_capacity.py new file mode 100644 index 0000000..d2b9518 --- /dev/null +++ b/tests/unit/test_disk_analyzer_capacity.py @@ -0,0 +1,66 @@ +"""Disk severity must reflect capacity available to ordinary processes.""" + +from types import SimpleNamespace + +import pytest + +from fixos.diagnostics.disk_analyzer import DiskAnalyzer + +GIB = 1024**3 + + +@pytest.fixture +def analyzer(tmp_path, monkeypatch): + instance = DiskAnalyzer(str(tmp_path)) + for method in ( + "get_large_files", + "get_cache_dirs", + "get_log_dirs", + "get_temp_dirs", + "suggest_cleanup_actions", + ): + monkeypatch.setattr(instance, method, lambda *args, **kwargs: []) + return instance + + +@pytest.mark.parametrize( + ("used", "available", "expected_percent", "expected_status"), + [ + (90, 1, 98.90, "critical"), + (94, 2, 97.92, "critical"), + (90, 0, 100.0, "critical"), + (0, 0, 100.0, "critical"), + (90, 10, 90.0, "warning"), + (50, 50, 50.0, "healthy"), + (0, 100, 0.0, "healthy"), + ], +) +def test_capacity_available_to_processes_controls_severity( + analyzer, monkeypatch, used, available, expected_percent, expected_status +): + monkeypatch.setattr( + "shutil.disk_usage", + lambda path: SimpleNamespace( + total=100 * GIB, used=used * GIB, free=available * GIB + ), + ) + + report = analyzer.analyze_disk_usage() + + assert report["status"] == expected_status + assert report["usage_percent"] == expected_percent + assert report["total_gb"] == 100 + assert report["used_gb"] == used + assert report["free_gb"] == available + + +def test_zero_capacity_is_reported_as_an_error(analyzer, monkeypatch): + monkeypatch.setattr( + "shutil.disk_usage", + lambda path: SimpleNamespace(total=0, used=0, free=0), + ) + + report = analyzer.analyze_disk_usage() + + assert "error" in report + assert "status" not in report