R-330: the agent sends SMART 187/188/199 (raw; omitted when unknown)
gates / gates (push) Successful in 53s
gates / gates (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+2
-1
@@ -1,4 +1,4 @@
|
||||
## Unreleased (2026-10-06 night, later) — after a restart the agent remembers the last backup per tier (R-894)
|
||||
## Unreleased (2026-10-06 night, later) — after a restart the agent remembers the last backup per tier (R-894); three more SMART counters on the wire (R-330)
|
||||
|
||||
Ships with the memory-kill check below as v0.150.0, AFTER the 2026-10-07 night read-back. Nothing delivered tonight.
|
||||
|
||||
@@ -7,6 +7,7 @@ Ships with the memory-kill check below as v0.150.0, AFTER the 2026-10-07 night r
|
||||
- `internal/localapi` `handleBackupDue`: when the tier's storage CANNOT be read, the saved copy stands in for the in-memory record. A fresh copy → not due („… (storage unreadable — age from the last success saved on disk)"); a copy older than the cadence → DUE; no copy → the old answer (DUE, age unknown). A storage that answers stays the ground truth: an archive absent there is due even when the file remembers one.
|
||||
- Wired in `buildLocalAPIServer` (`LastKnownBackups`); the local API's backup job saves each success.
|
||||
- Tests: `TestBackupDue_R894_*` (restart = a new server and a new state from the same file; fresh / old / none / storage answers / failed backup not saved), `TestBackupSuccessState_*`, `TestR894_LastKnownBackupsIsWiredIntoTheDaemon` (AST). Four red-proofs observed (`felhom.eu/documentation/audits/night-burndown-2026-10-06/s4/`).
|
||||
- **R-330 (disk health Phase 2, the wire only):** the SMART summary carries three more SATA raw counters — `reported_uncorrect` (187), `command_timeout` (188, carried as the vendor reports it; some pack several counters), `udma_crc_errors` (199). Pointer + omitempty: an attribute the drive does not report is OMITTED (unknown), never 0. No verdict reads them yet. Tests `TestParseSMART_R330_*` (two red-proofs, `felhom.eu/documentation/audits/night-burndown-2026-10-06/r330/`).
|
||||
|
||||
## Unreleased (2026-10-06 night) — the Docker step proves the engine reports a memory kill (`09` §3 decision 157, R-528)
|
||||
|
||||
|
||||
@@ -454,6 +454,17 @@ type SmartSummary struct {
|
||||
ReallocatedSectors *int `json:"reallocated_sectors"`
|
||||
PendingSectors *int `json:"pending_sectors"`
|
||||
OfflineUncorrectable *int `json:"offline_uncorrectable"`
|
||||
// R-330 (disk health Phase 2): three more SATA raw counters. omitempty + pointer: absent (an
|
||||
// older agent, an NVMe/USB device, or a drive that does not report the attribute) is OMITTED —
|
||||
// unknown, never a zero (S-39). Wire only: no verdict reads them yet.
|
||||
// 187 Reported_Uncorrect — the failing drive's most telling counter (normalized 1 vs thresh 0,
|
||||
// raw 1001) while SMART still said PASSED.
|
||||
// 188 Command_Timeout — some vendors PACK several counters into the 48-bit raw value, so the
|
||||
// number is carried as reported and must not be compared across vendors.
|
||||
// 199 UDMA_CRC_Error_Count — cabling / link errors, not the medium.
|
||||
ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"`
|
||||
CommandTimeout *int64 `json:"command_timeout,omitempty"`
|
||||
UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"`
|
||||
|
||||
// NVMe attributes.
|
||||
CriticalWarning *int `json:"critical_warning"`
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-330 (disk health Phase 2) — attributes 187, 188 and 199 ride the wire.
|
||||
//
|
||||
// The failing drive of 2026-08-14 carried 187 Reported_Uncorrect at raw 1001 while SMART said PASSED;
|
||||
// none of the three reached the controller. The values are carried as RAW counters; an absent
|
||||
// attribute is OMITTED from the JSON (unknown), never sent as 0 (S-39).
|
||||
|
||||
// The 2026-08-14 shape: PASSED, 187 raw 1001, plus 188/199 and the existing three.
|
||||
const r330SATA = `{"smart_status":{"passed":true},"ata_smart_attributes":{"table":[
|
||||
{"id":5,"raw":{"value":0}},
|
||||
{"id":187,"raw":{"value":1001}},
|
||||
{"id":188,"raw":{"value":4295032833}},
|
||||
{"id":197,"raw":{"value":8}},
|
||||
{"id":198,"raw":{"value":8}},
|
||||
{"id":199,"raw":{"value":3}}]}}`
|
||||
|
||||
// COMPANION RED-PROOF (observed): delete the three R-330 cases in parseSMART → this fails with
|
||||
// "187 Reported_Uncorrect must be carried (raw 1001); got <nil>". Restored.
|
||||
func TestParseSMART_R330_CarriesTheThreeCounters(t *testing.T) {
|
||||
s := parseSMART([]byte(r330SATA))
|
||||
if s.ReportedUncorrect == nil || *s.ReportedUncorrect != 1001 {
|
||||
t.Fatalf("187 Reported_Uncorrect must be carried (raw 1001); got %v", s.ReportedUncorrect)
|
||||
}
|
||||
// 188's raw value is vendor-packed on some drives (this one is 0x100010001): carried as reported, not truncated.
|
||||
if s.CommandTimeout == nil || *s.CommandTimeout != 4295032833 {
|
||||
t.Fatalf("188 Command_Timeout must be carried as the full raw value; got %v", s.CommandTimeout)
|
||||
}
|
||||
if s.UDMACRCErrors == nil || *s.UDMACRCErrors != 3 {
|
||||
t.Fatalf("199 UDMA_CRC_Error_Count must be carried (raw 3); got %v", s.UDMACRCErrors)
|
||||
}
|
||||
if s.PendingSectors == nil || *s.PendingSectors != 8 {
|
||||
t.Fatalf("the existing counters must be unchanged; pending=%v", s.PendingSectors)
|
||||
}
|
||||
}
|
||||
|
||||
// A drive (or an NVMe device) that does not report the attributes leaves them nil, and the JSON
|
||||
// OMITS the keys — the receiver reads "unknown", never a measured zero.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed): drop `,omitempty` from the three tags in hub.SmartSummary → this
|
||||
// fails with "an unreported attribute must be omitted, not sent: … reported_uncorrect …". Restored.
|
||||
func TestParseSMART_R330_AbsentIsOmittedNotZero(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"sata without the three": `{"smart_status":{"passed":true},"ata_smart_attributes":{"table":[{"id":5,"raw":{"value":0}}]}}`,
|
||||
"nvme": `{"smart_status":{"passed":true},"nvme_smart_health_information_log":{"critical_warning":0,"media_errors":0,"percentage_used":3}}`,
|
||||
} {
|
||||
s := parseSMART([]byte(raw))
|
||||
if s.ReportedUncorrect != nil || s.CommandTimeout != nil || s.UDMACRCErrors != nil {
|
||||
t.Fatalf("%s: unreported attributes must stay nil; got %v %v %v", name, s.ReportedUncorrect, s.CommandTimeout, s.UDMACRCErrors)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
for _, k := range []string{"reported_uncorrect", "command_timeout", "udma_crc_errors"} {
|
||||
if strings.Contains(string(b), `"`+k+`"`) {
|
||||
t.Fatalf("%s: an unreported attribute must be omitted, not sent: %s", name, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ const (
|
||||
ataReallocatedSectorCt = 5
|
||||
ataCurrentPending = 197
|
||||
ataOfflineUncorrect = 198
|
||||
ataReportedUncorrect = 187 // R-330
|
||||
ataCommandTimeout = 188 // R-330
|
||||
ataUDMACRCErrorCount = 199 // R-330
|
||||
)
|
||||
|
||||
// parseSMART maps smartctl JSON to a hub.SmartSummary, handling SATA + NVMe and degrading
|
||||
@@ -85,6 +88,12 @@ func parseSMART(raw []byte) hub.SmartSummary {
|
||||
s.PendingSectors = intPtr(int(a.Raw.Value))
|
||||
case ataOfflineUncorrect:
|
||||
s.OfflineUncorrectable = intPtr(int(a.Raw.Value))
|
||||
case ataReportedUncorrect:
|
||||
s.ReportedUncorrect = int64Ptr(a.Raw.Value)
|
||||
case ataCommandTimeout:
|
||||
s.CommandTimeout = int64Ptr(a.Raw.Value)
|
||||
case ataUDMACRCErrorCount:
|
||||
s.UDMACRCErrors = int64Ptr(a.Raw.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,3 +151,5 @@ func parseThinPoolMetadata(raw []byte) (float64, bool) {
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
|
||||
Reference in New Issue
Block a user