Files
mesh-catalog/modules/messenger/cmd/messenger/restart.go
T
jochen a92c08fd5b
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)
mesh/delivery delivered
messenger: keep what a walk held until it is said, and open no walk whose end came first (review of #230)
- No channel ready at a walk's end or bound: what it held is left unsent and tried
  again every minute, as any other message, instead of lost with the walk.
- At a walk's end or bound, a message another open walk touches is held on by
  that walk, even one started after it was held (hq ADR 0308 §2).
- A walk read back past its forget is released at its bound first, then forgotten.
- A walk's end is kept a day: a start heard after it (same started_at, or not
  later than the end) opens nothing, also after the router restarts; a later
  start under the same id still opens.
- A walk's plain end, when its words cannot be said, leaves what it held to be
  said on its own instead of marking it told.
- The restart pass no longer skips a held message: one with an open ask is never
  held (ADR 0308 §3), so its answers are made to work again.
2026-10-12 12:07:38 +02:00

202 lines
8.0 KiB
Go

package main
// Answers lost on a channel (novox/hq issue 384). On 2026-10-10 the desk's notifier started again and the
// desk showed, from its own memory, nine old popups: resolved, silenced and cancelled ones among them. A
// channel's memory is which notification a handle has, good for replacing or editing in place and for
// nothing else; what is still open and unanswered is known here alone. So a channel whose messages' answers
// were lost — the desk's notifier bundle started again: its notifications stay on screen, their buttons wait
// for nobody — says when in its standing (AnswersLostAt), and this router says again, on that kind only:
//
// - **what is open and asked there**, and was last said before the answers were lost: one message per
// problem, said again under its handle with the words and the answers of now, so the channel replaces its
// message in place and its buttons work again. Sent to that kind directly — never through the outbox, whose
// failure path moves a warning to the channel that reaches the operator away;
// - **what is silenced**: its message edited to the silenced words, with no answers and no urgency;
// - **nothing that ended**: a resolved or cancelled message's end was said where its message is; it never
// comes back.
//
// Whether a record is said again is its own: last said before the answers were lost. So a router that started
// after the loss still says it, and a standing that repeats the time says nothing more (the record was said
// since). A kind that cannot send waits: what is open there is said once it can.
//
// **A restart says nothing it has said** (novox/hq ADR 0308 §6, issue 531). On 2026-10-12 every delivery of the
// tool runner made this pass twice, a second apart, each time editing the four silenced messages on the desk,
// which showed them again — one as "A mesh message was withheld". Three causes, each closed here:
//
// - **Twice:** the desk's standing says the latest start of any machine's notifier, and the notifiers of two
// machines started a second apart, so two standings with two times came, and each was acted on at once.
// Now a loss is acted on once it stands for LostSettle: the standings of one restart are one pass.
// - **Again after the router's own restart:** which loss was acted on was kept in memory, so a router that
// started again acted on the standing it had already acted on. Now it is kept in the module's state
// (`sent`, key `passes`), read back on start.
// - **What it said:** a silenced message was edited to its silenced words, and a message withheld for its
// content was said again as withheld. Now the pass says again only what is open, asked there and never
// answered, whose words may leave; a silenced, a withheld or an answered message is left as it is. Being
// held by a walk is no reason: a message with an open ask is never held (ADR 0308 §3), even one a walk took
// before its ask came, so its answers are made to work again like any other's.
import (
"fmt"
"sort"
"time"
)
// LostSettle: a loss of answers is acted on once no later loss came for this long — the standings of one
// restart, a notifier on each machine, are one pass.
const LostSettle = 10 * time.Second
// Pass is the restart pass made in full on a kind: the loss it answered and when it was made.
type Pass struct {
Lost time.Time `json:"lost"`
At time.Time `json:"at"`
}
// Stand takes what a kind said of itself, and notes answers lost there since this router last acted.
func (h *Holder) Stand(kind string, s Standing) {
if h.Bench != nil {
h.Bench.Stand(kind, s)
}
if s.AnswersLostAt.IsZero() {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.init()
if !s.AnswersLostAt.After(h.passes[kind].Lost) || !s.AnswersLostAt.After(h.lost[kind]) {
return // acted on, or noted already
}
h.lost[kind] = s.AnswersLostAt
}
// duePasses makes the pass of each kind whose loss of answers stood for LostSettle, once per loss.
func (h *Holder) duePasses(now time.Time) {
h.mu.Lock()
due := map[string]time.Time{}
for kind, lost := range h.lost {
if now.Sub(lost) >= LostSettle {
due[kind] = lost
}
}
h.mu.Unlock()
for kind, lost := range due {
ch := h.channelOf(kind)
if ch == nil {
continue // the controller's word on the kinds is not read yet: acted on later
}
if err := ch.Ready(); err != nil {
h.mu.Lock()
said := h.saidOnce["lost."+kind]
h.mu.Unlock()
if said.IsZero() || now.Sub(said) >= 10*time.Minute {
h.Logf("[messenger] %s lost its answers at %s and cannot send (%v): what is open there is said again once it can",
kind, lost.UTC().Format(time.RFC3339), err)
h.mu.Lock()
h.saidOnce["lost."+kind] = now
h.mu.Unlock()
}
continue
}
h.mu.Lock()
tried := h.saidOnce["lost-try."+kind]
h.mu.Unlock()
if !tried.IsZero() && now.Sub(tried) < RetryEvery {
continue
}
if !h.reoffer(ch, lost) {
h.mu.Lock()
h.saidOnce["lost-try."+kind] = now // what failed is tried again in a minute
h.mu.Unlock()
continue
}
h.mu.Lock()
delete(h.saidOnce, "lost-try."+kind)
h.mu.Unlock()
h.mu.Lock()
h.passes[kind] = Pass{Lost: lost, At: now}
if h.lost[kind].Equal(lost) {
delete(h.lost, kind)
}
passes := map[string]Pass{}
for k, p := range h.passes {
passes[k] = p
}
h.mu.Unlock()
if k, ok := h.Store.(walkKeeper); ok {
if err := k.PutPasses(passes); err != nil {
h.noteStore(err)
}
}
}
}
// channelOf is the channel sending to a kind now, or nil.
func (h *Holder) channelOf(kind string) Channel {
for _, ch := range h.channels() {
if ch.Name() == kind {
return ch
}
}
return nil
}
// reoffer says again on one channel, whose answers were lost at `lost`, what is still open, asked there and
// never answered, and leaves everything else as it is: what ended, what is silenced, what was withheld for its
// content, and what was answered (ADR 0308 §6). It answers whether every message to be said again was.
func (h *Holder) reoffer(ch Channel, lost time.Time) bool {
kind := ch.Name()
rp, canReplace := ch.(replacer)
now := h.Now()
h.mu.Lock()
var recs []*Record
for _, r := range h.open {
recs = append(recs, r)
}
h.mu.Unlock()
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
again, left, ended, failed := 0, 0, 0, 0
for _, r := range recs {
h.mu.Lock()
id, shown := r.Sent[kind]
open := r.Cleared.IsZero()
before := lastSaid(r).Before(lost)
reminded := r.Reminded
withheld, answered := r.Refused != "", r.Answered != ""
h.mu.Unlock()
if !shown || id == "" {
continue // never a message of its own there: nothing to say again under
}
switch {
case !open:
ended++ // its end is said where its message is; nothing comes back
case r.silenced(now), withheld, answered:
left++ // a silence, a withheld message or an answer is never said again for a restart
case h.askKinds(r)[kind] && before && canReplace:
what := "raised"
if reminded {
what = "reminder"
}
err := rp.Replace(id, h.sayOn(r, what, kind))
h.noteChannel(kind, err)
if err != nil {
h.Logf("[messenger] could not say %s again on %s (%v): tried again at its next standing", r.Key, kind, err)
failed++
continue
}
h.mu.Lock()
r.LastSaid = now
h.lastSend[kind] = now
h.mu.Unlock()
h.record(Sent{At: now, Channel: kind, Key: r.Key, What: what + ", again: its answers were lost", Outcome: "sent"})
h.persist(r)
again++
}
}
h.record(Sent{At: now, Channel: kind, Key: "(answers lost)", What: "at " + lost.UTC().Format(time.RFC3339),
Outcome: fmt.Sprintf("%d open and asked said again, %d silenced, withheld or answered left as they are, "+
"%d ended left ended, %d failed", again, left, ended, failed)})
h.Logf("[messenger] %s lost its answers at %s: %d open and asked said again in place, %d silenced, withheld or "+
"answered left as they are, %d ended left ended, %d failed; nothing shown from the channel's memory", kind,
lost.UTC().Format(time.RFC3339), again, left, ended, failed)
return failed == 0
}