Read a shrink against the item's own path, so a moved directory starts its size history again (hq issue 368)
A reading now keeps the path it was measured at, and the peak is read only from readings at the item's path now. Before, an agent's home that moved to its own account was compared with the operator's home it left, and data-shrank was raised for data that was never lost. Existing readings take their item's path now, so a shrink that is real stays raised.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user