Send the mail signal after each batch: messenger_check, tell, untold carried over (issue 1522)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 --------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user