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