diff --git a/cmd/mesh-controller/data_test.go b/cmd/mesh-controller/data_test.go index f991b281..00324b19 100644 --- a/cmd/mesh-controller/data_test.go +++ b/cmd/mesh-controller/data_test.go @@ -169,6 +169,44 @@ func TestAShrinkOfMoreThanHalfIsUrgent(t *testing.T) { } } +// THE FALSE ALARM (issue 368), replayed through the store D13 reads: an agent's home moved from the +// operator's own home (94.7 MB) to the agent account's fresh one (490 B), and `data-shrank` was raised +// for data that was never lost. A moved item is read against its new path only, so nothing is raised — +// and a genuine shrink at the new path, a week of history later, still is. +func TestAMovedPathIsNoShrinkAndAShrinkThereStillIs(t *testing.T) { + inv := inventory.ForTest(t) + ctx := t.Context() + shelf := shelfFor(t, houseManifest) + declared := []inventory.DeclaredData{{Module: "house", Item: "config", Class: "irreplaceable", Owned: true}} + start := time.Now().Add(-6 * time.Hour) + measure := func(at time.Time, path string, size int64) []conditions.Observation { + t.Helper() + if _, err := inv.RecordData(ctx, "home", declared, map[string]map[string]inventory.Measurement{"house": { + "config": {Path: path, Size: bytesOf(size), MeasuredAt: when(at), LastWrite: when(at), + LastBackup: when(at)}}}, "", at); err != nil { + t.Fatal(err) + } + records, err := inv.Data(ctx) + if err != nil { + t.Fatal(err) + } + peaks, err := inv.DataPeaks(ctx, at.Add(-shrinkWindow)) + if err != nil { + t.Fatal(err) + } + return dataFindings(records, peaks, shelf, nil, nil, at) + } + measure(start, "/home/operator/.claude", 94_700_000) + if got := findingsByKind(measure(start.Add(10*time.Minute), "/home/agent/.claude", 490)); got[kindDataShrank].Kind != "" { + t.Fatalf("a moved path raised a shrink: %+v", got[kindDataShrank]) + } + measure(start.Add(2*time.Hour), "/home/agent/.claude", 300<<20) + got := findingsByKind(measure(start.Add(4*time.Hour), "/home/agent/.claude", 1<<20))[kindDataShrank] + if got.Severity != conditions.Urgent || !strings.Contains(got.Summary, "shrank") { + t.Fatalf("a genuine shrink at the new path was not raised: %+v", got) + } +} + // Data said to be written all the time and not written; data with no backup or an old one — urgent when // irreplaceable, a warning when valuable; and a new item given its bound before it is said. func TestQuietDataAndMissingBackupsAreSaidByClass(t *testing.T) { diff --git a/internal/inventory/data.go b/internal/inventory/data.go index 3518e577..54cc3af5 100644 --- a/internal/inventory/data.go +++ b/internal/inventory/data.go @@ -94,7 +94,9 @@ type DataChange struct { } // readingEvery is how often a measurement is kept as a reading: the shrink is read over days, and a -// row every five minutes would say the same thing sixty times an hour. +// row every five minutes would say the same thing sixty times an hour. A reading keeps the item's path +// as it stands after the measurement — the holder's, or the last one known when it named none — and an +// item at a path with no recent reading is read at once (novox/hq issue 368). const readingEvery = 55 * time.Minute // readingsKept is how long readings are kept. @@ -187,10 +189,13 @@ func (i *Inventory) RecordData(ctx context.Context, machine string, declared []D } if m.Size != nil && m.MeasuredAt != nil && Comparable(m.Precision) { if _, err := tx.Exec(ctx, ` - insert into data_reading (machine, module, item, at, size_bytes, last_write) - select $1, $2, $3, $4, $5, $6 - where not exists (select 1 from data_reading - where machine = $1 and module = $2 and item = $3 and at > $4::timestamptz - $7::interval)`, + insert into data_reading (machine, module, item, at, size_bytes, last_write, path) + select $1, $2, $3, $4, $5, $6, d.path + from data_item d + where d.machine = $1 and d.module = $2 and d.item = $3 + and not exists (select 1 from data_reading + where machine = $1 and module = $2 and item = $3 and path = d.path + and at > $4::timestamptz - $7::interval)`, machine, d.Module, d.Item, *m.MeasuredAt, *m.Size, m.LastWrite, fmt.Sprintf("%d seconds", int(readingEvery.Seconds()))); err != nil { return change, err @@ -281,10 +286,14 @@ func (i *Inventory) DataOf(ctx context.Context, machine, module, item string) (D return r, err } -// DataPeaks is each item's largest reading since a moment, keyed by DataRecord.Key. +// DataPeaks is each item's largest reading since a moment, keyed by DataRecord.Key — read only from +// readings at the item's path now (novox/hq issue 368): a directory is never compared with another one +// that once held the same item, so a moved item starts its size history again at its new path. func (i *Inventory) DataPeaks(ctx context.Context, since time.Time) (map[string]int64, error) { - rows, err := i.store.Pool().Query(ctx, `select machine, module, item, max(size_bytes) from data_reading - where at >= $1 group by machine, module, item`, since) + rows, err := i.store.Pool().Query(ctx, `select r.machine, r.module, r.item, max(r.size_bytes) + from data_reading r + join data_item d on d.machine = r.machine and d.module = r.module and d.item = r.item and d.path = r.path + where r.at >= $1 group by r.machine, r.module, r.item`, since) if err != nil { return nil, err } diff --git a/internal/inventory/data_test.go b/internal/inventory/data_test.go index d109c1b3..0c12fbe6 100644 --- a/internal/inventory/data_test.go +++ b/internal/inventory/data_test.go @@ -128,3 +128,47 @@ func TestAPartialMeasurementIsNeverAReading(t *testing.T) { t.Fatalf("%+v, %v", r, err) } } + +// A shrink is read against what the same directory held (novox/hq issue 368): an item whose path moved +// — an agent's home moved to its own account — starts its size history again at the new path, and a +// genuine shrink at the new path is still read against what that path held. +func TestThePeakIsReadAtTheItemsPathOnly(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + now := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC) + declared := []DeclaredData{{Module: "claude-code", Item: "agent-home", Class: "valuable", Owned: true}} + measure := func(when time.Time, path string, s int64) { + t.Helper() + if _, err := inv.RecordData(ctx, "novox", declared, map[string]map[string]Measurement{"claude-code": { + "agent-home": {Path: path, Size: size(s), MeasuredAt: at(when)}}}, "", when); err != nil { + t.Fatal(err) + } + } + measure(now, "/home/operator/.claude", 94_700_000) + // The path moves ten minutes later: the new directory is measured at once, not an hour on. + measure(now.Add(10*time.Minute), "/home/agent/.claude", 490) + peaks, err := inv.DataPeaks(ctx, now.Add(-time.Hour)) + if err != nil || peaks["novox/claude-code/agent-home"] != 490 { + t.Fatalf("after the path moved the peak is %v (%v), want 490: the old directory's size is no shrink "+ + "of the new one", peaks, err) + } + // The new directory grows, then genuinely loses most of it: that is read against the new path's peak. + measure(now.Add(2*time.Hour), "/home/agent/.claude", 80_000_000) + measure(now.Add(4*time.Hour), "/home/agent/.claude", 1_000) + peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour)) + if err != nil || peaks["novox/claude-code/agent-home"] != 80_000_000 { + t.Fatalf("a shrink at the new path is read against %v (%v), want 80000000", peaks, err) + } + // A measurement that names no path is the item's last known path's, not a new history. + measure(now.Add(6*time.Hour), "", 2_000) + peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour)) + if err != nil || peaks["novox/claude-code/agent-home"] != 80_000_000 { + t.Fatalf("a measurement with no path started a new history: peak %v (%v)", peaks, err) + } + // Moving back to a directory measured before reads it against what it held then. + measure(now.Add(8*time.Hour), "/home/operator/.claude", 94_000_000) + peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour)) + if err != nil || peaks["novox/claude-code/agent-home"] != 94_700_000 { + t.Fatalf("back at the first path the peak is %v (%v), want 94700000", peaks, err) + } +} diff --git a/internal/inventory/migrations/0092-a-reading-says-the-path-it-was-measured-at.sql b/internal/inventory/migrations/0092-a-reading-says-the-path-it-was-measured-at.sql new file mode 100644 index 00000000..6a02cc88 --- /dev/null +++ b/internal/inventory/migrations/0092-a-reading-says-the-path-it-was-measured-at.sql @@ -0,0 +1,21 @@ +-- A reading says the path it was measured at (novox/hq issue 368). +-- +-- A shrink is read against the largest reading of the last seven days. Readings were kept by machine, +-- module and item only, so when an item's path moved — on 2026-10-10 an agent's home moved from the +-- operator's own home to the agent account's (ADR 0266) — the new, fresh directory was read against +-- the old one's size, and `data-shrank` was raised for data that was never lost. A reading now keeps +-- its path, and the peak is read only from readings at the item's path now: a moved item starts its +-- size history again, and moving back to a path reads it against what that path held. +-- +-- **Every reading kept so far is taken to be at its item's path now.** Where it was really measured +-- is not on record; taking the path now keeps every item's history, so a shrink that is real stays +-- raised. An item whose path moved before this migration stays compared with its old directory until +-- those readings leave the seven-day window. Readings of an item no longer kept keep no path, and are +-- read for nothing. +-- +-- Numbered 0092, past 0091, the highest on main or any open branch when this was written. +alter table data_reading add column path text; + +update data_reading r set path = d.path + from data_item d + where d.machine = r.machine and d.module = r.module and d.item = r.item;