Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions internal/store/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion internal/store/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?
)`

Expand Down
Loading