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