From 82475691b2945fd2de52f4c781b2353f40807a66 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 17:37:15 +0200 Subject: [PATCH] issue 797: messenger gains the tell verb for a plain notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0380 §8: the operator channel's notify is shaped for conditions (a key that stays open, escalation, a plain rule that rewrites a subject), so a module has no way to say a plain notice such as new mail. tell sends a headline and one or two lines at once to every channel, bypassing routing, the burst window and the walk hold; it offers Details like any message, is kept in the history, but its record is marked cleared at once so it never stays open. Its cap (10 a channel an hour) is its own, apart from notify's and the conditions'. --- modules/messenger/cmd/messenger/answers.go | 4 +- modules/messenger/cmd/messenger/holder.go | 2 + .../messenger/cmd/messenger/holder_test.go | 52 +++++++++++++ modules/messenger/cmd/messenger/main.go | 21 ++++++ .../messenger/cmd/messenger/manifest_test.go | 2 +- modules/messenger/cmd/messenger/outbox.go | 23 ++++++ modules/messenger/cmd/messenger/status.go | 75 +++++++++++++++++++ modules/messenger/cmd/messenger/words.go | 4 + modules/messenger/module.json | 2 + 9 files changed, 183 insertions(+), 2 deletions(-) diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index 76a9cbcb..d92bf1af 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -86,7 +86,9 @@ func (h *Holder) choices(r *Record, kind string) []Choice { if rec := h.openAsk(r); rec != nil { out = h.offers(rec, kind) } - if r.Origin != "condition" || len(out) >= MostActions { + // A condition offers Details so the operator can read more; a tell does too (ADR 0380 §8: "Details is + // offered as for any message") — it is its own message, so Details just shows it again. + if (r.Origin != "condition" && r.Origin != "tell") || len(out) >= MostActions { return out } now := h.Now() diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 2c237bb4..44a167b6 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -345,6 +345,8 @@ type Holder struct { wantSync bool history int stale int + // tellSeq makes every tell's key its own, even when two calls land in the same instant. + tellSeq int // answered is what came of each answer event, by its id: a redelivered one is acted on once. answered map[string]string // asks are the asks this router holds, by `.`, and tickets each ask's tickets to it. diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index b7a27309..241b5dc2 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -524,6 +524,58 @@ func TestNotifyIsKeptApartFromConditions(t *testing.T) { } } +func TestATellReachesEveryChannelOnceAndOffersOnlyDetails(t *testing.T) { + h, tg, dt, c, _ := newHolder(t) + c.pass(14 * time.Hour) // well past midnight: it is not held for being "night" — there is no such hold + out, err := h.Tell("new mail arrived for ace", []string{"One message from the inbox you watch."}, "mail") + if err != nil { + t.Fatal(err) + } + if len(tg.sends) != 1 || len(dt.sends) != 1 { + t.Fatalf("telegram %d, desktop %d sends", len(tg.sends), len(dt.sends)) + } + for _, m := range []Message{tg.sends[0], dt.sends[0]} { + if len(m.Actions) != 1 || m.Actions[0].Label != DetailsLabel { + t.Fatalf("actions %+v", m.Actions) + } + } + if sent, _ := out["sent_on"].([]string); len(sent) != 2 { + t.Fatalf("sent_on %v", out) + } + if len(h.Open()) != 0 { + t.Fatalf("a tell left an open record: %+v", h.Open()) + } + c.pass(RemindUrgent + time.Minute) + h.Tick() + if len(tg.sends) != 1 || len(dt.sends) != 1 { + t.Fatalf("a tell was reminded: telegram %d, desktop %d", len(tg.sends), len(dt.sends)) + } +} + +func TestATellIsDroppedPastItsOwnHourlyCap(t *testing.T) { + h, tg, _, _, _ := newHolder(t) + h.Desk = nil + for i := 0; i < TellCapPerHour; i++ { + if _, err := h.Tell("mail", []string{"line"}, ""); err != nil { + t.Fatal(err) + } + } + if len(tg.sends) != TellCapPerHour { + t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour) + } + out, err := h.Tell("mail", []string{"line"}, "") + if err != nil { + t.Fatal(err) + } + if len(tg.sends) != TellCapPerHour { + t.Fatalf("the 11th tell was sent: %d", len(tg.sends)) + } + dropped, _ := out["dropped"].([]string) + if len(dropped) != 1 || dropped[0] != "telegram" { + t.Fatalf("dropped %v", out) + } +} + func TestATestMessageRespectsTheRule(t *testing.T) { h, tg, dt, _, _ := newHolder(t) if out := h.Test("telegram", "see https://example.org"); out["refused"] == "" || len(tg.sends) != 0 { diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 3a521339..c50dcecd 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -645,6 +645,27 @@ func tools(h *Holder, l *listening) []stdio.Tool { return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear, strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved")) }}, + {Name: "operator-channel.tell", + Description: "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380): a " + + "headline and one or two lines, said at once on every channel, held to the content rule as notify's " + + "words are, and offering Details like any message. Never escalated, never kept open, never reminded; " + + "its own cap of 10 a channel an hour, apart from the conditions' and notify's.", + Input: map[string]any{ + "headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for ace"), + "lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "one or two plain lines"}, + "subject": str("what it is about: a machine's role, a module, a plan"), + }, + Run: func(a map[string]any) (any, error) { + var lines []string + if raw, ok := a["lines"].([]any); ok { + for _, l := range raw { + if s, ok := l.(string); ok { + lines = append(lines, s) + } + } + } + return h.Tell(strArg(a, "headline"), lines, strArg(a, "subject")) + }}, {Name: "messenger_status", Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " + "send, what it said of itself, its last error, how many it sent in the last hour against the cap, how " + diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 3ae820f8..4e94d8f3 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -62,7 +62,7 @@ func readManifest(t *testing.T) (manifest, string) { func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { m, _ := readManifest(t) - verbs := []string{"open", "history", "notify", "asks"} + verbs := []string{"open", "history", "notify", "tell", "asks"} want := []seat{ {Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}}, diff --git a/modules/messenger/cmd/messenger/outbox.go b/modules/messenger/cmd/messenger/outbox.go index 7e9d2ae7..31159a63 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -626,3 +626,26 @@ func (h *Holder) sentLastHour(channel string) int { } return n } + +// TellCapPerHour: at most this many tells reach one channel in an hour — the tell verb's own cap +// (ADR 0380 §8), never shared with CapPerHour, the conditions' and notify's, so a burst of plain +// notices cannot crowd out a condition or an ask. +const TellCapPerHour = 10 + +// allowTell says whether a channel is under tell's own cap. +func (h *Holder) allowTell(channel string) bool { + return h.tellSentLastHour(channel) < TellCapPerHour +} + +func (h *Holder) tellSentLastHour(channel string) int { + h.mu.Lock() + defer h.mu.Unlock() + since := h.Now().Add(-time.Hour) + n := 0 + for _, s := range h.recent { + if s.Channel == channel && s.What == "tell" && s.Outcome == "sent" && s.At.After(since) { + n++ + } + } + return n +} diff --git a/modules/messenger/cmd/messenger/status.go b/modules/messenger/cmd/messenger/status.go index 2fecf348..17a0cab6 100644 --- a/modules/messenger/cmd/messenger/status.go +++ b/modules/messenger/cmd/messenger/status.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "sort" + "strings" "time" ) @@ -309,3 +311,76 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plai held := h.queued(key) return map[string]any{"key": key, "sent_on": sent, "held": held, "refused": refused, "unsent": pending}, nil } + +// Tell sends a plain notice that needs no answer and opens no condition (ADR 0380 §8): one message with a +// headline and one or two lines, said at once on every channel the router sends on — bypassing the severity +// routing, the burst window and the walk hold that a condition's message goes through, and never reminded, +// escalated or kept open. Composed and held to the content rule exactly as notify's words are (allowedWords' +// reduction, withheld parts as today). It offers Details, like any message, and is kept in the history so +// `history` shows it — but its record is already marked cleared, so `open` never shows it and it is forgotten +// after ReopenWindow, as any cleared record is. Its cap, TellCapPerHour a channel an hour, is its own and +// never shared with CapPerHour: past it, a tell is counted as a drop in the history and said once in the log. +func (h *Holder) Tell(headline string, lines []string, subject string) (map[string]any, error) { + headline = strings.TrimSpace(headline) + if headline == "" { + return nil, errorf("headline is required") + } + if len(lines) == 0 || len(lines) > 2 { + return nil, errorf("lines takes one or two lines") + } + clean := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l == "" { + return nil, errorf("a line is empty") + } + clean = append(clean, l) + } + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + h.mu.Unlock() + now := h.Now() + h.mu.Lock() + h.tellSeq++ + seq := h.tellSeq + h.mu.Unlock() + key := fmt.Sprintf("tell.%d.%d", now.UnixNano(), seq) + rec := &Record{Key: key, Kind: "tell", Subject: subject, Origin: "tell", Headline: headline, + Explanation: strings.Join(clean, "\n"), Summary: headline, Raised: now, Seen: now, Count: 1, + Sent: map[string]string{}, Cleared: now} + h.mu.Lock() + h.open[key] = rec + h.mu.Unlock() + var sentOn, dropped []string + for _, ch := range h.channels() { + name := ch.Name() + if ch.Ready() != nil { + continue + } + if !h.allowTell(name) { + dropped = append(dropped, name) + h.Logf("[messenger] a tell was dropped on %s: it is at its own cap of %d tells an hour", name, TellCapPerHour) + h.record(Sent{At: now, Channel: name, Key: key, What: "tell", + Outcome: fmt.Sprintf("dropped: at its own cap of %d tells an hour", TellCapPerHour)}) + continue + } + m := h.sayOn(rec, "tell", name) + id, err := ch.Send(m) + h.noteChannel(name, err) + if err != nil { + h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "failed: " + err.Error()}) + continue + } + h.mu.Lock() + rec.Sent[name] = id + h.mu.Unlock() + sentOn = append(sentOn, name) + h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "sent"}) + } + h.persist(rec) + sort.Strings(sentOn) + sort.Strings(dropped) + return map[string]any{"key": key, "sent_on": sentOn, "dropped": dropped, "refused": rec.Refused}, nil +} diff --git a/modules/messenger/cmd/messenger/words.go b/modules/messenger/cmd/messenger/words.go index b3546232..1a450d85 100644 --- a/modules/messenger/cmd/messenger/words.go +++ b/modules/messenger/cmd/messenger/words.go @@ -63,6 +63,10 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold if urgent { title = "Urgent: " + title } + case "tell": + // A plain notice (ADR 0380 §1, §8): the headline and its one or two lines, nothing else — no + // "since", no severity, no reminder: it waits for no answer and is never said again. + return Message{Title: headline, Body: explanation} default: title = headline if urgent { diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 3a12a074..3a50f885 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -11,6 +11,7 @@ "open", "history", "notify", + "tell", "asks" ], "accepts": [ @@ -62,6 +63,7 @@ "open", "history", "notify", + "tell", "asks" ] }