Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
690b75f659 | ||
|
|
d52de218c8 | ||
|
|
6188e6b1da | ||
|
|
798738cc12 | ||
|
|
babfef4f3a | ||
|
|
96b0966ad8 |
@@ -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) {
|
||||
|
||||
@@ -1366,7 +1366,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
var words []string
|
||||
var cur strings.Builder
|
||||
inWord := false
|
||||
quote := rune(0)
|
||||
quote, opened := rune(0), 0
|
||||
runes := []rune(line)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
@@ -1387,7 +1387,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
case r == '\'' || r == '"':
|
||||
quote = r
|
||||
quote, opened = r, i
|
||||
inWord = true
|
||||
case r == '\\' && i+1 < len(runes):
|
||||
i++
|
||||
@@ -1405,7 +1405,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
}
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
||||
return nil, unclosedQuote(quote, opened)
|
||||
}
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
@@ -1413,6 +1413,17 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
return words, nil
|
||||
}
|
||||
|
||||
// unclosedQuote is the refusal of a line whose quote is never closed. Most often an apostrophe inside
|
||||
// a single-quoted value ended that quote early and a later quote was left open, so the refusal says
|
||||
// where the open quote is and how a quote is written inside a quoted value — the shell's own two
|
||||
// ways, which this splitter already reads (novox/hq issue 294). It quotes none of the line: a refusal
|
||||
// is kept on the bus as the call's answer, and the line may carry a setting's value.
|
||||
func unclosedQuote(quote rune, opened int) error {
|
||||
return fmt.Errorf("command has an unclosed %c quote, opened at character %d — an apostrophe "+
|
||||
"inside a single-quoted value ends it; write a ' inside single quotes as '\\'' (it'\\''s), or use "+
|
||||
"double quotes and write \\\" for a \" and \\\\ for a \\ inside them", quote, opened+1)
|
||||
}
|
||||
|
||||
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
|
||||
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
|
||||
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
|
||||
|
||||
@@ -415,3 +415,48 @@ func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) {
|
||||
t.Fatalf("behind %v: %v", behind, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value with a quote in it can be said on a `command` line, as in a shell, and a line whose quote
|
||||
// is left open is refused naming where it opened and how a quote is written, quoting none of it (novox/hq issue 294: an
|
||||
// apostrophe inside a single-quoted JSON value cut the line, and the refusal named no cause).
|
||||
func TestAQuotedValueCanHoldAQuote(t *testing.T) {
|
||||
for _, c := range []struct{ line, want string }{
|
||||
{`settings set claude-code '{"role":"the operator'\''s laptop"}'`, `{"role":"the operator's laptop"}`},
|
||||
{`settings set claude-code "{\"role\":\"the operator's laptop\"}"`, `{"role":"the operator's laptop"}`},
|
||||
{"x \"a \\\\ b\nc\"", "a \\ b\nc"},
|
||||
{`x 'a\b'`, `a\b`},
|
||||
} {
|
||||
argv, err := splitCommandLine(c.line)
|
||||
if err != nil || argv[len(argv)-1] != c.want {
|
||||
t.Errorf("%s: read back %q %v, want %q", c.line, argv, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := splitCommandLine(`settings set claude-code '{"role":"the operator's laptop"}' --node g14`)
|
||||
if err == nil {
|
||||
t.Fatal("an apostrophe that leaves a quote open was accepted")
|
||||
}
|
||||
for _, want := range []string{"character 57", `'\''`, `\"`} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "laptop") || strings.Contains(err.Error(), "g14") {
|
||||
t.Errorf("the refusal quotes the line, which may carry a setting's value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every value reads back as it was when written the way the refusal says: single-quoted with '\”
|
||||
// for each quote, or double-quoted with \ before each " and \ — newlines and backslashes included.
|
||||
func TestAQuotedValueRoundTrips(t *testing.T) {
|
||||
for _, v := range []string{`plain`, `it's`, `say "hi"`, `back\slash\`, "two\nlines", `'"\'\"`, `''`, ``} {
|
||||
single := "x '" + strings.ReplaceAll(v, "'", `'\''`) + "'"
|
||||
double := `x "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + `"`
|
||||
for _, line := range []string{single, double} {
|
||||
argv, err := splitCommandLine(line)
|
||||
if err != nil || len(argv) != 2 || argv[1] != v {
|
||||
t.Errorf("%s: read back %q %v, want %q", line, argv, err, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 @@ 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)
|
||||
on conflict (machine, module, item, at) do nothing`,
|
||||
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 +287,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,66 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Two measurements at the same moment at two paths keep one reading and fail nothing: the second
|
||||
// would otherwise break the reading's key and lose the machine's whole record (issue 368 review).
|
||||
func TestTwoPathsAtOneMomentFailNothing(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}}
|
||||
for _, path := range []string{"/home/operator/.claude", "/home/agent/.claude"} {
|
||||
if _, err := inv.RecordData(ctx, "novox", declared, map[string]map[string]Measurement{"claude-code": {
|
||||
"agent-home": {Path: path, Size: size(100), MeasuredAt: at(now)}}}, "", now); err != nil {
|
||||
t.Fatalf("a measurement at %s failed: %v", path, err)
|
||||
}
|
||||
}
|
||||
r, err := inv.DataOf(ctx, "novox", "claude-code", "agent-home")
|
||||
if err != nil || r.Path != "/home/agent/.claude" {
|
||||
t.Fatalf("%+v, %v", r, 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