A channel shows nothing from its memory after a restart; the router says again what is still open (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 superseded: a newer head of the same pull request
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 superseded: a newer head of the same pull request
On 2026-10-10 the desk's notifier started again on two machines and the desk showed, from its own memory, nine old popups: resolved, silenced and cancelled ones among them. Its memory held every handle ever shown with buttons, and the router's edits (silenced, resolved) had reached only the newest handle of each problem, so the rest stood as if open. A channel's memory serves only to replace or edit a message in place. A notifier that started again lost 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 on that kind: one message per problem under its handle with the words and answers of now, a silenced one edited to say so with no answers and no urgency, nothing that ended. Each move of shows-since is acted on once, and waits while the desk cannot send rather than going to Telegram. Telegram re-shows nothing on a restart (its messages stay in the chat) and says no shows-since. 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.
This commit is contained in:
@@ -327,6 +327,8 @@ type Holder struct {
|
||||
lastUnlink map[string]Unlinked
|
||||
// rootWord is the controller's last word on whether an agent can become root, by the machines asked.
|
||||
rootWord map[string]rootWord
|
||||
// shows is, by kind, the ShowsSince this router last acted on: a later one is a screen made new (restart.go).
|
||||
shows map[string]time.Time
|
||||
}
|
||||
|
||||
func (h *Holder) init() {
|
||||
@@ -342,6 +344,7 @@ func (h *Holder) init() {
|
||||
h.chanOK = map[string]time.Time{}
|
||||
h.saidOnce = map[string]time.Time{}
|
||||
h.heard = map[string]int{}
|
||||
h.shows = map[string]time.Time{}
|
||||
}
|
||||
if h.Now == nil {
|
||||
h.Now = time.Now
|
||||
|
||||
@@ -397,9 +397,10 @@ func listenForChannels(h *Holder) {
|
||||
h.Failed(kind, f)
|
||||
}
|
||||
case asks.StandingWhat:
|
||||
var s asks.Standing
|
||||
// Read as this module's Standing: the bench's word, and shows-since beside it (issue 384).
|
||||
var s Standing
|
||||
if json.Unmarshal(e.Body, &s) == nil {
|
||||
h.Bench.Stand(kind, Standing{Ready: s.Ready, Why: s.Why, EditsSilently: s.EditsSilently, At: s.At})
|
||||
h.Stand(kind, s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
// A screen made new (novox/hq issue 384). On 2026-10-10 the desk's notifier started again and the desk showed,
|
||||
// from its own memory, nine old popups: resolved, silenced and cancelled ones among them. A channel's memory
|
||||
// is which notification a handle has, good for replacing or editing in place and for nothing else; what is
|
||||
// still open and unanswered is known here alone. So a channel that lost its screen says so in its standing
|
||||
// (ShowsSince moved), and this router says again, on that kind only:
|
||||
//
|
||||
// - **what is open and asked there**: one message per problem, under its handle, with the words and the
|
||||
// answers of now — the channel shows it anew where the screen is new and replaces it where it is not;
|
||||
// - **what is silenced**: its message edited to the silenced words, with no answers and no urgency, where the
|
||||
// channel still has it (nowhere on a new screen, so nothing appears);
|
||||
// - **nothing that ended**: a resolved or cancelled message's end was said, or is said, where its message is;
|
||||
// it never comes back.
|
||||
//
|
||||
// Each move of ShowsSince is acted on once; a standing that repeats it says nothing more. A kind that is not
|
||||
// ready when its screen became new is said again once it is: the move stays pending until then, so a warning
|
||||
// is not pushed to the channel that reaches the operator away as a failed delivery would be.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stand takes what a kind said of itself, and acts on a screen made new since its last word.
|
||||
func (h *Holder) Stand(kind string, s Standing) {
|
||||
if h.Bench != nil {
|
||||
h.Bench.Stand(kind, s)
|
||||
}
|
||||
if s.ShowsSince.IsZero() {
|
||||
return
|
||||
}
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
last, known := h.shows[kind]
|
||||
started := h.started
|
||||
h.mu.Unlock()
|
||||
if !known {
|
||||
// The kind's first word since this router started: a screen made new before that is one this router's
|
||||
// state already allows for (it reads the open conditions and says what was never said); one made new
|
||||
// since is acted on.
|
||||
if started.IsZero() {
|
||||
return
|
||||
}
|
||||
last = started
|
||||
}
|
||||
if !s.ShowsSince.After(last) {
|
||||
if !known {
|
||||
h.mu.Lock()
|
||||
h.shows[kind] = s.ShowsSince
|
||||
h.mu.Unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
ch := h.channelOf(kind)
|
||||
if ch == nil {
|
||||
h.Logf("[messenger] %s says its screen is new since %s, and no channel of that kind is held here: nothing said again",
|
||||
kind, s.ShowsSince.UTC().Format(time.RFC3339))
|
||||
return
|
||||
}
|
||||
if err := ch.Ready(); err != nil {
|
||||
h.Logf("[messenger] %s says its screen is new since %s and cannot send (%v): what is open there is said again once it can",
|
||||
kind, s.ShowsSince.UTC().Format(time.RFC3339), err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.shows[kind] = s.ShowsSince
|
||||
h.mu.Unlock()
|
||||
h.reoffer(ch, s.ShowsSince)
|
||||
}
|
||||
|
||||
// channelOf is the channel sending to a kind now, or nil.
|
||||
func (h *Holder) channelOf(kind string) Channel {
|
||||
for _, ch := range h.channels() {
|
||||
if ch.Name() == kind {
|
||||
return ch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reoffer says again on one channel, whose screen is new since `since`, what is still open and asked there,
|
||||
// edits what is silenced to say so, and leaves what ended alone.
|
||||
func (h *Holder) reoffer(ch Channel, since time.Time) {
|
||||
kind := ch.Name()
|
||||
now := h.Now()
|
||||
h.mu.Lock()
|
||||
var recs []*Record
|
||||
for _, r := range h.open {
|
||||
recs = append(recs, r)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
|
||||
again, silenced, ended := 0, 0, 0
|
||||
for _, r := range recs {
|
||||
h.mu.Lock()
|
||||
id, shown := r.Sent[kind]
|
||||
open := r.Cleared.IsZero()
|
||||
reminded := r.Reminded
|
||||
h.mu.Unlock()
|
||||
if !shown || id == "" {
|
||||
continue // never a message of its own there: nothing is on record to say again under
|
||||
}
|
||||
switch {
|
||||
case !open:
|
||||
ended++ // its end is said where its message is, or was; nothing comes back
|
||||
case r.silenced(now):
|
||||
h.mu.Lock()
|
||||
r.SilenceSaid = true
|
||||
h.mu.Unlock()
|
||||
h.editOn(ch, r, "silenced")
|
||||
h.persist(r)
|
||||
silenced++
|
||||
case h.askKinds(r)[kind]:
|
||||
what := "raised"
|
||||
if reminded {
|
||||
what = "reminder"
|
||||
}
|
||||
h.enqueue(kind, item{Key: r.Key, What: what, Severity: r.Severity, At: now})
|
||||
again++
|
||||
}
|
||||
}
|
||||
h.record(Sent{At: now, Channel: kind, Key: "(screen)", What: "new since " + since.UTC().Format(time.RFC3339),
|
||||
Outcome: fmt.Sprintf("%d open and asked said again, %d silenced said so, %d ended left ended", again, silenced, ended)})
|
||||
h.Logf("[messenger] %s's screen is new since %s: %d open and asked said again, %d silenced said so, %d ended left ended; "+
|
||||
"nothing shown from the channel's memory", kind, since.UTC().Format(time.RFC3339), again, silenced, ended)
|
||||
if again > 0 {
|
||||
// Now, not after the desk's warning rhythm: the operator is at a screen that lost its questions.
|
||||
h.flushChannel(ch, now, true)
|
||||
}
|
||||
}
|
||||
|
||||
// editOn edits a record's message on one channel, where it is shown as a message of its own.
|
||||
func (h *Holder) editOn(ch Channel, r *Record, what string) {
|
||||
kind := ch.Name()
|
||||
h.mu.Lock()
|
||||
id := r.Sent[kind]
|
||||
h.mu.Unlock()
|
||||
if id == "" || !ch.CanEdit() {
|
||||
return
|
||||
}
|
||||
err := ch.Edit(id, h.sayOn(r, what, kind))
|
||||
h.noteChannel(kind, err)
|
||||
if err != nil {
|
||||
h.Logf("[messenger] could not edit the message for %s on %s (%v)", r.Key, kind, err)
|
||||
return
|
||||
}
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: r.Key, What: what, Outcome: "edited"})
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Issue 384: the desk's notifier started again and the desk showed, from its own memory, nine old popups —
|
||||
// resolved, silenced and cancelled ones among them. Now the desk says in its standing since when its screen
|
||||
// is new, and this router says again only what is still open and unanswered: with three messages on the desk
|
||||
// — one open with an answer, one silenced, one resolved — one is said again under its handle, with its
|
||||
// answers; one is edited to the silenced words, with no answers and no urgency; nothing is said for the
|
||||
// resolved one. A standing that repeats the time says nothing more; its ticket is one-time still, and the
|
||||
// channel that reaches the operator away hears nothing of it.
|
||||
func TestAScreenMadeNewGetsOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
|
||||
h, tg, dt, c, _, w := newRouter(t)
|
||||
h.Start()
|
||||
c.pass(2 * time.Minute)
|
||||
// Open, with a question the desk carries.
|
||||
open := cond("machine.shanks.units", Warning, "2 failed services on shanks")
|
||||
open.Raised = c.now()
|
||||
h.Condition(EventRaised, open)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 {
|
||||
t.Fatalf("desk %d", len(dt.sends))
|
||||
}
|
||||
ask := ackAsk(c, "silence-units")
|
||||
ask.About = open.Key
|
||||
submit(t, h, ConditionOwner, ask)
|
||||
if e := dt.edits["g14=1"]; ticketFor(e, "Silence for a week") == "" {
|
||||
t.Fatalf("the question was not put on the open message: %+v", e)
|
||||
}
|
||||
// Silenced, with a question of its own, and its message says it is silenced.
|
||||
runFor(h, c, 20*time.Minute)
|
||||
sil := cond("machine.ace.agent-home", Warning, "Agent-home shrank")
|
||||
sil.Raised = c.now()
|
||||
h.Condition(EventRaised, sil)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 2 {
|
||||
t.Fatalf("desk %d", len(dt.sends))
|
||||
}
|
||||
stand(h, c) // the channels' word, fresh again after the minutes passed
|
||||
silAsk := ackAsk(c, "silence-home")
|
||||
silAsk.About = sil.Key
|
||||
submit(t, h, ConditionOwner, silAsk)
|
||||
if e := dt.edits["g14=2"]; ticketFor(e, "Silence for a week") == "" {
|
||||
t.Fatalf("the question was not put on the message to be silenced: %+v", e)
|
||||
}
|
||||
sil.SilencedTill, sil.SilencedWhy, sil.At = c.now().Add(7*24*time.Hour), "known", c.now()
|
||||
h.Condition(EventChanged, sil)
|
||||
if e := dt.edits["g14=2"]; !strings.HasPrefix(e.Title, "Silenced until") {
|
||||
t.Fatalf("the silence was not said: %+v", e)
|
||||
}
|
||||
// Open, told, and asking nothing: not unanswered, so not said again.
|
||||
runFor(h, c, 20*time.Minute)
|
||||
info := cond("seat.mesh-controller.assign-by-hand", Warning, "Assign keeps being fixed by hand")
|
||||
info.Raised = c.now()
|
||||
h.Condition(EventRaised, info)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 3 {
|
||||
t.Fatalf("desk %d", len(dt.sends))
|
||||
}
|
||||
// Resolved, and its clearing said.
|
||||
runFor(h, c, 20*time.Minute)
|
||||
res := cond("seat.operator-channel.asks-undelivered", Warning, "Questions for you not delivered")
|
||||
res.Raised = c.now()
|
||||
h.Condition(EventRaised, res)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 4 {
|
||||
t.Fatalf("desk %d", len(dt.sends))
|
||||
}
|
||||
h.Condition(EventCleared, Condition{Key: res.Key, At: c.now()})
|
||||
runFor(h, c, 3*time.Minute)
|
||||
if e := dt.edits["g14=4"]; !strings.HasPrefix(e.Title, "Resolved: ") {
|
||||
t.Fatalf("the clearing was not said: %+v", e)
|
||||
}
|
||||
silencedBefore := dt.edits["g14=2"]
|
||||
delete(dt.edits, "g14=2") // so the edit said again is seen as such
|
||||
sends, edits, replaced, tgSaid := len(dt.sends), len(dt.edits), len(dt.replaced), saidOn(tg)
|
||||
// The desk's notifier started again: its standing says since when its screen is new.
|
||||
c.pass(time.Minute)
|
||||
newSince := c.now()
|
||||
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: newSince})
|
||||
runFor(h, c, 2*time.Minute)
|
||||
if len(dt.sends) != sends {
|
||||
t.Fatalf("a new message was sent instead of one under its handle: %+v", dt.sends[sends:])
|
||||
}
|
||||
if len(dt.replaced) != replaced+1 || dt.replaced[replaced].id != "g14=1" {
|
||||
t.Fatalf("the open question was not said again under its handle, once and alone: %+v", dt.replaced[replaced:])
|
||||
}
|
||||
again := dt.replaced[replaced].m
|
||||
if ticketFor(again, "Silence for a week") == "" || !strings.Contains(again.Title, headlineOf(open.Key)) {
|
||||
t.Fatalf("said again without its answer or its words: %+v", again)
|
||||
}
|
||||
if e, said := dt.edits["g14=2"]; !said || !strings.HasPrefix(e.Title, "Silenced until") || len(e.Actions) != 0 || e.Urgent ||
|
||||
!e.Quiet || e.Title != silencedBefore.Title {
|
||||
t.Fatalf("the silenced one was not edited to the silenced words again: %+v (said %v)", e, said)
|
||||
}
|
||||
if len(dt.edits) != edits+1 {
|
||||
t.Fatalf("an edit reached a message that is not silenced: %+v", dt.edits)
|
||||
}
|
||||
if e := dt.edits["g14=4"]; !strings.HasPrefix(e.Title, "Resolved: ") {
|
||||
t.Fatalf("the resolved one was changed: %+v", e)
|
||||
}
|
||||
if saidOn(tg) != tgSaid {
|
||||
t.Fatalf("the channel that reaches the operator away heard of the desk's screen: %+v %+v", tg.sends, tg.edits)
|
||||
}
|
||||
// The standing said again with the same time, and one with an older time: nothing more.
|
||||
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: newSince})
|
||||
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: newSince.Add(-time.Hour)})
|
||||
runFor(h, c, 2*time.Minute)
|
||||
if len(dt.replaced) != replaced+1 || len(dt.sends) != sends {
|
||||
t.Fatalf("said again more than once: replaced %d, sends %d", len(dt.replaced)-replaced, len(dt.sends)-sends)
|
||||
}
|
||||
// Its ticket is one-time still: one warrant, however often it is tapped, and refused from another machine.
|
||||
tk := ticketFor(again, "Silence for a week")
|
||||
ch := choiceOf(tk, "g14", c.now())
|
||||
ch.Sender.Authenticated = false
|
||||
if got, err := h.Chosen("desktop", ch); got != "chosen" || err != nil {
|
||||
t.Fatalf("the answer on the message said again: %s, %v", got, err)
|
||||
}
|
||||
ch.ID = "c-second"
|
||||
_, _ = h.Chosen("desktop", ch) // the same tap again: said as recorded (issue 372), nothing more done
|
||||
other := choiceOf(tk, "shanks", c.now())
|
||||
other.Sender.Authenticated = false
|
||||
if got, _ := h.Chosen("desktop", other); got != "refused" {
|
||||
t.Fatalf("the ticket taken from another machine after it was used: %s", got)
|
||||
}
|
||||
chosen := 0
|
||||
for _, wt := range w.of(ConditionOwner) {
|
||||
if wt.Outcome == asks.OutcomeChosen {
|
||||
chosen++
|
||||
}
|
||||
}
|
||||
if chosen != 1 {
|
||||
t.Fatalf("warrants for one ticket: %d", chosen)
|
||||
}
|
||||
}
|
||||
|
||||
// A screen made new while the desk cannot send is said again once it can — and not pushed to the channel that
|
||||
// reaches the operator away. A desk that never said its screen is new (or Telegram, whose messages stay in the
|
||||
// chat) gets nothing said again; a screen made new before this router started is in the state it read.
|
||||
func TestAScreenMadeNewWaitsForTheDeskAndIsNeverGuessed(t *testing.T) {
|
||||
h, tg, dt, c, _, _ := newRouter(t)
|
||||
h.Start()
|
||||
c.pass(2 * time.Minute)
|
||||
open := cond("machine.shanks.units", Warning, "2 failed services on shanks")
|
||||
open.Raised = c.now()
|
||||
h.Condition(EventRaised, open)
|
||||
settle(h, c)
|
||||
ask := ackAsk(c, "silence-units")
|
||||
ask.About = open.Key
|
||||
submit(t, h, ConditionOwner, ask)
|
||||
runFor(h, c, time.Minute) // the question reaches Telegram too, as its own message
|
||||
replaced, tgSaid := len(dt.replaced), saidOn(tg)
|
||||
// Before this router started: already allowed for.
|
||||
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: h.started.Add(-time.Minute)})
|
||||
// Telegram says no screen: nothing.
|
||||
h.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
runFor(h, c, time.Minute)
|
||||
if len(dt.replaced) != replaced || saidOn(tg) != tgSaid {
|
||||
t.Fatalf("said again without a screen made new: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
|
||||
}
|
||||
// Made new while the desk cannot send: waits, and goes to the desk once it can — never to Telegram.
|
||||
c.pass(time.Minute)
|
||||
since := c.now()
|
||||
dt.notReady = errorf("notifications paused")
|
||||
h.Stand("desktop", Standing{Ready: false, Why: "notifications paused", At: c.now(), ShowsSince: since})
|
||||
runFor(h, c, 2*time.Minute)
|
||||
if len(dt.replaced) != replaced || saidOn(tg) != tgSaid {
|
||||
t.Fatalf("said while the desk cannot send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
|
||||
}
|
||||
dt.notReady = nil
|
||||
h.Stand("desktop", Standing{Ready: true, At: c.now(), ShowsSince: since})
|
||||
runFor(h, c, time.Minute)
|
||||
if len(dt.replaced) != replaced+1 || saidOn(tg) != tgSaid {
|
||||
t.Fatalf("once the desk can send: desk %+v, telegram %+v", dt.replaced[replaced:], tg.sends)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,11 @@ type Standing struct {
|
||||
Why string `json:"why,omitempty"`
|
||||
EditsSilently bool `json:"edits-silently,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
// ShowsSince is since when what the channel shows is on a screen: it moves when the screen became new — the
|
||||
// desk's notifier started again and lost what it showed (issue 384). The router then says again what is
|
||||
// still open and unanswered there (restart.go); the channel shows nothing from its own memory. Zero from a
|
||||
// channel whose messages stay where they were put (Telegram's chat).
|
||||
ShowsSince time.Time `json:"shows-since,omitempty"`
|
||||
}
|
||||
|
||||
// Bench is every kind the router may send to, and what each said.
|
||||
|
||||
Reference in New Issue
Block a user