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
+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.