Review round 1: an unset mail-reading verb answers unavailable not unknown, cache the index instead of leaking a pool per call, a gone mail leaves the queue without going unhealthy, sort the judge's own cause by its words, drive RunBatches itself for its pacing test, and prove no tell while unhealthy against a genuinely untold mail (issues 1430, 1523, 1517, 1518, 1520, 1519, 1524)

This commit is contained in:
jochen
2026-10-21 01:48:23 +02:00
parent f89b73d936
commit 8000b63785
7 changed files with 414 additions and 83 deletions
@@ -13,6 +13,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
@@ -79,7 +80,8 @@ func NewJudge(ask Ask, node string, now func() time.Time) JudgeFunc {
deadline := now().Add(JudgeWithin + time.Minute)
for {
if err != nil {
return nil, OutcomeRefused, err.Error()
outcome, why := classifyJudgeError(err)
return nil, outcome, why
}
var out struct {
Answer json.RawMessage `json:"answer"`
@@ -107,6 +109,34 @@ func NewJudge(ask Ask, node string, now func() time.Time) JudgeFunc {
}
}
// classifyJudgeError sorts an error the judge's own ask (claude_code_judge, claude_code_judged) returned
// into its real outcome (review round 1 finding 4, issue 1519/AC2, 1520/AC4): the real claude-code module
// answers "did not answer within <bound>; the judgement was ended" for a timeout and "answered, and not in
// the shape asked: <why>" for a misshapen answer (modules/claude-code/cmd/claude-code/judge.go), both as
// this ask's own error, not as a JSON body NewJudge's loop above could read a Pending/Answer shape from —
// before this, every one of those landed as OutcomeRefused, indistinguishable from the agent module
// actually refusing the ask ("holds no licence"). Anything else, including that real refusal, is
// OutcomeRefused, as before.
func classifyJudgeError(err error) (HealthOutcome, string) {
msg := err.Error()
switch {
case strings.Contains(msg, "did not answer within"):
return OutcomeTimedOut, msg
case strings.Contains(msg, "not in the shape asked"):
return OutcomeOutOfShape, msg
default:
return OutcomeRefused, msg
}
}
// isNoMessageError says whether err is mail-store.read's own "the account <a> holds no message <uid> in
// <folder>" (mesh-mail's cmd/mesh-mail/tools.go readTool): the mail named by account/folder/uid is no
// longer there — moved or deleted since it was heard — the one read failure RunOneBatch treats as the mail
// being simply gone rather than a reading problem (review round 1 finding 3, issue 1520).
func isNoMessageError(err error) bool {
return err != nil && strings.Contains(err.Error(), "holds no message")
}
// nextBatchWait is how long to wait before the next batch may start (1520/AC1): zero once gap has
// passed since the last start (or no batch has started yet), else the remainder. gap is given explicitly
// by the caller (RunBatches passes BatchGap; the unit tests in batch_test.go pass MinBatchGap directly)
@@ -155,16 +185,24 @@ func recordOutcome(ctx context.Context, store Store, outcome HealthOutcome, why
// caller's to select, so a test can hand it an exact slice), builds the question (question.go) from them, the
// existing mail groups and the applicable reading lessons, judges it, and applies the answer:
//
// - a mail whose read answers "holds no message" (moved or deleted since it was heard, review round 1
// finding 3, issue 1520) is taken out of the waiting queue, with a log line, and never reaches the
// question — the batch goes on with the rest;
// - any other read failure leaves every mail of this batch waiting, in the same order, for the next
// batch slot — the whole batch, not only the one that failed to read;
// - on success (1520/AC3): one mail entry per mail, any new mail group found-or-created by its label, and
// each mail leaves the waiting queue;
// - on a refused, timed-out or out-of-shape judgement (1520/AC4): no entry is written, every given mail
// stays waiting, in the same order, and the reader's health (1519) is set unhealthy with the outcome and
// why; the next successful batch sets it healthy again.
//
// Neither a mail that is simply gone nor an ordinary read failure ever makes the reader unhealthy (ADR 0459
// §12: health is the judgement's, review round 1 finding 3) — only a judgement's own outcome does.
//
// mesh-mail-reader never asks to send, move, flag or delete (ADR 0459 §11): this function asks only readText
// (seat:mail-store.read) and judge (claude-code.claude_code_judge, claude_code_judged) — 1520/AC5 is proven by
// building both from a recording Ask and checking what it recorded, not by anything this function does itself.
func RunOneBatch(ctx context.Context, store Store, readText ReadTextFunc, judge JudgeFunc, waiting []WaitingMail, now func() time.Time) error {
func RunOneBatch(ctx context.Context, store Store, readText ReadTextFunc, judge JudgeFunc, waiting []WaitingMail, now func() time.Time, logf func(string, ...any)) error {
if len(waiting) > MaxMailsPerBatch {
waiting = waiting[:MaxMailsPerBatch]
}
@@ -179,17 +217,31 @@ func RunOneBatch(ctx context.Context, store Store, readText ReadTextFunc, judge
if err != nil {
return err
}
mails := make([]QuestionMail, len(waiting))
ids := make([]int64, len(waiting))
for i, w := range waiting {
live := make([]WaitingMail, 0, len(waiting))
mails := make([]QuestionMail, 0, len(waiting))
ids := make([]int64, 0, len(waiting))
for _, w := range waiting {
text, err := readText(w.Account, w.Folder, w.UID)
if err != nil {
// mail-store.read itself failing stops the batch exactly as a refused judgement would: the mails
// stay waiting, and the reader is made unhealthy with the cause.
return recordOutcome(ctx, store, OutcomeRefused, "reading the mail's text: "+err.Error(), now())
if isNoMessageError(err) {
if takeErr := store.TakeWaiting(ctx, w.Seq); takeErr != nil {
return takeErr
}
logf("[mesh-mail-reader] %s %s uid %d is gone (moved or deleted since it was heard): taken out of the waiting queue, never judged", w.Account, w.Folder, w.UID)
continue
}
// Any other read failure: the whole batch — this mail and whatever is after it — waits for
// the next batch slot, and the reader stays whatever health it already was.
logf("[mesh-mail-reader] reading %s %s uid %d: %v; this batch waits for the next slot", w.Account, w.Folder, w.UID, err)
return nil
}
mails[i] = QuestionMail{ID: w.Seq, Account: w.Account, Folder: w.Folder, Date: w.Date, From: w.From, Subject: w.Subject, Flags: w.Flags, Text: text}
ids[i] = w.Seq
live = append(live, w)
mails = append(mails, QuestionMail{ID: w.Seq, Account: w.Account, Folder: w.Folder, Date: w.Date, From: w.From, Subject: w.Subject, Flags: w.Flags, Text: text})
ids = append(ids, w.Seq)
}
if len(live) == 0 {
// Every mail given was gone: the queue already reflects it, and there is nothing left to judge.
return nil
}
question := BuildQuestion(mails, groups, lessons)
raw, outcome, why := judge(question.Text, AnswerSchema())
@@ -201,7 +253,7 @@ func RunOneBatch(ctx context.Context, store Store, readText ReadTextFunc, judge
return recordOutcome(ctx, store, OutcomeOutOfShape, err.Error(), now())
}
judgedAt := now()
for _, w := range waiting {
for _, w := range live {
j := answer.Mails[w.Seq]
var groupID int64
if j.Group != "" {
@@ -262,7 +314,7 @@ func RunBatches(ctx context.Context, store Store, readText ReadTextFunc, judge J
waiting = waiting[:MaxMailsPerBatch]
}
lastStart = now()
if err := RunOneBatch(ctx, store, readText, judge, waiting, now); err != nil {
if err := RunOneBatch(ctx, store, readText, judge, waiting, now, logf); err != nil {
logf("[mesh-mail-reader] a batch failed: %v", err)
}
if err := SendSignal(ctx, store, check, tell); err != nil {
@@ -5,10 +5,14 @@ import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
)
// noopLogf is the logf RunOneBatch and RunBatches want, for a test that does not care what was logged.
func noopLogf(string, ...any) {}
func seedWaiting(t *testing.T, store Store, n int) {
t.Helper()
for i := 0; i < n; i++ {
@@ -58,45 +62,77 @@ func TestNextBatchWait(t *testing.T) {
}
}
// 1520/AC1: with 30 mails waiting, three batches of 10 start at least MinBatchGap (160s) apart (a test
// clock: the batch loop here is driven by hand, jumping straight to each batch's own allowed start, as
// nextBatchWait says to, passing MinBatchGap explicitly as production's RunBatches would with an
// unshortened BatchGap).
// 1520/AC1 (review round 1 finding 5): with 30 mails waiting, RunBatches ITSELF — never a hand-driven copy
// of its own loop — runs three batches of 10, their starts at least MinBatchGap (160s) apart. A test clock:
// now reads a clock this test owns, and sleep (the only thing RunBatches calls to wait) advances that same
// clock instead of actually waiting 320s+ of wall time. The judge stand-in records when each batch was
// judged (synchronous with RunOneBatch's own lastStart, since nothing sleeps between them) and cancels ctx
// once the 3rd has run, so RunBatches returns on its own next turn through the loop — this fails the moment
// RunBatches stops pacing, not only when nextBatchWait's own math would (TestNextBatchWait, above).
func TestThirtyWaitingMailsRunAsThreeBatchesAtLeast160sApart(t *testing.T) {
ctx := context.Background()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := newMemStore()
seedWaiting(t, store, 30)
now := time.Date(2026, 10, 20, 10, 0, 0, 0, time.UTC)
var lastStart time.Time
var starts []time.Time
batches := 0
for {
all, err := store.Waiting(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) == 0 {
break
}
if wait := nextBatchWait(lastStart, now, MinBatchGap); wait > 0 {
now = now.Add(wait)
}
batch := all
if len(batch) > MaxMailsPerBatch {
batch = batch[:MaxMailsPerBatch]
}
nowFunc := func() time.Time { return now }
if err := RunOneBatch(ctx, store, noopReadText, judgeAllNeither(batch), batch, nowFunc); err != nil {
t.Fatal(err)
}
starts = append(starts, now)
lastStart = now
batches++
now = now.Add(time.Second) // the batch itself takes a little time
seeded, err := store.Waiting(context.Background())
if err != nil {
t.Fatal(err)
}
if batches != 3 {
t.Fatalf("ran %d batches, want 3 (30 mails / 10 per batch)", batches)
ids := make([]int64, len(seeded))
for i, w := range seeded {
ids[i] = w.Seq
}
var clockMu sync.Mutex
clock := time.Date(2026, 10, 20, 10, 0, 0, 0, time.UTC)
now := func() time.Time {
clockMu.Lock()
defer clockMu.Unlock()
return clock
}
sleep := func(d time.Duration) {
clockMu.Lock()
clock = clock.Add(d)
clockMu.Unlock()
}
var starts []time.Time
call := 0
judge := func(string, map[string]any) (json.RawMessage, HealthOutcome, string) {
starts = append(starts, now())
start := call * MaxMailsPerBatch
end := start + MaxMailsPerBatch
if end > len(ids) {
end = len(ids)
}
batch := ids[start:end]
call++
mails := make([]map[string]any, len(batch))
for i, id := range batch {
mails[i] = map[string]any{"id": id, "mark": "neither"}
}
raw, _ := json.Marshal(map[string]any{"mails": mails})
if len(starts) == 3 {
cancel()
}
return raw, OutcomeOK, ""
}
check := func(string) (bool, error) { return true, nil }
tell := func(string, []string, string) (string, error) { return "", nil }
done := make(chan struct{})
go func() {
RunBatches(ctx, store, noopReadText, judge, check, tell, now, sleep, noopLogf)
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("RunBatches did not stop after its own 3rd batch and this test's cancel")
}
if len(starts) != 3 {
t.Fatalf("judge was called %d time(s), want 3 (30 mails / %d per batch)", len(starts), MaxMailsPerBatch)
}
for i := 1; i < len(starts); i++ {
if gap := starts[i].Sub(starts[i-1]); gap < MinBatchGap {
@@ -183,6 +219,34 @@ func TestNewJudgeRefused(t *testing.T) {
}
}
// 1519/AC2, 1520/AC4 (review round 1 finding 4): the ask's own error is sorted into its real outcome by the
// exact words the real claude-code module answers with (modules/claude-code/cmd/claude-code/judge.go) —
// never OutcomeRefused for all three, which is what every ask error landed as before.
func TestNewJudgeSortsItsCauseByTheRealJudgesWords(t *testing.T) {
cases := []struct {
name string
err string
want HealthOutcome
}{
{"timed out", "the agent on novox did not answer within 40s; the judgement was ended", OutcomeTimedOut},
{"out of shape", `the agent on novox answered, and not in the shape asked: "mails" is not an array`, OutcomeOutOfShape},
{"refused", "the agent on novox could not judge: holds no licence", OutcomeRefused},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ask := func(string, map[string]any) (json.RawMessage, error) { return nil, fmt.Errorf("%s", c.err) }
judge := NewJudge(ask, "novox", time.Now)
_, outcome, why := judge("the question", AnswerSchema())
if outcome != c.want {
t.Fatalf("outcome = %q, want %q", outcome, c.want)
}
if why != c.err {
t.Fatalf("why = %q, want the ask's own error %q, unchanged", why, c.err)
}
})
}
}
// 1520/AC3: an answered batch writes one mail entry per mail and each new mail group, and the waiting mails
// leave the queue.
func TestRunOneBatchWritesEntriesAndGroups(t *testing.T) {
@@ -199,7 +263,7 @@ func TestRunOneBatchWritesEntriesAndGroups(t *testing.T) {
return raw, OutcomeOK, ""
}
now := time.Date(2026, 10, 20, 11, 0, 0, 0, time.UTC)
if err := RunOneBatch(ctx, store, noopReadText, judge, waiting, func() time.Time { return now }); err != nil {
if err := RunOneBatch(ctx, store, noopReadText, judge, waiting, func() time.Time { return now }, noopLogf); err != nil {
t.Fatal(err)
}
entries, _ := store.Entries(ctx)
@@ -245,7 +309,7 @@ func TestRunOneBatchFailureKeepsWaitingAndSetsUnhealthy(t *testing.T) {
seedWaiting(t, store, 2)
waiting, _ := store.Waiting(ctx)
now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
if err := RunOneBatch(ctx, store, noopReadText, c.judge, waiting, func() time.Time { return now }); err != nil {
if err := RunOneBatch(ctx, store, noopReadText, c.judge, waiting, func() time.Time { return now }, noopLogf); err != nil {
t.Fatal(err)
}
if entries, _ := store.Entries(ctx); len(entries) != 0 {
@@ -262,7 +326,7 @@ func TestRunOneBatchFailureKeepsWaitingAndSetsUnhealthy(t *testing.T) {
// The next good batch sets it healthy again.
later := now.Add(time.Hour)
if err := RunOneBatch(ctx, store, noopReadText, judgeAllNeither(waiting), waiting, func() time.Time { return later }); err != nil {
if err := RunOneBatch(ctx, store, noopReadText, judgeAllNeither(waiting), waiting, func() time.Time { return later }, noopLogf); err != nil {
t.Fatal(err)
}
h, _ = store.ReaderHealth(ctx)
@@ -292,7 +356,7 @@ func TestRunOneBatchNeverAsksToSendMoveFlagOrDelete(t *testing.T) {
}
readText := NewReadText(recordingAsk)
judge := NewJudge(recordingAsk, "novox", time.Now)
if err := RunOneBatch(ctx, store, readText, judge, waiting, time.Now); err != nil {
if err := RunOneBatch(ctx, store, readText, judge, waiting, time.Now, noopLogf); err != nil {
t.Fatal(err)
}
if len(asked) == 0 {
@@ -306,3 +370,73 @@ func TestRunOneBatchNeverAsksToSendMoveFlagOrDelete(t *testing.T) {
}
}
}
// 1520 (review round 1 finding 3): a mail-store.read that answers "holds no message" (the mail was moved
// or deleted since it was heard) takes that one mail out of the waiting queue and the batch goes on with
// the rest — never unhealthy (ADR 0459 §12: health is the judgement's).
func TestRunOneBatchTakesAGoneMailOutAndGoesOnWithTheRest(t *testing.T) {
ctx := context.Background()
store := newMemStore()
seedWaiting(t, store, 2)
waiting, _ := store.Waiting(ctx)
gone, stays := waiting[0], waiting[1]
readText := func(account, folder string, uid uint32) (string, error) {
if uid == gone.UID {
return "", fmt.Errorf("the account %s holds no message %d in %s", account, uid, folder)
}
return "the mail's text", nil
}
judge := func(string, map[string]any) (json.RawMessage, HealthOutcome, string) {
raw, _ := json.Marshal(map[string]any{"mails": []map[string]any{{"id": stays.Seq, "mark": "neither"}}})
return raw, OutcomeOK, ""
}
now := time.Date(2026, 10, 20, 11, 0, 0, 0, time.UTC)
if err := RunOneBatch(ctx, store, readText, judge, waiting, func() time.Time { return now }, noopLogf); err != nil {
t.Fatal(err)
}
entries, _ := store.Entries(ctx)
if len(entries) != 1 || entries[0].MessageID != stays.MessageID {
t.Fatalf("entries = %+v, want exactly one, for the mail that was still there", entries)
}
left, _ := store.Waiting(ctx)
if len(left) != 0 {
t.Fatalf("waiting = %v, want none (the gone mail taken out, the other judged)", left)
}
h, _ := store.ReaderHealth(ctx)
if !h.Healthy {
t.Fatalf("health = %+v, want healthy: a gone mail is never the judgement's problem", h)
}
}
// 1520 (review round 1 finding 3): any OTHER read failure leaves the whole batch's mails waiting, in
// order, for the next batch slot — the judge is never even asked — and never makes the reader unhealthy.
func TestRunOneBatchOtherReadFailureLeavesTheWholeBatchWaiting(t *testing.T) {
ctx := context.Background()
store := newMemStore()
seedWaiting(t, store, 2)
waiting, _ := store.Waiting(ctx)
readText := func(string, string, uint32) (string, error) {
return "", fmt.Errorf("the mailbox is unreachable")
}
judge := func(string, map[string]any) (json.RawMessage, HealthOutcome, string) {
t.Fatal("the judge must never be asked when a mail could not be read for a reason other than being gone")
return nil, OutcomeRefused, ""
}
now := time.Date(2026, 10, 20, 11, 0, 0, 0, time.UTC)
if err := RunOneBatch(ctx, store, readText, judge, waiting, func() time.Time { return now }, noopLogf); err != nil {
t.Fatal(err)
}
if entries, _ := store.Entries(ctx); len(entries) != 0 {
t.Fatalf("entries = %v, want none", entries)
}
left, _ := store.Waiting(ctx)
if len(left) != 2 || left[0].Seq != waiting[0].Seq || left[1].Seq != waiting[1].Seq {
t.Fatalf("waiting = %v, want the same 2 mails, in the same order", left)
}
h, _ := store.ReaderHealth(ctx)
if !h.Healthy {
t.Fatalf("health = %+v, want healthy: an ordinary read failure is never the judgement's problem", h)
}
}
@@ -13,12 +13,17 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// sinceRE matches the "(since HH:MM YYYY-MM-DD)" health.go's own Answer embeds in why (review round 1
// finding 6): proves since when is actually named, not merely that the cause's own word ("licence") is.
var sinceRE = regexp.MustCompile(`since \d{2}:\d{2} \d{4}-\d{2}-\d{2}`)
// healthAnswer reads mail_reader_health through the player, as a plain map (the tool carries no caller
// check, ADR 0459 §12).
func healthAnswer(t *testing.T, r *startedReader) map[string]any {
@@ -37,10 +42,14 @@ func healthAnswer(t *testing.T, r *startedReader) map[string]any {
return out
}
// 1524/AC1, AC2: a refused judgement ("holds no licence", as claude-code's own judge.go answers when the
// licence binding is removed) makes the reader unhealthy with that cause and since when, and sends no tell;
// once the judge answers again, both mails (one heard while unhealthy) are read in the order they were
// published, and the reader answers healthy again.
// 1524/AC1, AC2 (review round 1 finding 6): a mattering mail judged and told is answered FIRST, its own
// tell dropped (so it is genuinely untold, the one thing that would make a later batch try the mail
// signal again) — then a refused judgement ("holds no licence", as claude-code's own judge.go answers when
// the licence binding is removed) makes the reader unhealthy with that cause and since when, and sends no
// tell for that still-untold mail; once the judge answers again, both mails (the second heard while
// unhealthy) are read in the order they were published, and the reader answers healthy again. The earlier
// version of this test asserted "no tell while unhealthy" with no mattering mail ever judged at all, so
// SendSignal had nothing to try telling regardless of health — proving nothing.
func TestAcceptanceRefusedJudgementIsUnhealthyThenCatchesUpInPublishedOrder(t *testing.T) {
r := startReader(t)
r.stand.MailAccounts = oneAccountAnswer("bob")
@@ -48,12 +57,14 @@ func TestAcceptanceRefusedJudgementIsUnhealthyThenCatchesUpInPublishedOrder(t *t
r.stand.MessengerCheck = func(map[string]any) (any, error) { return map[string]any{"allowed": true}, nil }
var tellCalls count
r.stand.Tell = func(map[string]any) (any, error) {
tellCalls.inc()
if tellCalls.inc() == 1 {
return map[string]any{"sent_on": ""}, nil // dropped: the operator channel's own cap (1522/AC2)
}
return map[string]any{"sent_on": time.Now().UTC().Format(time.RFC3339)}, nil
}
var mu sync.Mutex
refusing := true
refusing := false
var judgedOrder recorded[int64]
r.stand.Judge = func(args map[string]any) (any, error) {
mu.Lock()
@@ -71,15 +82,39 @@ func TestAcceptanceRefusedJudgementIsUnhealthyThenCatchesUpInPublishedOrder(t *t
}
mails := make([]map[string]any, len(ids))
for i, id := range ids {
mails[i] = map[string]any{"id": id, "mark": "neither", "summary": "s"}
mails[i] = map[string]any{"id": id, "mark": "matters", "summary": "s", "why": "pay", "due-date": "2026-11-01"}
}
raw, _ := json.Marshal(map[string]any{"mails": mails})
return map[string]any{"answer": json.RawMessage(raw)}, nil
}
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "first", time.Now())
conn := pgConnect(t, r.dbURL)
ctx := context.Background()
// 1524/AC1: unhealthy with cause and since, no tell.
// A mattering mail, judged successfully, its own tell dropped — so it stays genuinely untold.
publishAdded(t, r.nc, "bob", "INBOX", 1, []map[string]any{{"name": "Alice", "address": "alice@example.org"}}, "first", time.Now())
waitFor(t, 30*time.Second, "the first (dropped) tell attempt", func() bool { return tellCalls.get() >= 1 })
waitFor(t, 10*time.Second, "the first entry to be written", func() bool {
var n int
_ = conn.QueryRow(ctx, `select count(*) from mail_entry`).Scan(&n)
return n == 1
})
var told bool
if err := conn.QueryRow(ctx, `select told from mail_entry limit 1`).Scan(&told); err != nil {
t.Fatal(err)
}
if told {
t.Fatal("the mail was marked told even though its own tell was dropped")
}
// From here, every judgement is refused (1524/AC1) — set before the second mail is even heard, so its
// own batch, whenever it runs, is refused.
mu.Lock()
refusing = true
mu.Unlock()
publishAdded(t, r.nc, "bob", "INBOX", 2, []map[string]any{{"name": "Bob", "address": "bob2@example.org"}}, "second", time.Now().Add(time.Second))
// 1524/AC1: unhealthy with cause and since when, no tell for the mail that is already untold.
waitFor(t, 30*time.Second, "mail_reader_health to answer unhealthy", func() bool {
h := healthAnswer(t, r)
healthy, _ := h["healthy"].(bool)
@@ -90,22 +125,20 @@ func TestAcceptanceRefusedJudgementIsUnhealthyThenCatchesUpInPublishedOrder(t *t
if !strings.Contains(why, "licence") {
t.Fatalf("health = %v, want why naming the licence", h)
}
if n := tellCalls.get(); n != 0 {
t.Fatalf("a tell was sent while unhealthy: %d call(s)", n)
if !sinceRE.MatchString(why) {
t.Fatalf("health's why = %q, want it to name since when (HH:MM YYYY-MM-DD, health.go's own Answer)", why)
}
// A second mail, heard while still unhealthy — it must stay waiting, in order, not lost (1518's own
// guarantee, exercised here end to end).
publishAdded(t, r.nc, "bob", "INBOX", 2, []map[string]any{{"name": "Bob", "address": "bob2@example.org"}}, "second", time.Now().Add(time.Second))
before := tellCalls.get()
time.Sleep(2 * time.Second) // give the next failed batch attempt, if any, time to run and fail again
if n := tellCalls.get(); n != before {
t.Fatalf("a tell was sent while unhealthy: %d call(s), want still %d (the untold mail from above)", n, before)
}
// The judge answers again (1524/AC2): catch-up in the order the two mails were published.
mu.Lock()
refusing = false
mu.Unlock()
conn := pgConnect(t, r.dbURL)
ctx := context.Background()
waitFor(t, BatchGap+60*time.Second, "both entries written", func() bool {
var n int
if err := conn.QueryRow(ctx, `select count(*) from mail_entry`).Scan(&n); err != nil {
@@ -19,6 +19,7 @@ import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -33,14 +34,46 @@ func envFromOS() Env {
return Env{Callers: os.Getenv("MESH_MAIL_READER_CALLERS")}
}
// cachedStore opens the index once, lazily, and hands every later caller that SAME store (review round 1
// finding 2, issue 1517/1518): the Bundle's own tool calls (one per mesh/ask, tools.go), hearLoop's handler
// (one per event heard) and batchLoop. Before this, each of those called storeFor itself, which opened a
// fresh *pgxpool.Pool (PgStoreFromEnv) every time and never closed it — one connection pool leaked per tool
// call and per heard event, for the life of the process. Mirrors mesh-mail's own main.go Index (serve):
// a failed open is never retried here on its own; it is retried the next time any caller calls get, exactly
// as hearLoop's own backoff or a tool call's own caller already does.
type cachedStore struct {
open func(ctx context.Context) (Store, error)
mu sync.Mutex
s Store
}
func newCachedStore(open func(ctx context.Context) (Store, error)) *cachedStore {
return &cachedStore{open: open}
}
// get is this cachedStore's own storeFor: the open store once it has one, or a fresh attempt to open one.
func (c *cachedStore) get(ctx context.Context) (Store, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.s != nil {
return c.s, nil
}
s, err := c.open(ctx)
if err != nil {
return nil, err
}
c.s = s
return c.s, nil
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "prepare" {
os.Exit(prepare())
}
storeFor := func(ctx context.Context) (Store, error) { return PgStoreFromEnv(ctx) }
b := &Bundle{Env: envFromOS(), Store: storeFor}
go hearLoop(storeFor, stdio.Subscribe, time.Sleep)
go batchLoop(storeFor)
store := newCachedStore(func(ctx context.Context) (Store, error) { return PgStoreFromEnv(ctx) })
b := &Bundle{Env: envFromOS(), Store: store.get}
go hearLoop(store.get, stdio.Subscribe, time.Sleep)
go batchLoop(store.get)
if err := stdio.ServeOn("", b.Tools(), os.Stdin, os.Stdout); err != nil {
logf("[mesh-mail-reader] %v", err)
os.Exit(1)
@@ -0,0 +1,62 @@
package main
import (
"context"
"errors"
"testing"
)
// TestCachedStoreOpensOnceAndSharesTheSameStore (review round 1 finding 2, issue 1517/1518): two separate
// callers (as the Bundle's own tool calls and hearLoop's handler are, in the running bundle) each calling
// get get back the exact same Store, and open — the thing that would have opened a fresh *pgxpool.Pool
// every time — is called exactly once.
func TestCachedStoreOpensOnceAndSharesTheSameStore(t *testing.T) {
opens := 0
want := newMemStore()
c := newCachedStore(func(context.Context) (Store, error) {
opens++
return want, nil
})
first, err := c.get(context.Background())
if err != nil {
t.Fatal(err)
}
second, err := c.get(context.Background())
if err != nil {
t.Fatal(err)
}
if first != want || second != want {
t.Fatalf("get returned %v and %v, want both to be %v", first, second, want)
}
if opens != 1 {
t.Fatalf("open was called %d time(s), want 1 (opened once, shared everywhere)", opens)
}
}
// TestCachedStoreRetriesAfterAFailedOpen: a failed open is retried on the NEXT call, never looped on its
// own — the caller already retries (hearLoop's own backoff, a tool call's own caller).
func TestCachedStoreRetriesAfterAFailedOpen(t *testing.T) {
opens := 0
want := newMemStore()
failWanted := errors.New("the index cannot be reached")
c := newCachedStore(func(context.Context) (Store, error) {
opens++
if opens == 1 {
return nil, failWanted
}
return want, nil
})
if _, err := c.get(context.Background()); err != failWanted {
t.Fatalf("first get: %v, want %v", err, failWanted)
}
got, err := c.get(context.Background())
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("second get = %v, want %v", got, want)
}
if opens != 2 {
t.Fatalf("open was called %d time(s), want 2 (the failed attempt, then the one that succeeded)", opens)
}
}
@@ -55,6 +55,13 @@ func (r *recordedArgs) wasSet() bool {
func TestAcceptanceMailCorrectionAndUndoReachTheStandIn(t *testing.T) {
mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
// The classical list below the mail-reading section still asks seat:mail-store.accounts on every page
// load (accounts.go's own accountsPage), exactly as milestone 1's own tests set it up (review round 1
// finding 1, issue 1523): left unset, GET "/" fails this test as an unknown ask, never reaching the
// mail-reading flow this test is actually about.
mw.store.Accounts = func(map[string]any) (any, error) {
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
}
mw.mailReading.Status = func(map[string]any) (any, error) {
return map[string]any{"healthy": true, "weeks": []map[string]any{}}, nil
}
@@ -87,12 +87,12 @@ type standInMailStore struct {
calls []askRecord
}
// answer is this stand-in's own mailStoreFunc.
// answer is this stand-in's own mailStoreFunc. The key is matched BEFORE anything is recorded (review
// round 1 finding 1, issue 1430/1523): a key that is not one of mail-store's own five verbs — such as
// seat:mail-reading.<verb>, which handleAsk tries here first and falls through on — is never added to
// Calls/Keys at all, so a test's own "only these verbs were asked" check (acceptance_integration_test.go's
// wantKeys) is never tripped by an ask this stand-in was never for.
func (s *standInMailStore) answer(key string, args map[string]any) (any, error, bool) {
raw, _ := json.Marshal(args)
s.mu.Lock()
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
s.mu.Unlock()
var f func(args map[string]any) (any, error)
switch key {
case "seat:mail-store.accounts":
@@ -108,6 +108,10 @@ func (s *standInMailStore) answer(key string, args map[string]any) (any, error,
default:
return nil, nil, false
}
raw, _ := json.Marshal(args)
s.mu.Lock()
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
s.mu.Unlock()
if f == nil {
return nil, nil, false
}
@@ -160,12 +164,14 @@ type standInMailReading struct {
calls []askRecord
}
// answer is this stand-in's own mailReadingFunc.
// answer is this stand-in's own mailReadingFunc. A key matched below but left unset by the test answers an
// error, never "unknown" (review round 1 finding 1, issue 1430/1523): accounts.go's mailReadingSection asks
// mail-reading.status on every page load now (issue 1430), so every test from before mail-reading existed
// that loads "/" and never configured this stand-in at all would otherwise fail as an unknown ask; an error
// here is exactly what mailReadingSection itself reads as "mail-reading does not answer" and takes its own
// "unavailable" path, same as the real seat refusing or timing out would. A key that is not one of
// mail-reading's own six verbs is still unknown, as before (never recorded, below).
func (s *standInMailReading) answer(key string, args map[string]any) (any, error, bool) {
raw, _ := json.Marshal(args)
s.mu.Lock()
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
s.mu.Unlock()
var f func(args map[string]any) (any, error)
switch key {
case "seat:mail-reading.entries":
@@ -183,8 +189,12 @@ func (s *standInMailReading) answer(key string, args map[string]any) (any, error
default:
return nil, nil, false
}
raw, _ := json.Marshal(args)
s.mu.Lock()
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
s.mu.Unlock()
if f == nil {
return nil, nil, false
return nil, fmt.Errorf("the test's own stand-in for %s answers nothing", key), true
}
out, err := f(args)
return out, err, true