Review: keep the desk's notification ids so lost answers are said again in place (issue 384)
mesh/merge-gate pass: builds desk-channel, messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered

dunst.started says the notifier's bundle started again: the notifications stay on screen and only the
answers they waited for are lost. Forgetting their ids made the router's message a second popup beside a
dead one. The desk now keeps the ids and says answers-lost-at; the router's message replaces each
notification in place.

Whether a message is said again is now its own (last said before the answers were lost), so a router
that started after the loss still says it and a repeated standing says nothing. It is sent to the desk
directly, not through the outbox, whose failure path moves a warning to Telegram; a failed send is tried
again at the next standing.
This commit is contained in:
jochen
2026-10-10 16:13:57 +02:00
parent cd85104988
commit 45361b013a
9 changed files with 188 additions and 211 deletions
+6 -7
View File
@@ -11,13 +11,12 @@ each machine the operator sits at (the `node-notifier` seat), and says which but
was made. A message with buttons stays until it is dismissed.
- **What it remembers is never shown again** (issue 384: on 2026-10-10 a notifier that started again was
shown nine old popups from the desk's memory, resolved and silenced ones among them). Its memory — which
notification each handle has on each machine — serves only to replace or edit a message in place. A notifier
that started again (`dunst.started`) lost what was on its screen: the desk forgets that machine's
notification ids and says in its standing since when the screen is new (`shows-since`); the router, which
alone knows what is still open and unanswered, says that again under its handle with the words and answers of
now, so it is shown anew there, once. Nothing resolved, silenced or cancelled comes back. An edit that
offers nothing and reaches no screen its message is on is taken with nothing to change; one that offers
answers is said as failed, so the router says it anew.
notification each handle has on each machine — serves only to replace or edit a message in place. When the
notifier's bundle starts again on a machine (`dunst.started`), its notifications stay on screen and the
answers they waited for are lost: the desk keeps their ids and says in its standing when that happened
(`answers-lost-at`); the router, which alone knows what is still open and unanswered, says that again under
its handle, replacing the notification in place so its buttons work again. Nothing resolved, silenced or
cancelled comes back.
- A message no machine took is said to the router as failed; the router tries it again.
- **One notification per message** (issue 373): an `edit`, and a `send` or `show` under a handle already shown (a
reminder, an escalation), replace that message's notification on each machine by its id (the notifier's
+47 -67
View File
@@ -10,10 +10,11 @@ package main
// - **An answer is bound to the machines it was shown on**: a click on another machine is refused here, and
// said there. A message with buttons stays until it is dismissed.
// - **What it remembers is never shown again** (issue 384). Its memory — which notification a handle has on
// each machine — serves only to replace or edit a message in place. A notifier that started again
// (`dunst.started`) lost what was on its screen: the desk forgets that machine's notification ids and says in
// its standing since when the screen is new (`shows-since`); the router, which alone knows what is still open
// and unanswered, says that again with the words of now. Nothing resolved, silenced or cancelled comes back.
// each machine — serves only to replace or edit a message in place. When the notifier's bundle started again
// on a machine (`dunst.started`), the notifications stay on screen but the answers they waited for are lost:
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
// status. A message no machine took is said to the router as failed, and tried again by it.
@@ -22,7 +23,6 @@ import (
"errors"
"fmt"
"html"
"sort"
"strconv"
"strings"
"sync"
@@ -50,13 +50,13 @@ type pauseSeen struct {
// StandingEvery is how often the channel says whether it can send, at least.
const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and since when what it shows is on a screen.
// ShowsSince moves when a named machine's notifier started again: nothing shown before it is on that screen
// now, so the router says again what is still open and unanswered (issue 384). It belongs in asks.Standing
// once the SDK carries it; until then the field travels as JSON the router reads under the same name.
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
type Standing struct {
asks.Standing
ShowsSince time.Time `json:"shows-since,omitempty"`
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
@@ -91,8 +91,8 @@ type Desk struct {
saidAt time.Time
acted map[string]bool
pause map[string]pauseSeen
// screen is when each machine's notifier last started: nothing shown there before is on its screen.
screen map[string]time.Time
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
lost map[string]time.Time
}
func (d *Desk) init() {
@@ -104,8 +104,8 @@ func (d *Desk) init() {
if d.pause == nil {
d.pause = map[string]pauseSeen{}
}
if d.screen == nil {
d.screen = map[string]time.Time{}
if d.lost == nil {
d.lost = map[string]time.Time{}
}
if d.Now == nil {
d.Now = time.Now
@@ -200,13 +200,6 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
s.On[machine] = id
shown++
}
if shown == 0 && edit && len(failed) == 0 && len(m.Buttons) == 0 {
// An edit of a message on no screen now (its notifier started again, issue 384) has nothing to change:
// taken, and nothing shown — a resolved or silenced message never comes back this way. An edit that
// offers answers is a failure below, so the router says it anew and the answers reach a screen.
d.Logf("[desk-channel] an edit of %s reached no screen it is on: nothing to change", m.Handle)
return nil
}
if shown == 0 {
why := "no machine showed it"
if len(failed) > 0 {
@@ -342,45 +335,32 @@ func (d *Desk) again(event string) bool {
return done
}
// Started takes a notifier's word that it started again on a machine (dunst's event `started`): what was on
// that screen is gone. Nothing is shown from memory (issue 384: nine old popups, resolved and silenced ones
// among them). The machine's notification ids are forgotten, so an edit of a message shown there before
// changes nothing there and a message said again is shown anew, and the standing says since when the screen
// is new — the router then says again what is still open and unanswered. It answers how many messages were
// on that screen.
// Started takes a notifier's word that its bundle started again on a machine (dunst's event `started`): the
// notifications stay on screen, and the answers they waited for are lost. Nothing is shown from memory (issue
// 384: nine old popups, resolved and silenced ones among them, shown again from it). The notification ids
// are kept, so what the router says again replaces each notification in place, and the standing says when
// the answers were lost; the router alone knows what is still open and unanswered. It answers how many
// messages with buttons that machine shows.
func (d *Desk) Started(event, machine string) int {
d.init()
if d.again(event) {
return 0
}
now := d.Now()
d.mu.Lock()
d.screen[machine] = now
d.lost[machine] = d.Now()
d.mu.Unlock()
handles, err := d.Store.Handles()
if err != nil {
d.Logf("[desk-channel] the notifier on %s started again, and what was shown there could not be read (%v)", machine, err)
d.SayStanding()
return 0
}
sort.Strings(handles)
n := 0
for _, h := range handles {
s, err := d.Store.Get(h)
if err != nil || s == nil {
continue
if handles, err := d.Store.Handles(); err == nil {
for _, h := range handles {
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if _, here := s.On[machine]; here {
n++
}
}
}
if _, here := s.On[machine]; !here {
continue
}
delete(s.On, machine)
if err := d.Store.Put(h, *s); err != nil {
d.Logf("[desk-channel] a notification gone from %s could not be forgotten: %v", machine, err)
}
n++
}
d.Logf("[desk-channel] the notifier on %s started again: %d message(s) gone from its screen, none shown from memory; "+
"the router says again what is still open", machine, n)
d.Logf("[desk-channel] the notifier on %s started again: the answers of %d message(s) shown there are lost; none shown "+
"from memory, the router says again what is still open", machine, n)
d.SayStanding()
return n
}
@@ -425,7 +405,7 @@ func (d *Desk) seeing() ([]string, string) {
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() Standing {
d.init()
s := Standing{Standing: asks.Standing{At: d.Now()}, ShowsSince: d.showsSince()}
s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -434,25 +414,26 @@ func (d *Desk) Standing() Standing {
return s
}
// showsSince is when the newest screen among the named machines became new: the latest start of a named
// machine's notifier. Zero while none started again since this desk started.
func (d *Desk) showsSince() time.Time {
var since time.Time
// answersLostAt is the latest start of a named machine's notifier: zero while none started again since this
// desk started.
func (d *Desk) answersLostAt() time.Time {
machines := d.Machines() // read outside the lock: it reads the settings file
var at time.Time
d.mu.Lock()
defer d.mu.Unlock()
for _, m := range d.Machines() {
if at := d.screen[m]; at.After(since) {
since = at
for _, m := range machines {
if t := d.lost[m]; t.After(at) {
at = t
}
}
return since
return at
}
// SayStanding says the standing when it changed, or every StandingEvery.
func (d *Desk) SayStanding() {
s := d.Standing()
d.mu.Lock()
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.ShowsSince.Equal(d.said.ShowsSince) &&
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.AnswersLostAt.Equal(d.said.AnswersLostAt) &&
s.At.Sub(d.saidAt) < StandingEvery
d.mu.Unlock()
if same {
@@ -484,19 +465,18 @@ func (d *Desk) Status() map[string]any {
"it shows the router's messages on the notifier of each machine named in desktop-machines, and says which button was chosen there; the router judges every answer (novox/hq ADR 0259)",
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
"what it remembers is never shown again: a notifier that started again lost its screen, the desk forgets that machine's notifications and says so in its standing (shows-since), and the router says again what is still open and unanswered — nothing resolved, silenced or cancelled comes back (issue 384)",
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
}}
if s.Why != "" {
out["why_not"] = s.Why
}
if !s.ShowsSince.IsZero() {
screen := map[string]string{}
for m, at := range d.screen {
screen[m] = at.Format(time.RFC3339)
if !s.AnswersLostAt.IsZero() {
started := map[string]string{}
for m, at := range d.lost {
started[m] = at.Format(time.RFC3339)
}
out["notifier_started"] = screen
out["shows_since"] = s.ShowsSince.Format(time.RFC3339)
out["notifier_started"] = started
}
return out
}
@@ -113,12 +113,11 @@ func TestAnEditReplacesTheNotification(t *testing.T) {
}
// Issue 384: a notifier that started again (nine old popups, resolved and silenced ones among them, shown from
// the desk's own memory on 2026-10-10). After it, the desk shows nothing from memory: with three messages
// remembered on the machine — one open with answers, one silenced, one resolved — nothing is shown, the
// machine's notifications are forgotten, and the standing says since when the screen is new. The router then
// says what is still open: a show under the open one's handle is shown anew (no replace of a notification
// that is gone), an edit of the silenced one changes nothing and fails nothing, and the resolved one is never
// touched. A click on the gone notification's ticket is refused until it is shown again; the same start event
// the desk's own memory on 2026-10-10). Its bundle started again, so the notifications are still on screen and
// the answers they waited for are lost. With three messages remembered on the machine — one open with answers,
// one silenced, one resolved — nothing is shown, the notification ids are kept, and the standing says when the
// answers were lost. The router then says the open one again under its handle: it replaces its notification in
// place (one popup, its buttons alive again), and a click on it is said to the router. The same start event
// is acted on once.
func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
@@ -132,69 +131,47 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
take(t, d, asks.Show, asks.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []asks.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
openID := store["open"].On["laptop"]
before := len(*shown)
standings := len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
if n := d.Started("e-start", "laptop"); n != 3 {
t.Fatalf("messages gone from the screen: %d, want 3", n)
if n := d.Started("e-start", "laptop"); n != 1 {
t.Fatalf("messages with buttons on the machine: %d, want 1 (the open one)", n)
}
if len(*shown) != before {
t.Fatalf("shown from memory after the notifier started again: %+v", (*shown)[before:])
}
for _, h := range []string{"open", "silenced", "resolved"} {
if _, kept := store[h].On["laptop"]; kept {
t.Errorf("%s still has a notification on the laptop: %+v", h, store[h].On)
}
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[asks.IntakeSubject(asks.StandingWhat, Kind)]
var s Standing
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.ShowsSince.Equal(later) {
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// A click on a ticket of a notification that is gone: refused there, nothing said to the router.
d.Chosen("e1", "laptop", "t-open")
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 0 {
t.Error("a click on a gone notification was said to the router")
}
if last := (*shown)[len(*shown)-1]; last.args["summary"] != "That answer was not taken" {
t.Errorf("the refusal was not shown: %+v", last)
}
// The router says the open one again under its handle: shown anew, replacing nothing, and clickable.
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if _, replaces := last.args["replace_id"]; replaces || last.args["summary"] != "2 failed services on shanks" || last.args["actions"] == nil {
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
t.Fatalf("the open message said again: %+v", last.args)
}
if store["open"].On["laptop"] == 0 {
t.Errorf("the new notification is not kept: %+v", store["open"])
}
d.Chosen("e2", "laptop", "t-open")
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
t.Error("a click on the message shown again was not said to the router")
}
// The router's edit of the silenced one reaches no screen it is on: nothing shown, nothing failed.
n := len(*shown)
take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
if len(*shown) != n {
t.Errorf("an edit of a message gone from the screen was shown: %+v", (*shown)[n:])
}
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 0 {
t.Errorf("an edit with nothing to change was said as failed: %s", f)
}
// An edit that offers answers and reaches no screen is a failure, so the router says it anew.
take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Back: questions for you not delivered",
Buttons: []asks.Button{{Label: "Details", Ticket: "t-again"}}})
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 {
t.Errorf("an edit offering answers that reached no screen was not said as failed: %s", f)
t.Error("a click on the message said again was not said to the router")
}
// The same start event again (a redelivery) does nothing more.
standings = len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) != standings {
t.Errorf("a redelivered start was acted on again: %d", n)
}
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
fresh, _, _, _ := newDesk(t, "laptop")
if !fresh.Standing().AnswersLostAt.IsZero() {
t.Error("a desk whose notifier never started again says a time")
}
}
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
+7 -7
View File
@@ -18,13 +18,13 @@ channel's client and no channel's secret.
is the channel's own, said on `intake.standing`.
- **Keeps its state on the bus** (ADR 0201): `open` and `sent` (the messages), `asks` (every ask, closed ones
for 30 days), `identities` (the operator's linked accounts, one per kind, and a link asked for).
- **A screen made new is said again from here, never from a channel's memory** (issue 384,
`cmd/messenger/restart.go`): a channel whose screen was lost — the desk's notifier started again — says so
in its standing (`shows-since`), and the router says again on that kind what is still open and asked there,
one message per problem under its handle with the words and answers of now; what is silenced is edited to
say so, with no answers and no urgency; what ended never comes back. Each move of `shows-since` is acted on
once, and waits while the kind cannot send rather than going to another channel. Telegram's messages stay in
the chat and it says no `shows-since`.
- **Lost answers are said again from here, never from a channel's memory** (issue 384,
`cmd/messenger/restart.go`): a channel whose messages' answers were lost — the desk's notifier started
again — says when in its standing (`answers-lost-at`). The router says again on that kind, directly and not
through the outbox, each message that is still open, asked there and last said before that time: one per
problem, under its handle, with the words and answers of now, so it replaces the message in place. What is
silenced is edited to say so, with no answers and no urgency; what ended never comes back. A kind that cannot
send waits; it never goes to another channel. Telegram's answers are never lost and it says no time.
## Asks, answers and warrants (novox/hq ADR 0259)
+1 -1
View File
@@ -327,7 +327,7 @@ type Holder struct {
lastUnlink map[string]Unlinked
// rootWord is the controller's last word on whether an agent can become root, by the machines asked.
rootWord map[string]rootWord
// shows is, by kind, the ShowsSince this router last acted on: a later one is a screen made new (restart.go).
// shows is, by kind, the AnswersLostAt this router last acted on in full (restart.go).
shows map[string]time.Time
}
+1 -1
View File
@@ -397,7 +397,7 @@ func listenForChannels(h *Holder) {
h.Failed(kind, f)
}
case asks.StandingWhat:
// Read as this module's Standing: the bench's word, and shows-since beside it (issue 384).
// Read as this module's Standing: the bench's word, and answers-lost-at beside it (issue 384).
var s Standing
if json.Unmarshal(e.Body, &s) == nil {
h.Stand(kind, s)
+58 -58
View File
@@ -1,21 +1,23 @@
package main
// A screen made new (novox/hq issue 384). On 2026-10-10 the desk's notifier started again and the desk showed,
// from its own memory, nine old popups: resolved, silenced and cancelled ones among them. A channel's memory
// is which notification a handle has, good for replacing or editing in place and for nothing else; what is
// still open and unanswered is known here alone. So a channel that lost its screen says so in its standing
// (ShowsSince moved), and this router says again, on that kind only:
// Answers lost on a channel (novox/hq issue 384). On 2026-10-10 the desk's notifier started again and the
// desk showed, from its own memory, nine old popups: resolved, silenced and cancelled ones among them. A
// channel's memory is which notification a handle has, good for replacing or editing in place and for
// nothing else; what is still open and unanswered is known here alone. So a channel whose messages' answers
// were lost — the desk's notifier bundle started again: its notifications stay on screen, their buttons wait
// for nobody — says when in its standing (AnswersLostAt), and this router says again, on that kind only:
//
// - **what is open and asked there**: one message per problem, under its handle, with the words and the
// answers of now — the channel shows it anew where the screen is new and replaces it where it is not;
// - **what is silenced**: its message edited to the silenced words, with no answers and no urgency, where the
// channel still has it (nowhere on a new screen, so nothing appears);
// - **nothing that ended**: a resolved or cancelled message's end was said, or is said, where its message is;
// it never comes back.
// - **what is open and asked there**, and was last said before the answers were lost: one message per
// problem, said again under its handle with the words and the answers of now, so the channel replaces its
// message in place and its buttons work again. Sent to that kind directly — never through the outbox, whose
// failure path moves a warning to the channel that reaches the operator away;
// - **what is silenced**: its message edited to the silenced words, with no answers and no urgency;
// - **nothing that ended**: a resolved or cancelled message's end was said where its message is; it never
// comes back.
//
// Each move of ShowsSince is acted on once; a standing that repeats it says nothing more. A kind that is not
// ready when its screen became new is said again once it is: the move stays pending until then, so a warning
// is not pushed to the channel that reaches the operator away as a failed delivery would be.
// Whether a record is said again is its own: last said before the answers were lost. So a router that started
// after the loss still says it, and a standing that repeats the time says nothing more (the record was said
// since). A kind that cannot send waits: what is open there is said once it can.
import (
"fmt"
@@ -23,53 +25,37 @@ import (
"time"
)
// Stand takes what a kind said of itself, and acts on a screen made new since its last word.
// Stand takes what a kind said of itself, and acts on answers lost there since this router last acted.
func (h *Holder) Stand(kind string, s Standing) {
if h.Bench != nil {
h.Bench.Stand(kind, s)
}
if s.ShowsSince.IsZero() {
if s.AnswersLostAt.IsZero() {
return
}
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
last, known := h.shows[kind]
started := h.started
done := h.shows[kind].Equal(s.AnswersLostAt)
h.mu.Unlock()
if !known {
// The kind's first word since this router started: a screen made new before that is one this router's
// state already allows for (it reads the open conditions and says what was never said); one made new
// since is acted on.
if started.IsZero() {
return
}
last = started
}
if !s.ShowsSince.After(last) {
if !known {
h.mu.Lock()
h.shows[kind] = s.ShowsSince
h.mu.Unlock()
}
if done {
return
}
ch := h.channelOf(kind)
if ch == nil {
h.Logf("[messenger] %s says its screen is new since %s, and no channel of that kind is held here: nothing said again",
kind, s.ShowsSince.UTC().Format(time.RFC3339))
return
return // the controller's word on the kinds is not read yet: acted on at a later standing
}
if err := ch.Ready(); err != nil {
h.Logf("[messenger] %s says its screen is new since %s and cannot send (%v): what is open there is said again once it can",
kind, s.ShowsSince.UTC().Format(time.RFC3339), err)
h.Logf("[messenger] %s lost its answers at %s and cannot send (%v): what is open there is said again once it can",
kind, s.AnswersLostAt.UTC().Format(time.RFC3339), err)
return
}
h.mu.Lock()
h.shows[kind] = s.ShowsSince
h.mu.Unlock()
h.reoffer(ch, s.ShowsSince)
if h.reoffer(ch, s.AnswersLostAt) {
h.mu.Lock()
h.shows[kind] = s.AnswersLostAt
h.mu.Unlock()
}
}
// channelOf is the channel sending to a kind now, or nil.
@@ -82,10 +68,12 @@ func (h *Holder) channelOf(kind string) Channel {
return nil
}
// reoffer says again on one channel, whose screen is new since `since`, what is still open and asked there,
// edits what is silenced to say so, and leaves what ended alone.
func (h *Holder) reoffer(ch Channel, since time.Time) {
// reoffer says again on one channel, whose answers were lost at `lost`, what is still open and asked there,
// edits what is silenced to say so, and leaves what ended alone. It answers whether every message to be
// said again was.
func (h *Holder) reoffer(ch Channel, lost time.Time) bool {
kind := ch.Name()
rp, canReplace := ch.(replacer)
now := h.Now()
h.mu.Lock()
var recs []*Record
@@ -94,19 +82,20 @@ func (h *Holder) reoffer(ch Channel, since time.Time) {
}
h.mu.Unlock()
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
again, silenced, ended := 0, 0, 0
again, silenced, ended, failed := 0, 0, 0, 0
for _, r := range recs {
h.mu.Lock()
id, shown := r.Sent[kind]
open := r.Cleared.IsZero()
before := lastSaid(r).Before(lost)
reminded := r.Reminded
h.mu.Unlock()
if !shown || id == "" {
continue // never a message of its own there: nothing is on record to say again under
continue // never a message of its own there: nothing to say again under
}
switch {
case !open:
ended++ // its end is said where its message is, or was; nothing comes back
ended++ // its end is said where its message is; nothing comes back
case r.silenced(now):
h.mu.Lock()
r.SilenceSaid = true
@@ -114,23 +103,34 @@ func (h *Holder) reoffer(ch Channel, since time.Time) {
h.editOn(ch, r, "silenced")
h.persist(r)
silenced++
case h.askKinds(r)[kind]:
case h.askKinds(r)[kind] && before && canReplace:
what := "raised"
if reminded {
what = "reminder"
}
h.enqueue(kind, item{Key: r.Key, What: what, Severity: r.Severity, At: now})
err := rp.Replace(id, h.sayOn(r, what, kind))
h.noteChannel(kind, err)
if err != nil {
h.Logf("[messenger] could not say %s again on %s (%v): tried again at its next standing", r.Key, kind, err)
failed++
continue
}
h.mu.Lock()
r.LastSaid = now
h.lastSend[kind] = now
h.mu.Unlock()
h.record(Sent{At: now, Channel: kind, Key: r.Key, What: what + ", again: its answers were lost", Outcome: "sent"})
h.persist(r)
again++
}
}
h.record(Sent{At: now, Channel: kind, Key: "(screen)", What: "new since " + since.UTC().Format(time.RFC3339),
Outcome: fmt.Sprintf("%d open and asked said again, %d silenced said so, %d ended left ended", again, silenced, ended)})
h.Logf("[messenger] %s's screen is new since %s: %d open and asked said again, %d silenced said so, %d ended left ended; "+
"nothing shown from the channel's memory", kind, since.UTC().Format(time.RFC3339), again, silenced, ended)
if again > 0 {
// Now, not after the desk's warning rhythm: the operator is at a screen that lost its questions.
h.flushChannel(ch, now, true)
}
h.record(Sent{At: now, Channel: kind, Key: "(answers lost)", What: "at " + lost.UTC().Format(time.RFC3339),
Outcome: fmt.Sprintf("%d open and asked said again, %d silenced said so, %d ended left ended, %d failed",
again, silenced, ended, failed)})
h.Logf("[messenger] %s lost its answers at %s: %d open and asked said again in place, %d silenced said so, %d ended "+
"left ended, %d failed; nothing shown from the channel's memory", kind, lost.UTC().Format(time.RFC3339), again,
silenced, ended, failed)
return failed == 0
}
// editOn edits a record's message on one channel, where it is shown as a message of its own.
+44 -23
View File
@@ -9,13 +9,13 @@ import (
)
// Issue 384: the desk's notifier started again and the desk showed, from its own memory, nine old popups —
// resolved, silenced and cancelled ones among them. Now the desk says in its standing since when its screen
// is new, and this router says again only what is still open and unanswered: with three messages on the desk
// — one open with an answer, one silenced, one resolved — one is said again under its handle, with its
// answers; one is edited to the silenced words, with no answers and no urgency; nothing is said for the
// resolved one. A standing that repeats the time says nothing more; its ticket is one-time still, and the
// channel that reaches the operator away hears nothing of it.
func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
// resolved, silenced and cancelled ones among them. Now the desk says in its standing when the answers were
// lost, and this router says again only what is still open and unanswered: with the desk showing one open
// message with an answer, one silenced, one open asking nothing and one resolved, one is said again under its
// handle (in place of its message), with its answers; one is edited to the silenced words, with no answers
// and no urgency; nothing is said for the others. A standing that repeats the time says nothing more; its
// ticket is one-time still, and the channel that reaches the operator away hears nothing of it.
func TestLostAnswersGetOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
h, tg, dt, c, _, w := newRouter(t)
h.Start()
c.pass(2 * time.Minute)
@@ -80,10 +80,10 @@ func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
silencedBefore := dt.edits["g14=2"]
delete(dt.edits, "g14=2") // so the edit said again is seen as such
sends, edits, replaced, tgSaid := len(dt.sends), len(dt.edits), len(dt.replaced), saidOn(tg)
// The desk's notifier started again: its standing says since when its screen is new.
// The desk's notifier started again: its standing says when the answers were lost.
c.pass(time.Minute)
newSince := c.now()
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: newSince})
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: newSince})
runFor(h, c, 2*time.Minute)
if len(dt.sends) != sends {
t.Fatalf("a new message was sent instead of one under its handle: %+v", dt.sends[sends:])
@@ -109,8 +109,8 @@ func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
t.Fatalf("the channel that reaches the operator away heard of the desk's screen: %+v %+v", tg.sends, tg.edits)
}
// The standing said again with the same time, and one with an older time: nothing more.
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: newSince})
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: newSince.Add(-time.Hour)})
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: newSince})
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: newSince.Add(-time.Hour)})
runFor(h, c, 2*time.Minute)
if len(dt.replaced) != replaced+1 || len(dt.sends) != sends {
t.Fatalf("said again more than once: replaced %d, sends %d", len(dt.replaced)-replaced, len(dt.sends)-sends)
@@ -140,10 +140,11 @@ func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
}
}
// A screen made new while the desk cannot send is said again once it can — and not pushed to the channel that
// reaches the operator away. A desk that never said its screen is new (or Telegram, whose messages stay in the
// chat) gets nothing said again; a screen made new before this router started is in the state it read.
func TestAScreenMadeNewWaitsForTheDeskAndIsNeverGuessed(t *testing.T) {
// Whether a message is said again is its own: last said before the answers were lost. A loss before it was
// last said says nothing; a router that started again (it remembers no standing it acted on) says it again
// once, and then nothing more. A loss while the desk cannot send is said once it can — never pushed to the
// channel that reaches the operator away. Telegram, whose answers are never lost, says no time and gets nothing.
func TestLostAnswersAreJudgedPerMessageAndWaitForTheDesk(t *testing.T) {
h, tg, dt, c, _, _ := newRouter(t)
h.Start()
c.pass(2 * time.Minute)
@@ -156,27 +157,47 @@ func TestAScreenMadeNewWaitsForTheDeskAndIsNeverGuessed(t *testing.T) {
submit(t, h, ConditionOwner, ask)
runFor(h, c, time.Minute) // the question reaches Telegram too, as its own message
replaced, tgSaid := len(dt.replaced), saidOn(tg)
// Before this router started: already allowed for.
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: h.started.Add(-time.Minute)})
// Telegram says no screen: nothing.
// Lost before the message was last said: its answers are those of the notifier that runs now.
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: open.Raised.Add(-time.Minute)})
h.Stand("telegram", Standing{Ready: true, At: c.now()})
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced || saidOn(tg) != tgSaid {
t.Fatalf("said again without a screen made new: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
t.Fatalf("said again for answers that were not lost: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
// Made new while the desk cannot send: waits, and goes to the desk once it can — never to Telegram.
// Lost while the desk cannot send: waits, and goes to the desk once it can — never to Telegram.
c.pass(time.Minute)
since := c.now()
lost := c.now()
dt.notReady = errorf("notifications paused")
h.Stand("desktop", Standing{Ready: false, Why: "notifications paused", At: c.now(), ShowsSince: since})
h.Stand("desktop", Standing{Ready: false, Why: "notifications paused", At: c.now(), AnswersLostAt: lost})
runFor(h, c, 2*time.Minute)
if len(dt.replaced) != replaced || saidOn(tg) != tgSaid {
t.Fatalf("said while the desk cannot send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
dt.notReady = nil
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: since})
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced+1 || saidOn(tg) != tgSaid {
t.Fatalf("once the desk can send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
// The router started again, remembering no standing it acted on: the message was said since, so nothing.
h.shows = map[string]time.Time{}
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced+1 {
t.Fatalf("said again twice for one loss: %+v", dt.replaced[replaced:])
}
// A send that fails is tried again at the next standing, on the desk alone.
c.pass(time.Minute)
lost = c.now()
dt.fail = errorf("no session answers")
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
dt.fail = nil
runFor(h, c, time.Minute)
if len(dt.replaced) != replaced+1 || saidOn(tg) != tgSaid {
t.Fatalf("after a failed send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
h.Stand("desktop", Standing{Ready: true, At: c.now(), AnswersLostAt: lost})
if len(dt.replaced) != replaced+2 || saidOn(tg) != tgSaid {
t.Fatalf("the failed send was not tried again on the desk: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
}
}
@@ -45,11 +45,11 @@ type Standing struct {
Why string `json:"why,omitempty"`
EditsSilently bool `json:"edits-silently,omitempty"`
At time.Time `json:"at"`
// ShowsSince is since when what the channel shows is on a screen: it moves when the screen became new — the
// desk's notifier started again and lost what it showed (issue 384). The router then says again what is
// still open and unanswered there (restart.go); the channel shows nothing from its own memory. Zero from a
// channel whose messages stay where they were put (Telegram's chat).
ShowsSince time.Time `json:"shows-since,omitempty"`
// AnswersLostAt is when the answers the channel's messages waited for were last lost: the desk's notifier
// started again, its notifications still on screen and their buttons dead (issue 384). The router then says
// again what is still open and unanswered there (restart.go); the channel shows nothing from its own memory.
// Zero from a channel whose answers are never lost (Telegram's buttons live in the chat).
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Bench is every kind the router may send to, and what each said.