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.