From 2a2287d7bd5241a59604ceb3d0231afc058076b8 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Mon, 26 Oct 2026 11:22:22 +0100 Subject: [PATCH] coding-agent: PLF-20296 (ticket-109) --- fixos/diagnostics/quick_snapshot.py | 35 +++++--- project/ticket-109/README.md | 24 ++++++ project/ticket-109/intent.json | 81 +++++++++++++++++++ tests/unit/test_quick_snapshot.py | 120 ++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 project/ticket-109/README.md create mode 100644 project/ticket-109/intent.json diff --git a/fixos/diagnostics/quick_snapshot.py b/fixos/diagnostics/quick_snapshot.py index c66897f..72ef587 100644 --- a/fixos/diagnostics/quick_snapshot.py +++ b/fixos/diagnostics/quick_snapshot.py @@ -25,6 +25,7 @@ HISTORY_SCHEMA = "fixos-quick-history-v1" MAX_HISTORY_ITEMS = 192 +SAFE_DISK_FREE_BYTES = 50 * 1024**3 @dataclass(frozen=True) @@ -797,16 +798,30 @@ def _alerts( swap_warning = thresholds["swap_usage_warning"] cpu_warning = thresholds["cpu_usage_warning"] if disk["percent"] >= disk_critical: - alerts.append( - { - "resource": "disk", - "severity": "critical", - "message": ( - "Dysk systemowy przekroczył próg krytyczny " - f"{disk_critical:.0f}%." - ), - } - ) + free_bytes = disk.get("free_bytes", 0) + if free_bytes >= SAFE_DISK_FREE_BYTES: + free_gb = free_bytes / 1024**3 + alerts.append( + { + "resource": "disk", + "severity": "warning", + "message": ( + "Dysk systemowy przekroczył próg krytyczny " + f"{disk_critical:.0f}%, ale pozostało {free_gb:.1f} GB wolnego." + ), + } + ) + else: + alerts.append( + { + "resource": "disk", + "severity": "critical", + "message": ( + "Dysk systemowy przekroczył próg krytyczny " + f"{disk_critical:.0f}%." + ), + } + ) elif disk["percent"] >= disk_warning: alerts.append( { diff --git a/project/ticket-109/README.md b/project/ticket-109/README.md new file mode 100644 index 0000000..854dd0f --- /dev/null +++ b/project/ticket-109/README.md @@ -0,0 +1,24 @@ +# Ticket 109: fix(quick): suppress false-positive critical disk alert when absolute free space exceeds safe threshold on multi-TB filesystems + +- **ID**: ticket-109 +- **Owner**: agent:agy +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-26 + +## Goal and scope + +On large filesystems (e.g. 2 TB NVMe where 5% reserved blocks + 93-95% usage still leaves > 100 GB of available disk space), `fixos/diagnostics/quick_snapshot.py` (`_alerts`) triggers a `CRITICAL` disk alert purely based on `disk['percent'] >= disk_critical` without accounting for large absolute free space. + +Downgrade `critical` disk alerts to `warning` in `_alerts` when `disk['free_bytes'] >= 50 * 1024**3` (50 GiB), and include the human-readable free GB in the alert message so operators on multi-TB drives get accurate severity classification. Add unit tests covering alert severity on both small and multi-TB filesystems. + +## Acceptance criteria + +- [x] AC-01: Scope is approved by a human owner (GitHub issue semcod/fixos#133 / PLF-20296). +- [x] AC-02: `_alerts` in `fixos/diagnostics/quick_snapshot.py` downgrades critical disk alert to warning when `free_bytes >= 50 * 1024**3`, formatting free space in GB in the message. +- [x] AC-03: Unit tests in `tests/unit/test_quick_snapshot.py` verify alert severity and message on small and multi-TB filesystems. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-109/intent.json b/project/ticket-109/intent.json new file mode 100644 index 0000000..cd1b947 --- /dev/null +++ b/project/ticket-109/intent.json @@ -0,0 +1,81 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-109", + "summary": "fix(quick): suppress false-positive critical disk alert when absolute free space exceeds safe threshold on multi-TB filesystems", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P2", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-109/**", + "TODO.md", + "project/TICKETS.md", + "fixos/diagnostics/quick_snapshot.py", + "tests/unit/test_quick_snapshot.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "f787fa4192b39fffd79aa781469dff405f640406", + "targetBranch": "main", + "outcome": "In fixos/diagnostics/quick_snapshot.py (_alerts), downgrade critical disk alerts to warning when disk['free_bytes'] >= 50 * 1024**3 (50 GiB) and include human-readable free GB in the alert message. Verified by unit tests in tests/unit/test_quick_snapshot.py covering small and multi-TB filesystems.", + "nonGoals": [ + "Do not modify full fixos analysis or service scanner disk logic", + "Do not modify percentage threshold configuration profiles" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 5, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "In _alerts, check disk free_bytes against SAFE_DISK_FREE_BYTES (50 GiB) before emitting critical disk alert. When free space exceeds safe threshold, emit warning severity with formatted free GB.", + "components": [ + { + "name": "quick-snapshot", + "paths": [ + "fixos/diagnostics/quick_snapshot.py" + ] + }, + { + "name": "tests", + "paths": [ + "tests/unit/test_quick_snapshot.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert ticket-109 commit." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "pytest tests/unit/test_quick_snapshot.py -q" + ], + "evidence": "project/ticket-109/README.md" + } + ] + } +} diff --git a/tests/unit/test_quick_snapshot.py b/tests/unit/test_quick_snapshot.py index 0d7a133..9e4c9e3 100644 --- a/tests/unit/test_quick_snapshot.py +++ b/tests/unit/test_quick_snapshot.py @@ -268,3 +268,123 @@ def test_timed_out_cache_shows_last_complete_size(): assert items[0]["last_complete_at"] == "t1" assert items[0]["complete"] is False assert "last_complete_at" not in items[1] + + +def test_disk_critical_alert_downgrades_to_warning_on_multi_tb_filesystem(): + resources = _resources() + total_bytes = 2 * 1024**4 # 2 TiB + free_bytes = 100 * 1024**3 # 100 GiB (>= 50 GiB threshold) + resources["disk"] = { + "mount": "/", + "total_bytes": total_bytes, + "used_bytes": total_bytes - free_bytes, + "free_bytes": free_bytes, + "percent": 95.1, + } + thresholds = quick_snapshot._profile_thresholds("developer") + + alerts = quick_snapshot._alerts( + resources, + {"escalating": []}, + thresholds, + ) + + disk_alerts = [a for a in alerts if a["resource"] == "disk"] + assert len(disk_alerts) == 1 + alert = disk_alerts[0] + assert alert["severity"] == "warning" + assert "100.0 GB" in alert["message"] + assert "Dysk systemowy przekroczył próg krytyczny" in alert["message"] + + +def test_disk_critical_alert_remains_critical_on_small_filesystem(): + resources = _resources() + total_bytes = 100 * 1024**3 # 100 GiB + free_bytes = 4 * 1024**3 # 4 GiB (< 50 GiB threshold) + resources["disk"] = { + "mount": "/", + "total_bytes": total_bytes, + "used_bytes": total_bytes - free_bytes, + "free_bytes": free_bytes, + "percent": 96.0, + } + thresholds = quick_snapshot._profile_thresholds("developer") + + alerts = quick_snapshot._alerts( + resources, + {"escalating": []}, + thresholds, + ) + + disk_alerts = [a for a in alerts if a["resource"] == "disk"] + assert len(disk_alerts) == 1 + alert = disk_alerts[0] + assert alert["severity"] == "critical" + assert alert["message"] == "Dysk systemowy przekroczył próg krytyczny 95%." + + +def test_disk_critical_alert_safe_free_space_boundary(): + thresholds = { + "disk_usage_critical": 95, + "disk_usage_warning": 85, + "memory_usage_warning": 80, + "memory_usage_critical": 90, + "swap_usage_warning": 75, + "cpu_usage_warning": 90, + } + + # Exactly 50 GiB free -> downgraded to warning + resources_at_boundary = _resources() + resources_at_boundary["disk"] = { + "mount": "/", + "total_bytes": 1000 * 1024**3, + "used_bytes": 950 * 1024**3, + "free_bytes": 50 * 1024**3, + "percent": 95.0, + } + alerts_at = quick_snapshot._alerts( + resources_at_boundary, {"escalating": []}, thresholds + ) + disk_at = next(a for a in alerts_at if a["resource"] == "disk") + assert disk_at["severity"] == "warning" + assert "50.0 GB" in disk_at["message"] + + # Just below 50 GiB free -> critical + resources_below = _resources() + resources_below["disk"] = { + "mount": "/", + "total_bytes": 1000 * 1024**3, + "used_bytes": 950 * 1024**3 + 1, + "free_bytes": 50 * 1024**3 - 1, + "percent": 95.0, + } + alerts_below = quick_snapshot._alerts( + resources_below, {"escalating": []}, thresholds + ) + disk_below = next(a for a in alerts_below if a["resource"] == "disk") + assert disk_below["severity"] == "critical" + + +def test_disk_critical_alert_on_multi_tb_filesystem_when_nearly_exhausted(): + resources = _resources() + total_bytes = 4 * 1024**4 # 4 TiB + free_bytes = 20 * 1024**3 # 20 GiB (< 50 GiB threshold) + resources["disk"] = { + "mount": "/", + "total_bytes": total_bytes, + "used_bytes": total_bytes - free_bytes, + "free_bytes": free_bytes, + "percent": 99.5, + } + thresholds = quick_snapshot._profile_thresholds("developer") + + alerts = quick_snapshot._alerts( + resources, + {"escalating": []}, + thresholds, + ) + + disk_alerts = [a for a in alerts if a["resource"] == "disk"] + assert len(disk_alerts) == 1 + assert disk_alerts[0]["severity"] == "critical" +