Merge pull request 'A channel shows nothing from its memory after a restart; the router says again what is still open (issue 384)' (#182) from fix/issue-384-restart-replays-nothing into main

This commit was merged in pull request #182.
This commit is contained in:
2026-10-10 14:17:04 +00:00
10 changed files with 528 additions and 48 deletions
+8 -1
View File
@@ -9,7 +9,14 @@ each machine the operator sits at (the `node-notifier` seat), and says which but
acknowledgements (Silence for a week, Details) and says in the words where an approval can be given.
- **An answer is taken only on a machine its message was shown on**, and once; a refused click is said where it
was made. A message with buttons stays until it is dismissed.
- **A notifier that started again** (`dunst.started`) is shown again every open message with buttons it showed.
- **What it remembers is never shown again** (issue 384: on 2026-10-10 a notifier that started again was
shown nine old popups from the desk's memory, resolved and silenced ones among them). Its memory — which
notification each handle has on each machine — serves only to replace or edit a message in place. When the
notifier's bundle starts again on a machine (`dunst.started`), its notifications stay on screen and the
answers they waited for are lost: the desk keeps their ids and says in its standing when that happened
(`answers-lost-at`); the router, which alone knows what is still open and unanswered, says that again under
its handle, replacing the notification in place so its buttons work again. Nothing resolved, silenced or
cancelled comes back.
- A message no machine took is said to the router as failed; the router tries it again.
- **One notification per message** (issue 373): an `edit`, and a `send` or `show` under a handle already shown (a
reminder, an escalation), replace that message's notification on each machine by its id (the notifier's
+85 -37
View File
@@ -9,8 +9,12 @@ package main
// acknowledgements, and says where an approval can be given.
// - **An answer is bound to the machines it was shown on**: a click on another machine is refused here, and
// said there. A message with buttons stays until it is dismissed.
// - **A notifier that started again lost the answers it waited for**: every open message with buttons shown
// on that machine is shown there again.
// - **What it remembers is never shown again** (issue 384). Its memory — which notification a handle has on
// each machine — serves only to replace or edit a message in place. When the notifier's bundle started again
// on a machine (`dunst.started`), the notifications stay on screen but the answers they waited for are lost:
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
// status. A message no machine took is said to the router as failed, and tried again by it.
@@ -19,7 +23,6 @@ import (
"errors"
"fmt"
"html"
"sort"
"strconv"
"strings"
"sync"
@@ -47,6 +50,15 @@ type pauseSeen struct {
// StandingEvery is how often the channel says whether it can send, at least.
const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
type Standing struct {
asks.Standing
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
// machine.
type Shown struct {
@@ -75,10 +87,12 @@ type Desk struct {
mu sync.Mutex
last map[string]string // machine -> its last error, "" after a success
clickedOn map[string]string // a choice's id -> the machine it was made on, for the router's reply
said asks.Standing
said Standing
saidAt time.Time
acted map[string]bool
pause map[string]pauseSeen
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
lost map[string]time.Time
}
func (d *Desk) init() {
@@ -90,6 +104,9 @@ func (d *Desk) init() {
if d.pause == nil {
d.pause = map[string]pauseSeen{}
}
if d.lost == nil {
d.lost = map[string]time.Time{}
}
if d.Now == nil {
d.Now = time.Now
}
@@ -251,13 +268,7 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
// machine is the event's own node, said by the runtime; the same event is acted on once.
func (d *Desk) Chosen(event, machine, ticket string) {
d.init()
d.mu.Lock()
done := d.acted[event]
if event != "" {
d.acted[event] = true
}
d.mu.Unlock()
if done {
if d.again(event) {
return
}
handle, shown := d.find(ticket)
@@ -309,34 +320,48 @@ func (d *Desk) find(ticket string) (string, *Shown) {
return "", nil
}
// Started shows again, on a machine whose notifier started again, every message with buttons shown there.
func (d *Desk) Started(machine string) int {
// again says whether an event was acted on before (a redelivery), and marks it acted on.
func (d *Desk) again(event string) bool {
if event == "" {
return false
}
d.mu.Lock()
defer d.mu.Unlock()
done := d.acted[event]
if len(d.acted) > 2000 {
d.acted = map[string]bool{}
}
d.acted[event] = true
return done
}
// Started takes a notifier's word that its bundle started again on a machine (dunst's event `started`): the
// notifications stay on screen, and the answers they waited for are lost. Nothing is shown from memory (issue
// 384: nine old popups, resolved and silenced ones among them, shown again from it). The notification ids
// are kept, so what the router says again replaces each notification in place, and the standing says when
// the answers were lost; the router alone knows what is still open and unanswered. It answers how many
// messages with buttons that machine shows.
func (d *Desk) Started(event, machine string) int {
d.init()
handles, err := d.Store.Handles()
if err != nil {
if d.again(event) {
return 0
}
sort.Strings(handles)
d.mu.Lock()
d.lost[machine] = d.Now()
d.mu.Unlock()
n := 0
for _, h := range handles {
s, err := d.Store.Get(h)
if err != nil || s == nil || len(s.Message.Buttons) == 0 {
continue
if handles, err := d.Store.Handles(); err == nil {
for _, h := range handles {
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if _, here := s.On[machine]; here {
n++
}
}
}
if _, here := s.On[machine]; !here {
continue
}
id, err := d.show(machine, s.Message, 0)
if err != nil {
continue
}
s.On[machine] = id
_ = d.Store.Put(h, *s)
n++
}
if n > 0 {
d.Logf("[desk-channel] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
}
d.Logf("[desk-channel] the notifier on %s started again: the answers of %d message(s) shown there are lost; none shown "+
"from memory, the router says again what is still open", machine, n)
d.SayStanding()
return n
}
@@ -378,9 +403,9 @@ func (d *Desk) seeing() ([]string, string) {
// Standing is whether the desk can send: a named machine whose notifier said lately that it is not paused
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() asks.Standing {
func (d *Desk) Standing() Standing {
d.init()
s := asks.Standing{At: d.Now()}
s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -389,11 +414,27 @@ func (d *Desk) Standing() asks.Standing {
return s
}
// answersLostAt is the latest start of a named machine's notifier: zero while none started again since this
// desk started.
func (d *Desk) answersLostAt() time.Time {
machines := d.Machines() // read outside the lock: it reads the settings file
var at time.Time
d.mu.Lock()
defer d.mu.Unlock()
for _, m := range machines {
if t := d.lost[m]; t.After(at) {
at = t
}
}
return at
}
// SayStanding says the standing when it changed, or every StandingEvery.
func (d *Desk) SayStanding() {
s := d.Standing()
d.mu.Lock()
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.At.Sub(d.saidAt) < StandingEvery
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.AnswersLostAt.Equal(d.said.AnswersLostAt) &&
s.At.Sub(d.saidAt) < StandingEvery
d.mu.Unlock()
if same {
return
@@ -424,12 +465,19 @@ func (d *Desk) Status() map[string]any {
"it shows the router's messages on the notifier of each machine named in desktop-machines, and says which button was chosen there; the router judges every answer (novox/hq ADR 0259)",
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
"a notifier that started again is shown again every open message with buttons it showed",
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
}}
if s.Why != "" {
out["why_not"] = s.Why
}
if !s.AnswersLostAt.IsZero() {
started := map[string]string{}
for m, at := range d.lost {
started[m] = at.Format(time.RFC3339)
}
out["notifier_started"] = started
}
return out
}
@@ -103,20 +103,74 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
}
}
func TestAnEditReplacesTheNotificationAndANotifierThatStartedAgainIsShownItsQuestions(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
take(t, d, asks.Show, asks.Message{Handle: "m2", Title: "q2", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t2"}}})
}
// Issue 384: a notifier that started again (nine old popups, resolved and silenced ones among them, shown from
// the desk's own memory on 2026-10-10). Its bundle started again, so the notifications are still on screen and
// the answers they waited for are lost. With three messages remembered on the machine — one open with answers,
// one silenced, one resolved — nothing is shown, the notification ids are kept, and the standing says when the
// answers were lost. The router then says the open one again under its handle: it replaces its notification in
// place (one popup, its buttons alive again), and a click on it is said to the router. The same start event
// is acted on once.
func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, asks.Show, asks.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []asks.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
openID := store["open"].On["laptop"]
before := len(*shown)
if n := d.Started("laptop"); n != 1 || len(*shown) != before+1 {
t.Errorf("shown again: %d", n)
standings := len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
if n := d.Started("e-start", "laptop"); n != 1 {
t.Fatalf("messages with buttons on the machine: %d, want 1 (the open one)", n)
}
if store["m2"].On["laptop"] != len(*shown) {
t.Errorf("the new notification is not kept: %+v", store["m2"])
if len(*shown) != before {
t.Fatalf("shown from memory after the notifier started again: %+v", (*shown)[before:])
}
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[asks.IntakeSubject(asks.StandingWhat, Kind)]
var s Standing
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
t.Fatalf("the open message said again: %+v", last.args)
}
d.Chosen("e2", "laptop", "t-open")
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
t.Error("a click on the message said again was not said to the router")
}
// The same start event again (a redelivery) does nothing more.
standings = len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) != standings {
t.Errorf("a redelivered start was acted on again: %d", n)
}
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
fresh, _, _, _ := newDesk(t, "laptop")
if !fresh.Standing().AnswersLostAt.IsZero() {
t.Error("a desk whose notifier never started again says a time")
}
}
@@ -118,7 +118,7 @@ func run(d *Desk) {
}
switch e.Key {
case StartedEvent:
d.Started(machine)
d.Started(e.Headers["x-event-id"], machine)
case PausedEvent:
// Only from the event's own node: a word about another machine is no word at all.
if body.Paused != nil && e.Node != "" {
+7
View File
@@ -18,6 +18,13 @@ channel's client and no channel's secret.
is the channel's own, said on `intake.standing`.
- **Keeps its state on the bus** (ADR 0201): `open` and `sent` (the messages), `asks` (every ask, closed ones
for 30 days), `identities` (the operator's linked accounts, one per kind, and a link asked for).
- **Lost answers are said again from here, never from a channel's memory** (issue 384,
`cmd/messenger/restart.go`): a channel whose messages' answers were lost — the desk's notifier started
again — says when in its standing (`answers-lost-at`). The router says again on that kind, directly and not
through the outbox, each message that is still open, asked there and last said before that time: one per
problem, under its handle, with the words and answers of now, so it replaces the message in place. What is
silenced is edited to say so, with no answers and no urgency; what ended never comes back. A kind that cannot
send waits; it never goes to another channel. Telegram's answers are never lost and it says no time.
## Asks, answers and warrants (novox/hq ADR 0259)
@@ -327,6 +327,8 @@ type Holder struct {
lastUnlink map[string]Unlinked
// rootWord is the controller's last word on whether an agent can become root, by the machines asked.
rootWord map[string]rootWord
// shows is, by kind, the AnswersLostAt this router last acted on in full (restart.go).
shows map[string]time.Time
}
func (h *Holder) init() {
@@ -342,6 +344,7 @@ func (h *Holder) init() {
h.chanOK = map[string]time.Time{}
h.saidOnce = map[string]time.Time{}
h.heard = map[string]int{}
h.shows = map[string]time.Time{}
}
if h.Now == nil {
h.Now = time.Now
+3 -2
View File
@@ -397,9 +397,10 @@ func listenForChannels(h *Holder) {
h.Failed(kind, f)
}
case asks.StandingWhat:
var s asks.Standing
// Read as this module's Standing: the bench's word, and answers-lost-at beside it (issue 384).
var s Standing
if json.Unmarshal(e.Body, &s) == nil {
h.Bench.Stand(kind, Standing{Ready: s.Ready, Why: s.Why, EditsSilently: s.EditsSilently, At: s.At})
h.Stand(kind, s)
}
}
return nil
+152
View File
@@ -0,0 +1,152 @@
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.
import (
"fmt"
"sort"
"time"
)
// Stand takes what a kind said of itself, and acts on 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.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
done := h.shows[kind].Equal(s.AnswersLostAt)
h.mu.Unlock()
if done {
return
}
ch := h.channelOf(kind)
if ch == nil {
return // the controller's word on the kinds is not read yet: acted on at a later standing
}
if err := ch.Ready(); err != nil {
h.Logf("[messenger] %s lost its answers at %s and cannot send (%v): what is open there is said again once it can",
kind, s.AnswersLostAt.UTC().Format(time.RFC3339), err)
return
}
if h.reoffer(ch, s.AnswersLostAt) {
h.mu.Lock()
h.shows[kind] = s.AnswersLostAt
h.mu.Unlock()
}
}
// 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 and asked there,
// edits what is silenced to say so, and leaves what ended alone. 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, silenced, 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
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):
h.mu.Lock()
r.SilenceSaid = true
h.mu.Unlock()
h.editOn(ch, r, "silenced")
h.persist(r)
silenced++
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 said so, %d ended left ended, %d failed",
again, silenced, ended, failed)})
h.Logf("[messenger] %s lost its answers at %s: %d open and asked said again in place, %d silenced said so, %d ended "+
"left ended, %d failed; nothing shown from the channel's memory", kind, lost.UTC().Format(time.RFC3339), again,
silenced, ended, failed)
return failed == 0
}
// editOn edits a record's message on one channel, where it is shown as a message of its own.
func (h *Holder) editOn(ch Channel, r *Record, what string) {
kind := ch.Name()
h.mu.Lock()
id := r.Sent[kind]
h.mu.Unlock()
if id == "" || !ch.CanEdit() {
return
}
err := ch.Edit(id, h.sayOn(r, what, kind))
h.noteChannel(kind, err)
if err != nil {
h.Logf("[messenger] could not edit the message for %s on %s (%v)", r.Key, kind, err)
return
}
h.record(Sent{At: h.Now(), Channel: kind, Key: r.Key, What: what, Outcome: "edited"})
}
@@ -0,0 +1,203 @@
package main
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Issue 384: the desk's notifier started again and the desk showed, from its own memory, nine old popups —
// resolved, silenced and cancelled ones among them. Now the desk says in its standing when the answers were
// lost, and this router says again only what is still open and unanswered: with the desk showing one open
// message with an answer, one silenced, one open asking nothing and one resolved, one is said again under its
// handle (in place of its message), with its answers; one is edited to the silenced words, with no answers
// and no urgency; nothing is said for the others. A standing that repeats the time says nothing more; its
// ticket is one-time still, and the channel that reaches the operator away hears nothing of it.
func TestLostAnswersGetOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
h, tg, dt, c, _, w := newRouter(t)
h.Start()
c.pass(2 * time.Minute)
// Open, with a question the desk carries.
open := cond("machine.shanks.units", Warning, "2 failed services on shanks")
open.Raised = c.now()
h.Condition(EventRaised, open)
settle(h, c)
if len(dt.sends) != 1 {
t.Fatalf("desk %d", len(dt.sends))
}
ask := ackAsk(c, "silence-units")
ask.About = open.Key
submit(t, h, ConditionOwner, ask)
if e := dt.edits["g14=1"]; ticketFor(e, "Silence for a week") == "" {
t.Fatalf("the question was not put on the open message: %+v", e)
}
// Silenced, with a question of its own, and its message says it is silenced.
runFor(h, c, 20*time.Minute)
sil := cond("machine.ace.agent-home", Warning, "Agent-home shrank")
sil.Raised = c.now()
h.Condition(EventRaised, sil)
settle(h, c)
if len(dt.sends) != 2 {
t.Fatalf("desk %d", len(dt.sends))
}
stand(h, c) // the channels' word, fresh again after the minutes passed
silAsk := ackAsk(c, "silence-home")
silAsk.About = sil.Key
submit(t, h, ConditionOwner, silAsk)
if e := dt.edits["g14=2"]; ticketFor(e, "Silence for a week") == "" {
t.Fatalf("the question was not put on the message to be silenced: %+v", e)
}
sil.SilencedTill, sil.SilencedWhy, sil.At = c.now().Add(7*24*time.Hour), "known", c.now()
h.Condition(EventChanged, sil)
if e := dt.edits["g14=2"]; !strings.HasPrefix(e.Title, "Silenced until") {
t.Fatalf("the silence was not said: %+v", e)
}
// Open, told, and asking nothing: not unanswered, so not said again.
runFor(h, c, 20*time.Minute)
info := cond("seat.mesh-controller.assign-by-hand", Warning, "Assign keeps being fixed by hand")
info.Raised = c.now()
h.Condition(EventRaised, info)
settle(h, c)
if len(dt.sends) != 3 {
t.Fatalf("desk %d", len(dt.sends))
}
// Resolved, and its clearing said.
runFor(h, c, 20*time.Minute)
res := cond("seat.operator-channel.asks-undelivered", Warning, "Questions for you not delivered")
res.Raised = c.now()
h.Condition(EventRaised, res)
settle(h, c)
if len(dt.sends) != 4 {
t.Fatalf("desk %d", len(dt.sends))
}
h.Condition(EventCleared, Condition{Key: res.Key, At: c.now()})
runFor(h, c, 3*time.Minute)
if e := dt.edits["g14=4"]; !strings.HasPrefix(e.Title, "Resolved: ") {
t.Fatalf("the clearing was not said: %+v", e)
}
silencedBefore := dt.edits["g14=2"]
delete(dt.edits, "g14=2") // so the edit said again is seen as such
sends, edits, replaced, tgSaid := len(dt.sends), len(dt.edits), len(dt.replaced), saidOn(tg)
// The desk's notifier started again: its standing says when the answers were lost.
c.pass(time.Minute)
newSince := c.now()
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: newSince})
runFor(h, c, 2*time.Minute)
if len(dt.sends) != sends {
t.Fatalf("a new message was sent instead of one under its handle: %+v", dt.sends[sends:])
}
if len(dt.replaced) != replaced+1 || dt.replaced[replaced].id != "g14=1" {
t.Fatalf("the open question was not said again under its handle, once and alone: %+v", dt.replaced[replaced:])
}
again := dt.replaced[replaced].m
if ticketFor(again, "Silence for a week") == "" || !strings.Contains(again.Title, headlineOf(open.Key)) {
t.Fatalf("said again without its answer or its words: %+v", again)
}
if e, said := dt.edits["g14=2"]; !said || !strings.HasPrefix(e.Title, "Silenced until") || len(e.Actions) != 0 || e.Urgent ||
!e.Quiet || e.Title != silencedBefore.Title {
t.Fatalf("the silenced one was not edited to the silenced words again: %+v (said %v)", e, said)
}
if len(dt.edits) != edits+1 {
t.Fatalf("an edit reached a message that is not silenced: %+v", dt.edits)
}
if e := dt.edits["g14=4"]; !strings.HasPrefix(e.Title, "Resolved: ") {
t.Fatalf("the resolved one was changed: %+v", e)
}
if saidOn(tg) != tgSaid {
t.Fatalf("the channel that reaches the operator away heard of the desk's screen: %+v %+v", tg.sends, tg.edits)
}
// The standing said again with the same time, and one with an older time: nothing more.
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: newSince})
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: newSince.Add(-time.Hour)})
runFor(h, c, 2*time.Minute)
if len(dt.replaced) != replaced+1 || len(dt.sends) != sends {
t.Fatalf("said again more than once: replaced %d, sends %d", len(dt.replaced)-replaced, len(dt.sends)-sends)
}
// Its ticket is one-time still: one warrant, however often it is tapped, and refused from another machine.
tk := ticketFor(again, "Silence for a week")
ch := choiceOf(tk, "g14", c.now())
ch.Sender.Authenticated = false
if got, err := h.Chosen("desktop", ch); got != "chosen" || err != nil {
t.Fatalf("the answer on the message said again: %s, %v", got, err)
}
ch.ID = "c-second"
_, _ = h.Chosen("desktop", ch) // the same tap again: said as recorded (issue 372), nothing more done
other := choiceOf(tk, "shanks", c.now())
other.Sender.Authenticated = false
if got, _ := h.Chosen("desktop", other); got != "refused" {
t.Fatalf("the ticket taken from another machine after it was used: %s", got)
}
chosen := 0
for _, wt := range w.of(ConditionOwner) {
if wt.Outcome == asks.OutcomeChosen {
chosen++
}
}
if chosen != 1 {
t.Fatalf("warrants for one ticket: %d", chosen)
}
}
// Whether a message is said again is its own: last said before the answers were lost. A loss before it was
// last said says nothing; a router that started again (it remembers no standing it acted on) says it again
// once, and then nothing more. A loss while the desk cannot send is said once it can — never pushed to the
// channel that reaches the operator away. Telegram, whose answers are never lost, says no time and gets nothing.
func TestLostAnswersAreJudgedPerMessageAndWaitForTheDesk(t *testing.T) {
h, tg, dt, c, _, _ := newRouter(t)
h.Start()
c.pass(2 * time.Minute)
open := cond("machine.shanks.units", Warning, "2 failed services on shanks")
open.Raised = c.now()
h.Condition(EventRaised, open)
settle(h, c)
ask := ackAsk(c, "silence-units")
ask.About = open.Key
submit(t, h, ConditionOwner, ask)
runFor(h, c, time.Minute) // the question reaches Telegram too, as its own message
replaced, tgSaid := len(dt.replaced), saidOn(tg)
// Lost before the message was last said: its answers are those of the notifier that runs now.
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: open.Raised.Add(-time.Minute)})
h.Stand("telegram", Standing{Ready: true, At: c.now()})
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced || saidOn(tg) != tgSaid {
t.Fatalf("said again for answers that were not lost: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
// Lost while the desk cannot send: waits, and goes to the desk once it can — never to Telegram.
c.pass(time.Minute)
lost := c.now()
dt.notReady = errorf("notifications paused")
h.Stand("desktop", Standing{Ready: false, Why: "notifications paused", At: c.now(), AnswersLostAt: lost})
runFor(h, c, 2*time.Minute)
if len(dt.replaced) != replaced || saidOn(tg) != tgSaid {
t.Fatalf("said while the desk cannot send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
dt.notReady = nil
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced+1 || saidOn(tg) != tgSaid {
t.Fatalf("once the desk can send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
// The router started again, remembering no standing it acted on: the message was said since, so nothing.
h.shows = map[string]time.Time{}
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced+1 {
t.Fatalf("said again twice for one loss: %+v", dt.replaced[replaced:])
}
// A send that fails is tried again at the next standing, on the desk alone.
c.pass(time.Minute)
lost = c.now()
dt.fail = errorf("no session answers")
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
dt.fail = nil
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced+1 || saidOn(tg) != tgSaid {
t.Fatalf("after a failed send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
if len(dt.replaced) != replaced+2 || saidOn(tg) != tgSaid {
t.Fatalf("the failed send was not tried again on the desk: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
}
@@ -45,6 +45,11 @@ type Standing struct {
Why string `json:"why,omitempty"`
EditsSilently bool `json:"edits-silently,omitempty"`
At time.Time `json:"at"`
// AnswersLostAt is when the answers the channel's messages waited for were last lost: the desk's notifier
// started again, its notifications still on screen and their buttons dead (issue 384). The router then says
// again what is still open and unanswered there (restart.go); the channel shows nothing from its own memory.
// Zero from a channel whose answers are never lost (Telegram's buttons live in the chat).
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Bench is every kind the router may send to, and what each said.