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