diff --git a/README.md b/README.md index 37cdfda..97343ce 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Reading is destructive only through a drain: an agent that reads its mail and th ### Bounds - A single message body is capped at 64 KiB (`ErrBodyTooLarge` if exceeded). -- One agent's pending mail is capped at 500 messages. Past that, the **oldest** message is dropped first — a silent agent loses its stalest context, never the message that just arrived. +- One agent's pending mail is capped at 500 messages. Past that, the oldest **`note`** is dropped first; `handoff`/`question`/`answer` only start dropping once every pending `note` is gone. Either way, it's oldest-first within that group — a silent agent loses its stalest context, never the message that just arrived. - Every drop increments a per-agent dropped counter, surfaced by `tether ls` (the `PENDING` column, as `N (+M dropped)`), `tether explain`, and `tether inbox`'s stderr warning. Degradation is visible, never silent, and the counter resets to zero the next time that agent actually drains its inbox. - Unacked mail nobody ever comes back for is swept and marked dead after 24 hours instead of kept forever. Read-or-dead mail older than **7 days** is then deleted outright in the same background sweep, so the database doesn't grow without bound. No `VACUUM` runs; SQLite reuses the freed space, so the file plateaus rather than shrinks. - `tether doctor` reports the database's file path, its size on disk, row counts for messages, agents, and observations, and the daemon log path — so "is my DB getting too big" has a direct answer. diff --git a/internal/store/messages_test.go b/internal/store/messages_test.go index 3ea4882..10d1748 100644 --- a/internal/store/messages_test.go +++ b/internal/store/messages_test.go @@ -975,6 +975,43 @@ func TestSendEnforcesInboxDepthDropsOldest(t *testing.T) { } } +// TestSendEnforcesInboxDepthPrefersDroppingNotes sends a handoff first, then +// floods the inbox past the cap with notes. Eviction must take the oldest +// notes, not the oldest message overall -- the handoff survives regardless +// of its age. +func TestSendEnforcesInboxDepthPrefersDroppingNotes(t *testing.T) { + ctx := context.Background() + s := newStore(t) + pair(t, s) + + handoff := note("the actual task") + handoff.Kind = KindHandoff + handoffID := mustSend(t, s, handoff) + + const over = 5 + notes := make([]string, 0, maxInboxDepth+over-1) + for i := 0; i < maxInboxDepth+over-1; i++ { + notes = append(notes, mustSend(t, s, note(fmt.Sprintf("m%d", i)))) + } + + a, err := s.GetAgent(ctx, "ws", "bob") + if err != nil { + t.Fatalf("GetAgent: %v", err) + } + if a.Dropped != over { + t.Fatalf("agents.dropped = %d, want %d", a.Dropped, over) + } + + pending, err := s.Inbox(ctx, "ws", "bob", maxInboxDepth+1) + if err != nil { + t.Fatalf("Inbox: %v", err) + } + wantAlive := append([]string{handoffID}, notes[over:]...) + if !equalStrings(ids(pending), wantAlive) { + t.Fatalf("pending after drops = %v, want %v", ids(pending), wantAlive) + } +} + func TestSendAtExactlyMaxInboxDepthDropsNothing(t *testing.T) { ctx := context.Background() s := newStore(t) diff --git a/internal/store/queries.go b/internal/store/queries.go index 33c0f28..1e06a48 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -131,12 +131,14 @@ DELETE FROM messages WHERE (dead = 1 OR acked_at IS NOT NULL) AND created_at < ?` // Subquery, not ORDER BY/LIMIT on UPDATE: modernc.org/sqlite rejects that directly. + // Notes are evicted before anything else -- a handoff/question/answer only + // goes once every pending note is gone, regardless of age. qDropOldest = ` UPDATE messages SET dead = 1 WHERE id IN ( SELECT id FROM messages WHERE to_ws = ? AND to_name = ? AND acked_at IS NULL AND dead = 0 - ORDER BY id + ORDER BY kind = 'note' DESC, id LIMIT ? )`