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
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user