diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/batch.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/batch.go index db22c949..30ba8bcc 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/batch.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/batch.go @@ -218,10 +218,13 @@ func RunOneBatch(ctx context.Context, store Store, readText ReadTextFunc, judge } // RunBatches is the loop the running module drives (main.go): pace batch starts at least BatchGap apart -// (nextBatchWait), take at most MaxMailsPerBatch of the waiting mails in arrival order, and run one batch at a -// time, forever, until ctx is done. Not unit-tested itself beyond compiling (go vet): 1520/AC1's pacing and -// AC2-AC5's one-batch behaviour are each proven directly, on nextBatchWait and RunOneBatch, in batch_test.go. -func RunBatches(ctx context.Context, store Store, readText ReadTextFunc, judge JudgeFunc, now func() time.Time, sleep func(time.Duration), logf func(string, ...any)) { +// (nextBatchWait), take at most MaxMailsPerBatch of the waiting mails in arrival order, run one batch at a +// time, and after each one — win or lose — try the mail signal (SendSignal, tell.go, issue 1522; it is its +// own no-op when there is nothing untold that matters, or the reader is unhealthy) — forever, until ctx is +// done. Not unit-tested itself beyond compiling (go vet): 1520/AC1's pacing and AC2-AC5's one-batch +// behaviour, and 1522's signal-sending, are each proven directly, on nextBatchWait, RunOneBatch and +// SendSignal, in batch_test.go and tell_test.go. +func RunBatches(ctx context.Context, store Store, readText ReadTextFunc, judge JudgeFunc, check MessengerCheck, tell Tell, now func() time.Time, sleep func(time.Duration), logf func(string, ...any)) { var lastStart time.Time for { if ctx.Err() != nil { @@ -248,5 +251,8 @@ func RunBatches(ctx context.Context, store Store, readText ReadTextFunc, judge J if err := RunOneBatch(ctx, store, readText, judge, waiting, now); err != nil { logf("[mesh-mail-reader] a batch failed: %v", err) } + if err := SendSignal(ctx, store, check, tell); err != nil { + logf("[mesh-mail-reader] the mail signal could not be sent: %v", err) + } } } diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go index 3a16b424..038cb470 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go @@ -65,7 +65,9 @@ func batchLoop(storeFor func(context.Context) (Store, error)) { node := os.Getenv("MESH_NODE") readText := NewReadText(bundleAsk) judge := NewJudge(bundleAsk, node, time.Now) - RunBatches(context.Background(), store, readText, judge, time.Now, time.Sleep, logf) + check := NewMessengerCheck(bundleAsk) + tell := NewTell(bundleAsk) + RunBatches(context.Background(), store, readText, judge, check, tell, time.Now, time.Sleep, logf) } // ---- hearing -------------------------------------------------------------------------------------------------- diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go index 5d7ed598..b4d1a4e7 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go @@ -64,7 +64,13 @@ func TestTheManifestClaimsMailReadingAndInvokesReadOnly(t *testing.T) { if !reflect.DeepEqual(m.Claims, wantClaims) { t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims) } - wantInvokes := []string{"seat:mail-store.read", "seat:mail-store.accounts"} + // mail-store.read and mail-store.accounts only (1426/AC2); claude_code_judge/judged (1520), messenger_check + // and operator-channel.tell (1522) are its other invokes, added as each landed — never send or send-status. + wantInvokes := []string{ + "seat:mail-store.read", "seat:mail-store.accounts", + "claude-code.claude_code_judge", "claude-code.claude_code_judged", + "messenger.messenger_check", "seat:operator-channel.tell", + } if !reflect.DeepEqual(m.Invokes, wantInvokes) { t.Fatalf("invokes %v, want exactly %v (no send, send-status, or any verb that changes the mailbox)", m.Invokes, wantInvokes) } diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tell.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tell.go new file mode 100644 index 00000000..90bcc2e4 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tell.go @@ -0,0 +1,118 @@ +package main + +// Sending the mail signal (novox/hq ADR 0461 §1, §2, §4 to §6; issue 1522): the words (signal.go, issue 1429) +// checked with messenger.messenger_check, then one seat:operator-channel.tell with subject "mail" — or the +// safe form when the words are refused. mesh-mail-reader's manifest invokes both (1522/AC4). + +import ( + "context" + "encoding/json" + "fmt" +) + +// MessengerCheck is one seat:messenger.messenger_check ask (ADR 0461 §4): whether text is safe to say on an +// operator channel. +type MessengerCheck func(text string) (allowed bool, err error) + +// NewMessengerCheck is MessengerCheck over the module's own Ask (batch.go). +func NewMessengerCheck(ask Ask) MessengerCheck { + return func(text string) (bool, error) { + raw, err := ask("messenger.messenger_check", map[string]any{"text": text}) + if err != nil { + return false, err + } + var out struct { + Allowed bool `json:"allowed"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return false, fmt.Errorf("messenger_check's answer could not be read: %.200s", raw) + } + return out.Allowed, nil + } +} + +// Tell is one seat:operator-channel.tell ask (ADR 0461 §1): the headline, at most two lines, and the +// subject "mail" (1522/AC1). SentOn answers non-empty once a channel actually said it (1522/AC2); empty +// means every channel dropped it (the cap of 10 an hour, messenger's outbox.go) or refused it outright. +type Tell func(headline string, lines []string, subject string) (sentOn string, err error) + +// NewTell is Tell over the module's own Ask (batch.go). +func NewTell(ask Ask) Tell { + return func(headline string, lines []string, subject string) (string, error) { + raw, err := ask("seat:operator-channel.tell", map[string]any{"headline": headline, "lines": lines, "subject": subject}) + if err != nil { + return "", err + } + var out struct { + SentOn string `json:"sent_on"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("operator-channel.tell's answer could not be read: %.200s", raw) + } + return out.SentOn, nil + } +} + +// SendSignal is 1522: every entry marked matters and not yet told — including one carried over from an +// earlier batch that went untold (1429/AC4) — is gathered; its words are built (signal.go) and checked word +// by word with check; any one refused falls back to the safe form (1522/AC1). Nothing is sent, and nothing +// asked at all, when there is nothing to say (a batch whose only mark is scam warning, 1522/AC3) or while +// the reader is unhealthy (1522/AC3, ADR 0461 §6) — read fresh from the store, not assumed from the caller's +// own last outcome. The tell sent, every one of those entries is marked told only once it answers sent_on +// (1522/AC2); a tell that answers sent_on empty leaves them exactly as they were, for the next signal to +// count (1522/AC2, AC3). +func SendSignal(ctx context.Context, store Store, check MessengerCheck, tell Tell) error { + health, err := store.ReaderHealth(ctx) + if err != nil { + return err + } + if !health.Healthy { + return nil + } + entries, err := store.Entries(ctx) + if err != nil { + return err + } + var toTell []Entry + var mails []SignalMail + for _, e := range entries { + if e.Mark == MarkMatters && !e.Told { + toTell = append(toTell, e) + mails = append(mails, SignalMail{SenderName: e.SenderName, Summary: e.Summary, Date: e.Date, DueDate: e.DueDate}) + } + } + if len(toTell) == 0 { + return nil + } + + words := BuildSignal(mails) + allowed := true + for _, line := range append([]string{words.Headline}, words.Lines...) { + ok, err := check(line) + if err != nil { + return err + } + if !ok { + allowed = false + break + } + } + if !allowed { + words = SafeSignal(len(toTell)) + } + + sentOn, err := tell(words.Headline, words.Lines, "mail") + if err != nil { + return err + } + if sentOn == "" { + return nil + } + for _, e := range toTell { + e.Told = true + if err := store.UpdateEntry(ctx, e); err != nil { + return err + } + } + return nil +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tell_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tell_test.go new file mode 100644 index 00000000..707c7f3f --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tell_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "testing" + "time" +) + +func mattersEntry(sender, summary string) Entry { + return Entry{Account: "bob", SenderName: sender, Summary: summary, Mark: MarkMatters, Date: time.Now()} +} + +// recordingCheck and recordingTell let a test see exactly what SendSignal asked, and answer however the +// test needs (1522/AC1, AC2). +func recordingCheck(allow bool) (MessengerCheck, *[]string) { + var asked []string + return func(text string) (bool, error) { + asked = append(asked, text) + return allow, nil + }, &asked +} + +func recordingTell(sentOn string) (Tell, *[]string, *[]string) { + var headlines, subjects []string + return func(headline string, lines []string, subject string) (string, error) { + headlines = append(headlines, headline) + subjects = append(subjects, subject) + return sentOn, nil + }, &headlines, &subjects +} + +// 1522/AC1: a batch marking mails as mattering asks messenger_check with the headline and lines, then +// sends one tell with subject "mail". +func TestSendSignalChecksWordsThenTellsWithSubjectMail(t *testing.T) { + ctx := context.Background() + store := newMemStore() + id, _ := store.AddEntry(ctx, mattersEntry("Alice", "pay the bill")) + check, asked := recordingCheck(true) + tell, headlines, subjects := recordingTell("2026-10-21T10:00:00Z") + + if err := SendSignal(ctx, store, check, tell); err != nil { + t.Fatal(err) + } + if len(*asked) == 0 || (*asked)[0] != "1 mail matters" { + t.Fatalf("messenger_check was asked %v, want the headline first", *asked) + } + if len(*headlines) != 1 || (*headlines)[0] != "1 mail matters" { + t.Fatalf("tell's headlines = %v", *headlines) + } + if len(*subjects) != 1 || (*subjects)[0] != "mail" { + t.Fatalf("tell's subjects = %v, want \"mail\"", *subjects) + } + entry, _, _ := store.Entry(ctx, id) + if !entry.Told { + t.Fatal("the entry was not marked told") + } +} + +// 1522/AC1: words messenger_check refuses are replaced by the safe form. +func TestSendSignalFallsBackToTheSafeFormWhenRefused(t *testing.T) { + ctx := context.Background() + store := newMemStore() + store.AddEntry(ctx, mattersEntry("Alice", "pay the bill")) + check, _ := recordingCheck(false) + tell, headlines, _ := recordingTell("sent") + + if err := SendSignal(ctx, store, check, tell); err != nil { + t.Fatal(err) + } + if len(*headlines) != 1 { + t.Fatalf("tell called %d time(s), want 1", len(*headlines)) + } +} + +// 1522/AC2: a tell whose answer has sent_on non-empty marks the mails told; one with sent_on empty leaves +// them untold, and the next mail signal counts them (1429/AC4). +func TestSendSignalSentOnEmptyLeavesMailsUntold(t *testing.T) { + ctx := context.Background() + store := newMemStore() + id, _ := store.AddEntry(ctx, mattersEntry("Alice", "pay the bill")) + check, _ := recordingCheck(true) + tellDropped, calls, _ := recordingTell("") // every channel dropped it + + if err := SendSignal(ctx, store, check, tellDropped); err != nil { + t.Fatal(err) + } + entry, _, _ := store.Entry(ctx, id) + if entry.Told { + t.Fatal("the entry was marked told even though sent_on was empty") + } + if len(*calls) != 1 { + t.Fatalf("tell called %d time(s)", len(*calls)) + } + + // The next signal counts the still-untold mail (1429/AC4): SendSignal asks about it again. + tellSent, headlines, _ := recordingTell("sent-now") + if err := SendSignal(ctx, store, check, tellSent); err != nil { + t.Fatal(err) + } + if len(*headlines) != 1 { + t.Fatalf("the second signal was not sent") + } + entry, _, _ = store.Entry(ctx, id) + if !entry.Told { + t.Fatal("the entry was still not marked told after the second, successful signal") + } +} + +// 1522/AC3: a batch whose only marks are scam warnings sends no tell. +func TestSendSignalSendsNothingForScamWarningsAlone(t *testing.T) { + ctx := context.Background() + store := newMemStore() + store.AddEntry(ctx, Entry{Account: "bob", Mark: MarkScamWarning, SenderName: "Eve", Summary: "a scam"}) + check, asked := recordingCheck(true) + tell, headlines, _ := recordingTell("sent") + + if err := SendSignal(ctx, store, check, tell); err != nil { + t.Fatal(err) + } + if len(*asked) != 0 || len(*headlines) != 0 { + t.Fatalf("messenger_check asked %v, tell called %d time(s); want neither for a scam-only batch", *asked, len(*headlines)) + } +} + +// 1522/AC3: no tell while the reader is unhealthy; the untold mails are counted by the first signal after +// recovery. +func TestSendSignalSendsNothingWhileUnhealthy(t *testing.T) { + ctx := context.Background() + store := newMemStore() + id, _ := store.AddEntry(ctx, mattersEntry("Alice", "pay the bill")) + store.SetReaderHealth(ctx, ReaderHealth{Healthy: false, Cause: OutcomeRefused, Why: "holds no licence"}) + check, asked := recordingCheck(true) + tell, headlines, _ := recordingTell("sent") + + if err := SendSignal(ctx, store, check, tell); err != nil { + t.Fatal(err) + } + if len(*asked) != 0 || len(*headlines) != 0 { + t.Fatalf("messenger_check asked %v, tell called %d time(s); want neither while unhealthy", *asked, len(*headlines)) + } + + // After recovery, the first signal counts the mail that went untold while unhealthy. + store.SetReaderHealth(ctx, ReaderHealth{Healthy: true}) + if err := SendSignal(ctx, store, check, tell); err != nil { + t.Fatal(err) + } + if len(*headlines) != 1 { + t.Fatalf("after recovery, tell called %d time(s), want 1", len(*headlines)) + } + entry, _, _ := store.Entry(ctx, id) + if !entry.Told { + t.Fatal("the entry was not marked told after recovery") + } +} + +// 1522/AC4: the manifest invokes seat:operator-channel.tell and messenger.messenger_check. +func TestTheManifestInvokesTellAndMessengerCheck(t *testing.T) { + m, _ := readManifest(t) + found := map[string]bool{} + for _, inv := range m.Invokes { + found[inv] = true + } + for _, want := range []string{"seat:operator-channel.tell", "messenger.messenger_check"} { + if !found[want] { + t.Fatalf("invokes %v, want it to include %q", m.Invokes, want) + } + } +} diff --git a/modules/mesh-mail-reader/module.json b/modules/mesh-mail-reader/module.json index 7e05017d..b83e8bb4 100644 --- a/modules/mesh-mail-reader/module.json +++ b/modules/mesh-mail-reader/module.json @@ -14,7 +14,11 @@ ], "invokes": [ "seat:mail-store.read", - "seat:mail-store.accounts" + "seat:mail-store.accounts", + "claude-code.claude_code_judge", + "claude-code.claude_code_judged", + "messenger.messenger_check", + "seat:operator-channel.tell" ], "requires": ["postgres-database"], "contributes": {