From cd85104988d7fcbe35f12a22fa2adcf5551199c9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 16:02:37 +0200 Subject: [PATCH 1/2] A channel shows nothing from its memory after a restart; the router says again what is still open (issue 384) On 2026-10-10 the desk's notifier started again on two machines and the desk showed, from its own memory, nine old popups: resolved, silenced and cancelled ones among them. Its memory held every handle ever shown with buttons, and the router's edits (silenced, resolved) had reached only the newest handle of each problem, so the rest stood as if open. A channel's memory serves only to replace or edit a message in place. A notifier that started again lost its screen: the desk forgets that machine's notification ids and says in its standing since when the screen is new (shows-since). The router, which alone knows what is still open and unanswered, says that again on that kind: one message per problem under its handle with the words and answers of now, a silenced one edited to say so with no answers and no urgency, nothing that ended. Each move of shows-since is acted on once, and waits while the desk cannot send rather than going to Telegram. Telegram re-shows nothing on a restart (its messages stay in the chat) and says no shows-since. An edit that offers nothing and reaches no screen its message is on is taken with nothing to change; one that offers answers is said as failed, so the router says it anew. --- modules/desk-channel/README.md | 10 +- modules/desk-channel/cmd/desk-channel/desk.go | 118 +++++++++--- .../cmd/desk-channel/desk_test.go | 91 ++++++++- modules/desk-channel/cmd/desk-channel/main.go | 2 +- modules/messenger/README.md | 7 + modules/messenger/cmd/messenger/holder.go | 3 + modules/messenger/cmd/messenger/main.go | 5 +- modules/messenger/cmd/messenger/restart.go | 152 +++++++++++++++ .../messenger/cmd/messenger/restart_test.go | 182 ++++++++++++++++++ .../messenger/cmd/messenger/seatchannel.go | 5 + 10 files changed, 539 insertions(+), 36 deletions(-) create mode 100644 modules/messenger/cmd/messenger/restart.go create mode 100644 modules/messenger/cmd/messenger/restart_test.go diff --git a/modules/desk-channel/README.md b/modules/desk-channel/README.md index e6050c86..d25a5eb9 100644 --- a/modules/desk-channel/README.md +++ b/modules/desk-channel/README.md @@ -9,7 +9,15 @@ 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. A notifier + that started again (`dunst.started`) lost what was on its screen: the desk forgets that machine's + notification ids and says in its standing since when the screen is new (`shows-since`); the router, which + alone knows what is still open and unanswered, says that again under its handle with the words and answers of + now, so it is shown anew there, once. Nothing resolved, silenced or cancelled comes back. An edit that + offers nothing and reaches no screen its message is on is taken with nothing to change; one that offers + answers is said as failed, so the router says it anew. - 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 diff --git a/modules/desk-channel/cmd/desk-channel/desk.go b/modules/desk-channel/cmd/desk-channel/desk.go index 3e846a93..b1886e56 100644 --- a/modules/desk-channel/cmd/desk-channel/desk.go +++ b/modules/desk-channel/cmd/desk-channel/desk.go @@ -9,8 +9,11 @@ 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. A notifier that started again +// (`dunst.started`) lost what was on its screen: the desk forgets that machine's notification ids and says in +// its standing since when the screen is new (`shows-since`); the router, which alone knows what is still open +// and unanswered, says that again with the words of now. 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. @@ -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 since when what it shows is on a screen. +// ShowsSince moves when a named machine's notifier started again: nothing shown before it is on that screen +// now, so the router 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. +type Standing struct { + asks.Standing + ShowsSince time.Time `json:"shows-since,omitempty"` +} + // 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 + // screen is when each machine's notifier last started: nothing shown there before is on its screen. + screen 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.screen == nil { + d.screen = map[string]time.Time{} + } if d.Now == nil { d.Now = time.Now } @@ -183,6 +200,13 @@ func (d *Desk) Take(subject string, body json.RawMessage) error { s.On[machine] = id shown++ } + if shown == 0 && edit && len(failed) == 0 && len(m.Buttons) == 0 { + // An edit of a message on no screen now (its notifier started again, issue 384) has nothing to change: + // taken, and nothing shown — a resolved or silenced message never comes back this way. An edit that + // offers answers is a failure below, so the router says it anew and the answers reach a screen. + d.Logf("[desk-channel] an edit of %s reached no screen it is on: nothing to change", m.Handle) + return nil + } if shown == 0 { why := "no machine showed it" if len(failed) > 0 { @@ -251,13 +275,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 +327,61 @@ 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 it started again on a machine (dunst's event `started`): what was on +// that screen is gone. Nothing is shown from memory (issue 384: nine old popups, resolved and silenced ones +// among them). The machine's notification ids are forgotten, so an edit of a message shown there before +// changes nothing there and a message said again is shown anew, and the standing says since when the screen +// is new — the router then says again what is still open and unanswered. It answers how many messages were +// on that screen. +func (d *Desk) Started(event, machine string) int { d.init() + if d.again(event) { + return 0 + } + now := d.Now() + d.mu.Lock() + d.screen[machine] = now + d.mu.Unlock() handles, err := d.Store.Handles() if err != nil { + d.Logf("[desk-channel] the notifier on %s started again, and what was shown there could not be read (%v)", machine, err) + d.SayStanding() return 0 } sort.Strings(handles) n := 0 for _, h := range handles { s, err := d.Store.Get(h) - if err != nil || s == nil || len(s.Message.Buttons) == 0 { + if err != nil || s == nil { continue } if _, here := s.On[machine]; !here { continue } - id, err := d.show(machine, s.Message, 0) - if err != nil { - continue + delete(s.On, machine) + if err := d.Store.Put(h, *s); err != nil { + d.Logf("[desk-channel] a notification gone from %s could not be forgotten: %v", machine, err) } - 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: %d message(s) gone from its screen, none shown from memory; "+ + "the router says again what is still open", machine, n) + d.SayStanding() return n } @@ -378,9 +423,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()}, ShowsSince: d.showsSince()} if _, why := d.seeing(); why != "" { s.Why = why return s @@ -389,11 +434,26 @@ func (d *Desk) Standing() asks.Standing { return s } +// showsSince is when the newest screen among the named machines became new: the latest start of a named +// machine's notifier. Zero while none started again since this desk started. +func (d *Desk) showsSince() time.Time { + var since time.Time + d.mu.Lock() + defer d.mu.Unlock() + for _, m := range d.Machines() { + if at := d.screen[m]; at.After(since) { + since = at + } + } + return since +} + // 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.ShowsSince.Equal(d.said.ShowsSince) && + s.At.Sub(d.saidAt) < StandingEvery d.mu.Unlock() if same { return @@ -424,12 +484,20 @@ 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: a notifier that started again lost its screen, the desk forgets that machine's notifications and says so in its standing (shows-since), and the router says again what is still open and unanswered — 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.ShowsSince.IsZero() { + screen := map[string]string{} + for m, at := range d.screen { + screen[m] = at.Format(time.RFC3339) + } + out["notifier_started"] = screen + out["shows_since"] = s.ShowsSince.Format(time.RFC3339) + } return out } diff --git a/modules/desk-channel/cmd/desk-channel/desk_test.go b/modules/desk-channel/cmd/desk-channel/desk_test.go index d6245312..a7f02862 100644 --- a/modules/desk-channel/cmd/desk-channel/desk_test.go +++ b/modules/desk-channel/cmd/desk-channel/desk_test.go @@ -103,20 +103,97 @@ 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). After it, the desk shows nothing from memory: with three messages +// remembered on the machine — one open with answers, one silenced, one resolved — nothing is shown, the +// machine's notifications are forgotten, and the standing says since when the screen is new. The router then +// says what is still open: a show under the open one's handle is shown anew (no replace of a notification +// that is gone), an edit of the silenced one changes nothing and fails nothing, and the resolved one is never +// touched. A click on the gone notification's ticket is refused until it is shown again; 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}) 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 != 3 { + t.Fatalf("messages gone from the screen: %d, want 3", 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:]) + } + for _, h := range []string{"open", "silenced", "resolved"} { + if _, kept := store[h].On["laptop"]; kept { + t.Errorf("%s still has a notification on the laptop: %+v", h, store[h].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.ShowsSince.Equal(later) { + t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1]) + } + // A click on a ticket of a notification that is gone: refused there, nothing said to the router. + d.Chosen("e1", "laptop", "t-open") + if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 0 { + t.Error("a click on a gone notification was said to the router") + } + if last := (*shown)[len(*shown)-1]; last.args["summary"] != "That answer was not taken" { + t.Errorf("the refusal was not shown: %+v", last) + } + // The router says the open one again under its handle: shown anew, replacing nothing, and clickable. + 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 _, replaces := last.args["replace_id"]; replaces || last.args["summary"] != "2 failed services on shanks" || last.args["actions"] == nil { + t.Fatalf("the open message said again: %+v", last.args) + } + if store["open"].On["laptop"] == 0 { + t.Errorf("the new notification is not kept: %+v", store["open"]) + } + d.Chosen("e2", "laptop", "t-open") + if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 { + t.Error("a click on the message shown again was not said to the router") + } + // The router's edit of the silenced one reaches no screen it is on: nothing shown, nothing failed. + n := len(*shown) + take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true}) + if len(*shown) != n { + t.Errorf("an edit of a message gone from the screen was shown: %+v", (*shown)[n:]) + } + if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 0 { + t.Errorf("an edit with nothing to change was said as failed: %s", f) + } + // An edit that offers answers and reaches no screen is a failure, so the router says it anew. + take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Back: questions for you not delivered", + Buttons: []asks.Button{{Label: "Details", Ticket: "t-again"}}}) + if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 { + t.Errorf("an edit offering answers that reached no screen was not said as failed: %s", f) + } + // 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) } } diff --git a/modules/desk-channel/cmd/desk-channel/main.go b/modules/desk-channel/cmd/desk-channel/main.go index 6e9519b0..1fa72fcf 100644 --- a/modules/desk-channel/cmd/desk-channel/main.go +++ b/modules/desk-channel/cmd/desk-channel/main.go @@ -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 != "" { diff --git a/modules/messenger/README.md b/modules/messenger/README.md index d377c49f..60d5245b 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -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). +- **A screen made new is said again from here, never from a channel's memory** (issue 384, + `cmd/messenger/restart.go`): a channel whose screen was lost — the desk's notifier started again — says so + in its standing (`shows-since`), and the router says again on that kind what is still open and asked there, + one message per problem under its handle with the words and answers of now; what is silenced is edited to + say so, with no answers and no urgency; what ended never comes back. Each move of `shows-since` is acted on + once, and waits while the kind cannot send rather than going to another channel. Telegram's messages stay in + the chat and it says no `shows-since`. ## Asks, answers and warrants (novox/hq ADR 0259) diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index b5441d49..1205938b 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -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 ShowsSince this router last acted on: a later one is a screen made new (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 diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index e4d474bd..8648140d 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -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 shows-since 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 diff --git a/modules/messenger/cmd/messenger/restart.go b/modules/messenger/cmd/messenger/restart.go new file mode 100644 index 00000000..958f350a --- /dev/null +++ b/modules/messenger/cmd/messenger/restart.go @@ -0,0 +1,152 @@ +package main + +// A screen made new (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 that lost its screen says so in its standing +// (ShowsSince moved), and this router says again, on that kind only: +// +// - **what is open and asked there**: one message per problem, under its handle, with the words and the +// answers of now — the channel shows it anew where the screen is new and replaces it where it is not; +// - **what is silenced**: its message edited to the silenced words, with no answers and no urgency, where the +// channel still has it (nowhere on a new screen, so nothing appears); +// - **nothing that ended**: a resolved or cancelled message's end was said, or is said, where its message is; +// it never comes back. +// +// Each move of ShowsSince is acted on once; a standing that repeats it says nothing more. A kind that is not +// ready when its screen became new is said again once it is: the move stays pending until then, so a warning +// is not pushed to the channel that reaches the operator away as a failed delivery would be. + +import ( + "fmt" + "sort" + "time" +) + +// Stand takes what a kind said of itself, and acts on a screen made new since its last word. +func (h *Holder) Stand(kind string, s Standing) { + if h.Bench != nil { + h.Bench.Stand(kind, s) + } + if s.ShowsSince.IsZero() { + return + } + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + last, known := h.shows[kind] + started := h.started + h.mu.Unlock() + if !known { + // The kind's first word since this router started: a screen made new before that is one this router's + // state already allows for (it reads the open conditions and says what was never said); one made new + // since is acted on. + if started.IsZero() { + return + } + last = started + } + if !s.ShowsSince.After(last) { + if !known { + h.mu.Lock() + h.shows[kind] = s.ShowsSince + h.mu.Unlock() + } + return + } + ch := h.channelOf(kind) + if ch == nil { + h.Logf("[messenger] %s says its screen is new since %s, and no channel of that kind is held here: nothing said again", + kind, s.ShowsSince.UTC().Format(time.RFC3339)) + return + } + if err := ch.Ready(); err != nil { + h.Logf("[messenger] %s says its screen is new since %s and cannot send (%v): what is open there is said again once it can", + kind, s.ShowsSince.UTC().Format(time.RFC3339), err) + return + } + h.mu.Lock() + h.shows[kind] = s.ShowsSince + h.mu.Unlock() + h.reoffer(ch, s.ShowsSince) +} + +// 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 screen is new since `since`, what is still open and asked there, +// edits what is silenced to say so, and leaves what ended alone. +func (h *Holder) reoffer(ch Channel, since time.Time) { + kind := ch.Name() + 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 := 0, 0, 0 + for _, r := range recs { + h.mu.Lock() + id, shown := r.Sent[kind] + open := r.Cleared.IsZero() + reminded := r.Reminded + h.mu.Unlock() + if !shown || id == "" { + continue // never a message of its own there: nothing is on record to say again under + } + switch { + case !open: + ended++ // its end is said where its message is, or was; 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]: + what := "raised" + if reminded { + what = "reminder" + } + h.enqueue(kind, item{Key: r.Key, What: what, Severity: r.Severity, At: now}) + again++ + } + } + h.record(Sent{At: now, Channel: kind, Key: "(screen)", What: "new since " + since.UTC().Format(time.RFC3339), + Outcome: fmt.Sprintf("%d open and asked said again, %d silenced said so, %d ended left ended", again, silenced, ended)}) + h.Logf("[messenger] %s's screen is new since %s: %d open and asked said again, %d silenced said so, %d ended left ended; "+ + "nothing shown from the channel's memory", kind, since.UTC().Format(time.RFC3339), again, silenced, ended) + if again > 0 { + // Now, not after the desk's warning rhythm: the operator is at a screen that lost its questions. + h.flushChannel(ch, now, true) + } +} + +// 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"}) +} diff --git a/modules/messenger/cmd/messenger/restart_test.go b/modules/messenger/cmd/messenger/restart_test.go new file mode 100644 index 00000000..42f0069e --- /dev/null +++ b/modules/messenger/cmd/messenger/restart_test.go @@ -0,0 +1,182 @@ +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 since when its screen +// is new, and this router says again only what is still open and unanswered: with three messages on the desk +// — one open with an answer, one silenced, one resolved — one is said again under its handle, with its +// answers; one is edited to the silenced words, with no answers and no urgency; nothing is said for the +// resolved one. 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 TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(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 since when its screen is new. + c.pass(time.Minute) + newSince := c.now() + h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: 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(), ShowsSince: newSince}) + h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: 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) + } +} + +// A screen made new while the desk cannot send is said again once it can — and not pushed to the channel that +// reaches the operator away. A desk that never said its screen is new (or Telegram, whose messages stay in the +// chat) gets nothing said again; a screen made new before this router started is in the state it read. +func TestAScreenMadeNewWaitsForTheDeskAndIsNeverGuessed(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) + // Before this router started: already allowed for. + h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: h.started.Add(-time.Minute)}) + // Telegram says no screen: nothing. + 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 without a screen made new: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends) + } + // Made new while the desk cannot send: waits, and goes to the desk once it can — never to Telegram. + c.pass(time.Minute) + since := c.now() + dt.notReady = errorf("notifications paused") + h.Stand("desktop", Standing{Ready: false, Why: "notifications paused", At: c.now(), ShowsSince: since}) + 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(), ShowsSince: since}) + 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) + } +} diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go index f7c8fff2..08dae1c0 100644 --- a/modules/messenger/cmd/messenger/seatchannel.go +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -45,6 +45,11 @@ type Standing struct { Why string `json:"why,omitempty"` EditsSilently bool `json:"edits-silently,omitempty"` At time.Time `json:"at"` + // ShowsSince is since when what the channel shows is on a screen: it moves when the screen became new — the + // desk's notifier started again and lost what it showed (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 messages stay where they were put (Telegram's chat). + ShowsSince time.Time `json:"shows-since,omitempty"` } // Bench is every kind the router may send to, and what each said. From 45361b013a8df37ebb010d19bf5d100412b8b799 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 16:13:57 +0200 Subject: [PATCH 2/2] Review: keep the desk's notification ids so lost answers are said again in place (issue 384) dunst.started says the notifier's bundle started again: the notifications stay on screen and only the answers they waited for are lost. Forgetting their ids made the router's message a second popup beside a dead one. The desk now keeps the ids and says answers-lost-at; the router's message replaces each notification in place. Whether a message is said again is now its own (last said before the answers were lost), so a router that started after the loss still says it and a repeated standing says nothing. It is sent to the desk directly, not through the outbox, whose failure path moves a warning to Telegram; a failed send is tried again at the next standing. --- modules/desk-channel/README.md | 13 +- modules/desk-channel/cmd/desk-channel/desk.go | 114 +++++++---------- .../cmd/desk-channel/desk_test.go | 61 +++------ modules/messenger/README.md | 14 +-- modules/messenger/cmd/messenger/holder.go | 2 +- modules/messenger/cmd/messenger/main.go | 2 +- modules/messenger/cmd/messenger/restart.go | 116 +++++++++--------- .../messenger/cmd/messenger/restart_test.go | 67 ++++++---- .../messenger/cmd/messenger/seatchannel.go | 10 +- 9 files changed, 188 insertions(+), 211 deletions(-) diff --git a/modules/desk-channel/README.md b/modules/desk-channel/README.md index d25a5eb9..860ef98a 100644 --- a/modules/desk-channel/README.md +++ b/modules/desk-channel/README.md @@ -11,13 +11,12 @@ each machine the operator sits at (the `node-notifier` seat), and says which but was made. A message with buttons stays until it is dismissed. - **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. A notifier - that started again (`dunst.started`) lost what was on its screen: the desk forgets that machine's - notification ids and says in its standing since when the screen is new (`shows-since`); the router, which - alone knows what is still open and unanswered, says that again under its handle with the words and answers of - now, so it is shown anew there, once. Nothing resolved, silenced or cancelled comes back. An edit that - offers nothing and reaches no screen its message is on is taken with nothing to change; one that offers - answers is said as failed, so the router says it anew. + 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 diff --git a/modules/desk-channel/cmd/desk-channel/desk.go b/modules/desk-channel/cmd/desk-channel/desk.go index b1886e56..880a8939 100644 --- a/modules/desk-channel/cmd/desk-channel/desk.go +++ b/modules/desk-channel/cmd/desk-channel/desk.go @@ -10,10 +10,11 @@ package main // - **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. // - **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. A notifier that started again -// (`dunst.started`) lost what was on its screen: the desk forgets that machine's notification ids and says in -// its standing since when the screen is new (`shows-since`); the router, which alone knows what is still open -// and unanswered, says that again with the words of now. Nothing resolved, silenced or cancelled comes back. +// 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. @@ -22,7 +23,6 @@ import ( "errors" "fmt" "html" - "sort" "strconv" "strings" "sync" @@ -50,13 +50,13 @@ 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 since when what it shows is on a screen. -// ShowsSince moves when a named machine's notifier started again: nothing shown before it is on that screen -// now, so the router 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. +// 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 - ShowsSince time.Time `json:"shows-since,omitempty"` + 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 @@ -91,8 +91,8 @@ type Desk struct { saidAt time.Time acted map[string]bool pause map[string]pauseSeen - // screen is when each machine's notifier last started: nothing shown there before is on its screen. - screen map[string]time.Time + // 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() { @@ -104,8 +104,8 @@ func (d *Desk) init() { if d.pause == nil { d.pause = map[string]pauseSeen{} } - if d.screen == nil { - d.screen = map[string]time.Time{} + if d.lost == nil { + d.lost = map[string]time.Time{} } if d.Now == nil { d.Now = time.Now @@ -200,13 +200,6 @@ func (d *Desk) Take(subject string, body json.RawMessage) error { s.On[machine] = id shown++ } - if shown == 0 && edit && len(failed) == 0 && len(m.Buttons) == 0 { - // An edit of a message on no screen now (its notifier started again, issue 384) has nothing to change: - // taken, and nothing shown — a resolved or silenced message never comes back this way. An edit that - // offers answers is a failure below, so the router says it anew and the answers reach a screen. - d.Logf("[desk-channel] an edit of %s reached no screen it is on: nothing to change", m.Handle) - return nil - } if shown == 0 { why := "no machine showed it" if len(failed) > 0 { @@ -342,45 +335,32 @@ func (d *Desk) again(event string) bool { return done } -// Started takes a notifier's word that it started again on a machine (dunst's event `started`): what was on -// that screen is gone. Nothing is shown from memory (issue 384: nine old popups, resolved and silenced ones -// among them). The machine's notification ids are forgotten, so an edit of a message shown there before -// changes nothing there and a message said again is shown anew, and the standing says since when the screen -// is new — the router then says again what is still open and unanswered. It answers how many messages were -// on that screen. +// 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() if d.again(event) { return 0 } - now := d.Now() d.mu.Lock() - d.screen[machine] = now + d.lost[machine] = d.Now() d.mu.Unlock() - handles, err := d.Store.Handles() - if err != nil { - d.Logf("[desk-channel] the notifier on %s started again, and what was shown there could not be read (%v)", machine, err) - d.SayStanding() - return 0 - } - sort.Strings(handles) n := 0 - for _, h := range handles { - s, err := d.Store.Get(h) - if err != nil || s == nil { - 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 - } - delete(s.On, machine) - if err := d.Store.Put(h, *s); err != nil { - d.Logf("[desk-channel] a notification gone from %s could not be forgotten: %v", machine, err) - } - n++ } - d.Logf("[desk-channel] the notifier on %s started again: %d message(s) gone from its screen, none shown from memory; "+ - "the router says again what is still open", 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 } @@ -425,7 +405,7 @@ func (d *Desk) seeing() ([]string, string) { // (the review of 2026-10-09). The router shows no link's code while the desk is not ready. func (d *Desk) Standing() Standing { d.init() - s := Standing{Standing: asks.Standing{At: d.Now()}, ShowsSince: d.showsSince()} + s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()} if _, why := d.seeing(); why != "" { s.Why = why return s @@ -434,25 +414,26 @@ func (d *Desk) Standing() Standing { return s } -// showsSince is when the newest screen among the named machines became new: the latest start of a named -// machine's notifier. Zero while none started again since this desk started. -func (d *Desk) showsSince() time.Time { - var since time.Time +// 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 d.Machines() { - if at := d.screen[m]; at.After(since) { - since = at + for _, m := range machines { + if t := d.lost[m]; t.After(at) { + at = t } } - return since + 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.ShowsSince.Equal(d.said.ShowsSince) && + 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 { @@ -484,19 +465,18 @@ 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", - "what it remembers is never shown again: a notifier that started again lost its screen, the desk forgets that machine's notifications and says so in its standing (shows-since), and the router says again what is still open and unanswered — nothing resolved, silenced or cancelled comes back (issue 384)", + "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.ShowsSince.IsZero() { - screen := map[string]string{} - for m, at := range d.screen { - screen[m] = at.Format(time.RFC3339) + if !s.AnswersLostAt.IsZero() { + started := map[string]string{} + for m, at := range d.lost { + started[m] = at.Format(time.RFC3339) } - out["notifier_started"] = screen - out["shows_since"] = s.ShowsSince.Format(time.RFC3339) + out["notifier_started"] = started } return out } diff --git a/modules/desk-channel/cmd/desk-channel/desk_test.go b/modules/desk-channel/cmd/desk-channel/desk_test.go index a7f02862..69b8c72f 100644 --- a/modules/desk-channel/cmd/desk-channel/desk_test.go +++ b/modules/desk-channel/cmd/desk-channel/desk_test.go @@ -113,12 +113,11 @@ func TestAnEditReplacesTheNotification(t *testing.T) { } // 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). After it, the desk shows nothing from memory: with three messages -// remembered on the machine — one open with answers, one silenced, one resolved — nothing is shown, the -// machine's notifications are forgotten, and the standing says since when the screen is new. The router then -// says what is still open: a show under the open one's handle is shown anew (no replace of a notification -// that is gone), an edit of the silenced one changes nothing and fails nothing, and the resolved one is never -// touched. A click on the gone notification's ticket is refused until it is shown again; the same start event +// 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") @@ -132,69 +131,47 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) { 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) 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 != 3 { - t.Fatalf("messages gone from the screen: %d, want 3", n) + 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 len(*shown) != before { t.Fatalf("shown from memory after the notifier started again: %+v", (*shown)[before:]) } - for _, h := range []string{"open", "silenced", "resolved"} { - if _, kept := store[h].On["laptop"]; kept { - t.Errorf("%s still has a notification on the laptop: %+v", h, store[h].On) - } + 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.ShowsSince.Equal(later) { + 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]) } - // A click on a ticket of a notification that is gone: refused there, nothing said to the router. - d.Chosen("e1", "laptop", "t-open") - if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 0 { - t.Error("a click on a gone notification was said to the router") - } - if last := (*shown)[len(*shown)-1]; last.args["summary"] != "That answer was not taken" { - t.Errorf("the refusal was not shown: %+v", last) - } - // The router says the open one again under its handle: shown anew, replacing nothing, and clickable. + // 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 _, replaces := last.args["replace_id"]; replaces || last.args["summary"] != "2 failed services on shanks" || last.args["actions"] == nil { + if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil { t.Fatalf("the open message said again: %+v", last.args) } - if store["open"].On["laptop"] == 0 { - t.Errorf("the new notification is not kept: %+v", store["open"]) - } d.Chosen("e2", "laptop", "t-open") if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 { - t.Error("a click on the message shown again was not said to the router") - } - // The router's edit of the silenced one reaches no screen it is on: nothing shown, nothing failed. - n := len(*shown) - take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true}) - if len(*shown) != n { - t.Errorf("an edit of a message gone from the screen was shown: %+v", (*shown)[n:]) - } - if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 0 { - t.Errorf("an edit with nothing to change was said as failed: %s", f) - } - // An edit that offers answers and reaches no screen is a failure, so the router says it anew. - take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Back: questions for you not delivered", - Buttons: []asks.Button{{Label: "Details", Ticket: "t-again"}}}) - if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 { - t.Errorf("an edit offering answers that reached no screen was not said as failed: %s", f) + 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") + } } func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) { diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 60d5245b..bf4771ff 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -18,13 +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). -- **A screen made new is said again from here, never from a channel's memory** (issue 384, - `cmd/messenger/restart.go`): a channel whose screen was lost — the desk's notifier started again — says so - in its standing (`shows-since`), and the router says again on that kind what is still open and asked there, - one message per problem under its handle with the words and answers of now; what is silenced is edited to - say so, with no answers and no urgency; what ended never comes back. Each move of `shows-since` is acted on - once, and waits while the kind cannot send rather than going to another channel. Telegram's messages stay in - the chat and it says no `shows-since`. +- **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) diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 1205938b..4aaaa9bf 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -327,7 +327,7 @@ 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 ShowsSince this router last acted on: a later one is a screen made new (restart.go). + // shows is, by kind, the AnswersLostAt this router last acted on in full (restart.go). shows map[string]time.Time } diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 8648140d..c20b5041 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -397,7 +397,7 @@ func listenForChannels(h *Holder) { h.Failed(kind, f) } case asks.StandingWhat: - // Read as this module's Standing: the bench's word, and shows-since beside it (issue 384). + // 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.Stand(kind, s) diff --git a/modules/messenger/cmd/messenger/restart.go b/modules/messenger/cmd/messenger/restart.go index 958f350a..ff57f14f 100644 --- a/modules/messenger/cmd/messenger/restart.go +++ b/modules/messenger/cmd/messenger/restart.go @@ -1,21 +1,23 @@ package main -// A screen made new (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 that lost its screen says so in its standing -// (ShowsSince moved), and this router says again, on that kind only: +// 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**: one message per problem, under its handle, with the words and the -// answers of now — the channel shows it anew where the screen is new and replaces it where it is not; -// - **what is silenced**: its message edited to the silenced words, with no answers and no urgency, where the -// channel still has it (nowhere on a new screen, so nothing appears); -// - **nothing that ended**: a resolved or cancelled message's end was said, or is said, where its message is; -// it never comes back. +// - **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. // -// Each move of ShowsSince is acted on once; a standing that repeats it says nothing more. A kind that is not -// ready when its screen became new is said again once it is: the move stays pending until then, so a warning -// is not pushed to the channel that reaches the operator away as a failed delivery would be. +// 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" @@ -23,53 +25,37 @@ import ( "time" ) -// Stand takes what a kind said of itself, and acts on a screen made new since its last word. +// 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.ShowsSince.IsZero() { + if s.AnswersLostAt.IsZero() { return } h.work.Lock() defer h.work.Unlock() h.mu.Lock() h.init() - last, known := h.shows[kind] - started := h.started + done := h.shows[kind].Equal(s.AnswersLostAt) h.mu.Unlock() - if !known { - // The kind's first word since this router started: a screen made new before that is one this router's - // state already allows for (it reads the open conditions and says what was never said); one made new - // since is acted on. - if started.IsZero() { - return - } - last = started - } - if !s.ShowsSince.After(last) { - if !known { - h.mu.Lock() - h.shows[kind] = s.ShowsSince - h.mu.Unlock() - } + if done { return } ch := h.channelOf(kind) if ch == nil { - h.Logf("[messenger] %s says its screen is new since %s, and no channel of that kind is held here: nothing said again", - kind, s.ShowsSince.UTC().Format(time.RFC3339)) - return + 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 says its screen is new since %s and cannot send (%v): what is open there is said again once it can", - kind, s.ShowsSince.UTC().Format(time.RFC3339), err) + 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 } - h.mu.Lock() - h.shows[kind] = s.ShowsSince - h.mu.Unlock() - h.reoffer(ch, s.ShowsSince) + 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. @@ -82,10 +68,12 @@ func (h *Holder) channelOf(kind string) Channel { return nil } -// reoffer says again on one channel, whose screen is new since `since`, what is still open and asked there, -// edits what is silenced to say so, and leaves what ended alone. -func (h *Holder) reoffer(ch Channel, since time.Time) { +// 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 @@ -94,19 +82,20 @@ func (h *Holder) reoffer(ch Channel, since time.Time) { } h.mu.Unlock() sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) }) - again, silenced, ended := 0, 0, 0 + 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 is on record to say again under + 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, or was; nothing comes back + ended++ // its end is said where its message is; nothing comes back case r.silenced(now): h.mu.Lock() r.SilenceSaid = true @@ -114,23 +103,34 @@ func (h *Holder) reoffer(ch Channel, since time.Time) { h.editOn(ch, r, "silenced") h.persist(r) silenced++ - case h.askKinds(r)[kind]: + case h.askKinds(r)[kind] && before && canReplace: what := "raised" if reminded { what = "reminder" } - h.enqueue(kind, item{Key: r.Key, What: what, Severity: r.Severity, At: now}) + 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: "(screen)", What: "new since " + since.UTC().Format(time.RFC3339), - Outcome: fmt.Sprintf("%d open and asked said again, %d silenced said so, %d ended left ended", again, silenced, ended)}) - h.Logf("[messenger] %s's screen is new since %s: %d open and asked said again, %d silenced said so, %d ended left ended; "+ - "nothing shown from the channel's memory", kind, since.UTC().Format(time.RFC3339), again, silenced, ended) - if again > 0 { - // Now, not after the desk's warning rhythm: the operator is at a screen that lost its questions. - h.flushChannel(ch, now, true) - } + 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. diff --git a/modules/messenger/cmd/messenger/restart_test.go b/modules/messenger/cmd/messenger/restart_test.go index 42f0069e..d7a5be8f 100644 --- a/modules/messenger/cmd/messenger/restart_test.go +++ b/modules/messenger/cmd/messenger/restart_test.go @@ -9,13 +9,13 @@ import ( ) // 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 since when its screen -// is new, and this router says again only what is still open and unanswered: with three messages on the desk -// — one open with an answer, one silenced, one resolved — one is said again under its handle, with its -// answers; one is edited to the silenced words, with no answers and no urgency; nothing is said for the -// resolved one. 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 TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) { +// 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) @@ -80,10 +80,10 @@ func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) { 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 since when its screen is new. + // 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(), ShowsSince: newSince}) + 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:]) @@ -109,8 +109,8 @@ func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) { 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(), ShowsSince: newSince}) - h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: newSince.Add(-time.Hour)}) + 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) @@ -140,10 +140,11 @@ func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) { } } -// A screen made new while the desk cannot send is said again once it can — and not pushed to the channel that -// reaches the operator away. A desk that never said its screen is new (or Telegram, whose messages stay in the -// chat) gets nothing said again; a screen made new before this router started is in the state it read. -func TestAScreenMadeNewWaitsForTheDeskAndIsNeverGuessed(t *testing.T) { +// 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) @@ -156,27 +157,47 @@ func TestAScreenMadeNewWaitsForTheDeskAndIsNeverGuessed(t *testing.T) { 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) - // Before this router started: already allowed for. - h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: h.started.Add(-time.Minute)}) - // Telegram says no screen: nothing. + // 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 without a screen made new: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends) + t.Fatalf("said again for answers that were not lost: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends) } - // Made new while the desk cannot send: waits, and goes to the desk once it can — never to Telegram. + // Lost while the desk cannot send: waits, and goes to the desk once it can — never to Telegram. c.pass(time.Minute) - since := c.now() + lost := c.now() dt.notReady = errorf("notifications paused") - h.Stand("desktop", Standing{Ready: false, Why: "notifications paused", At: c.now(), ShowsSince: since}) + 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(), ShowsSince: since}) + 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) + } } diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go index 08dae1c0..d05ca6f6 100644 --- a/modules/messenger/cmd/messenger/seatchannel.go +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -45,11 +45,11 @@ type Standing struct { Why string `json:"why,omitempty"` EditsSilently bool `json:"edits-silently,omitempty"` At time.Time `json:"at"` - // ShowsSince is since when what the channel shows is on a screen: it moves when the screen became new — the - // desk's notifier started again and lost what it showed (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 messages stay where they were put (Telegram's chat). - ShowsSince time.Time `json:"shows-since,omitempty"` + // 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.