issue 797: messenger gains the tell verb for a plain notice

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'.
This commit is contained in:
jochen
2026-10-15 17:37:15 +02:00
parent 789b0a9474
commit 82475691b2
9 changed files with 183 additions and 2 deletions
+3 -1
View File
@@ -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()
@@ -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 `<asker>.<id>`, and tickets each ask's tickets to it.
@@ -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 {
+21
View File
@@ -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 " +
@@ -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"}},
+23
View File
@@ -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
}
+75
View File
@@ -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
}
+4
View File
@@ -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 {
+2
View File
@@ -11,6 +11,7 @@
"open",
"history",
"notify",
"tell",
"asks"
],
"accepts": [
@@ -62,6 +63,7 @@
"open",
"history",
"notify",
"tell",
"asks"
]
}