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.