Files
mesh-catalog/modules/messenger/cmd/messenger/answers.go
T
jochen bb96f4d163
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
messenger: every message still shown keeps its own Details, bounded in number and age (issue 371)
A newer message about the same problem (an escalation, a reminder, a re-send) replaced the Details
ticket of the one before it on that channel kind, so tapping Details on any but the newest was refused
"it belongs to no open message". Each message now keeps a ticket of its own, used once, at most
MostDetails per record and kind and none older than TokenFor; one pushed out is refused in words that
send the operator to the newest message. A change of the condition's actions no longer drops Details,
which names no action.
2026-10-10 13:44:43 +02:00

300 lines
10 KiB
Go

package main
// What the operator needs to do, and answering from a message (novox/hq ADR 0253, ADR 0258, ADR 0259).
//
// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or
// "Needs you:" and one thing the operator can do themselves.
// - **A warning that needs nothing is not sent.** It is kept open here and in the controller's
// conditions, the history says it was kept quiet, and it is said when it needs the operator — urgent
// at its bound, its words changed to "Needs you:", or an ask about it arrived — and once after a day in
// any case (QuietRemindAfter), so nothing stays silent for ever.
// - **The router performs nothing** (ADR 0259). A message offers the answers of the ask about it, which
// its asker performs on a warrant (asks.go), and Details, which performs nothing and is shown where it
// was chosen. The condition's own actions are never called from here: the controller asks for them.
// - **A ticket is an answer's one-time ticket**: made for one message on one channel kind, good for
// TokenFor, used once; refused for a record cleared or silenced, used, expired, from another kind or
// unknown — and the refusal is shown where the answer came from. One answer is acted on once,
// whatever its redelivery.
// - **Every message still shown keeps its Details** (issue 371): a newer message about the same problem
// makes a ticket of its own and leaves the older ones standing, so Details works on each message once.
// A record keeps at most MostDetails of them on a kind, and none older than TokenFor; a ticket dropped
// by that bound is refused in words that say to tap Details on the newest message.
import (
"encoding/json"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
// Kept as the controller says it; the router offers a condition's answers only through an ask about it.
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
Machine string `json:"machine,omitempty"`
Arguments map[string]string `json:"arguments,omitempty"`
}
// Grant is one Details ticket: the kind it was shown on, when it was made, and whether it was used.
type Grant struct {
Index int `json:"index"`
Kind string `json:"kind,omitempty"`
Issued time.Time `json:"issued"`
Used bool `json:"used,omitempty"`
}
const (
// DetailsLabel is the answer every condition's message offers.
DetailsLabel = "Details"
// MostActions bounds what a message offers, Details included.
MostActions = 4
// TokenFor is how long a Details ticket is good for.
TokenFor = 24 * time.Hour
// MostDetails bounds the Details tickets one record keeps on one channel kind: one per message about it
// still shown there; beyond it the oldest is forgotten (issue 371).
MostDetails = 8
// KeptEvents is how many answers are remembered, so a redelivered one is acted on once.
KeptEvents = 200
)
// NoOpenMessage is the refusal of a ticket no open message holds: its problem ended, or so many newer
// messages about it were sent that this older one's Details was forgotten.
const NoOpenMessage = "it belongs to no open message; the problem may have ended, or this message is older " +
"than the last few about it: tap Details on the newest one"
func sameActions(a, b []Action) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for
// you to do.", only a warning, and nothing asked about it. A record without words is said.
func needsNothing(r *Record) bool {
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" && r.Ask == ""
}
// choices are the answers a record's next message offers on one kind: the open ask's options offered
// there, and Details for a condition, with a ticket of its own. The tickets of the record's earlier messages
// stand (issue 371): each message keeps a Details of its own, used once, until it is TokenFor old or
// MostDetails newer ones on that kind push it out.
func (h *Holder) choices(r *Record, kind string) []Choice {
var out []Choice
if rec := h.openAsk(r); rec != nil {
out = h.offers(rec, kind)
}
if r.Origin != "condition" || len(out) >= MostActions {
return out
}
now := h.Now()
h.mu.Lock()
defer h.mu.Unlock()
if r.Tokens == nil {
r.Tokens = map[string]Grant{}
}
pruneGrants(r.Tokens, kind, now, MostDetails-1)
t := ticket()
r.Tokens[t] = Grant{Index: -1, Kind: kind, Issued: now}
return append(out, Choice{Token: t, Label: DetailsLabel})
}
// pruneGrants forgets every ticket older than TokenFor, and keeps at most keep of kind's, the newest.
func pruneGrants(grants map[string]Grant, kind string, now time.Time, keep int) {
var mine []string
for t, g := range grants {
switch {
case now.Sub(g.Issued) > TokenFor:
delete(grants, t)
case g.Kind == kind:
mine = append(mine, t)
}
}
if len(mine) <= keep {
return
}
sort.Slice(mine, func(i, j int) bool {
a, b := grants[mine[i]], grants[mine[j]]
if !a.Issued.Equal(b.Issued) {
return a.Issued.Before(b.Issued)
}
return mine[i] < mine[j]
})
for _, t := range mine[:len(mine)-keep] {
delete(grants, t)
}
}
// Chosen takes an answer a channel of a kind said it was given: an ask's option is judged and, when it
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
// Anything else is refused, and said there. The same answer is acted on once.
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
// The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
// and judged with in it.
root := h.rootWordFor(kind)
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
if c.ID != "" {
if outcome, done := h.answered[c.ID]; done {
h.mu.Unlock()
return outcome, nil
}
}
key := ""
if h.tickets != nil {
key = h.tickets[c.Ticket]
}
now := h.Now()
var r *Record
var g Grant
for _, rec := range h.open {
if gr, ok := rec.Tokens[c.Ticket]; ok {
r, g = rec, gr
}
}
h.mu.Unlock()
if !c.At.IsZero() && now.Sub(c.At) > FreshFor {
// Replayed from the stream long after it was given: history, never an answer.
h.Logf("[messenger] an answer on %s from %s is %s old and was not acted on", kind, c.At.Format(time.RFC3339), roughly(now.Sub(c.At)))
h.remember(c.ID, "too old")
return "too old", errorf("it was given %s ago", roughly(now.Sub(c.At)))
}
if key != "" {
out, err := h.judge(kind, c, key, root)
h.remember(c.ID, out)
return out, err
}
refuse := func(why string) (string, error) {
out, err := h.refuseChoice(kind, c, why)
h.remember(c.ID, out)
return out, err
}
switch {
case r == nil:
return refuse(NoOpenMessage)
case !r.Cleared.IsZero():
return refuse("the problem it answered has ended")
case r.silenced(now):
return refuse("the problem it answered is silenced")
case g.Used:
return refuse("it was already chosen once")
case now.Sub(g.Issued) > TokenFor:
return refuse("it is more than a day old")
case g.Kind != "" && g.Kind != kind:
return refuse("it was not offered here")
}
h.mu.Lock()
g.Used = true
r.Tokens[c.Ticket] = g
h.mu.Unlock()
headline, _ := wordsOf(r)
// Each line held to the content rule on its own: a line that carries what may not leave is said
// withheld, and the rest is still shown.
var lines []string
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
if refusal, ok := Check(l); !ok {
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
}
lines = append(lines, l)
}
err := h.showOn(kind, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n"), Reply: c.ID})
h.record(Sent{At: now, Channel: kind, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
h.remember(c.ID, "shown")
h.persist(r)
return "shown", err
}
func (h *Holder) remember(event, outcome string) {
if event == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if h.answered == nil {
h.answered = map[string]string{}
}
if len(h.answered) >= KeptEvents {
for k := range h.answered {
delete(h.answered, k)
break
}
}
h.answered[event] = outcome
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func outcomeOf(err error) string {
if err != nil {
return "failed: " + err.Error()
}
return "sent"
}
func firstLineOf(s string) string {
s, _, _ = strings.Cut(s, "\n")
if len(s) > 160 {
s = s[:160] + "…"
}
return s
}
// Failed takes a channel's word that a message did not arrive: the channel is said to be failing, and the
// record it carried is tried again — unless the failure is permanent, which is said and not repeated.
func (h *Holder) Failed(kind string, f asks.Failed) {
h.work.Lock()
defer h.work.Unlock()
h.noteChannel(kind, errorf("%s", f.Why))
h.mu.Lock()
var r *Record
for _, rec := range h.open {
if rec.Sent[kind] == f.Handle && f.Handle != "" {
r = rec
}
}
if r != nil && r.Cleared.IsZero() && !f.Permanent {
delete(r.Sent, kind)
if r.Pending == "" {
r.Pending = "raised"
}
}
desk := h.Bench != nil && h.Bench.roleOf(kind) == RoleDesk
h.mu.Unlock()
// A warning the desk could not show goes to the channel that reaches the operator away, as it did when the
// desk was the router's own (the correctness review of 2026-10-08).
if desk && r != nil && r.Cleared.IsZero() && r.Severity != Urgent && h.Away != nil && h.Away.Ready() == nil {
h.enqueue(h.Away.Name(), item{Key: r.Key, What: "raised", Severity: r.Severity, At: h.Now()})
h.mu.Lock()
r.Pending = ""
h.mu.Unlock()
h.flush(h.Now(), false)
}
outcome := "failed: " + f.Why
if f.Permanent {
outcome = "failed for good: " + f.Why
}
h.record(Sent{At: h.Now(), Channel: kind, Key: keyOrNone(r), What: "delivery", Outcome: outcome})
if r != nil {
h.persist(r)
}
}
func keyOrNone(r *Record) string {
if r == nil {
return "(none)"
}
return r.Key
}