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:
@@ -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