messenger: end a replaced ask's own message, and drop what a cleared record owed (issue 548)
mesh/merge-gate pass: builds 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

A replaced ask's message was left open as if the newer ask took it over, but
the newer ask opens a message of its own, so the old one kept offering answers
nobody takes. It now ends on every channel with 'Replaced by a newer question',
and the minute's sweep ends any such message left from before. A record that
clears no longer keeps what a walk owed of it (the review of PR 238).
This commit is contained in:
jochen
2026-10-12 13:44:08 +02:00
parent 3fe19c9c0e
commit 7e27259a64
4 changed files with 150 additions and 5 deletions
+6 -2
View File
@@ -148,7 +148,10 @@ new message is shown, so it rings once and no second message stands beside the f
notification by its id; Telegram deletes the earlier message). A message said again goes alone, never as a
line of a digest beside the message it follows; one first said inside a digest gets a message of its own the
next time, and later words replace or edit that one. An ask's buttons live on that one message, so an ask that
ends takes them away everywhere; its expiry is said with its day when it is not today.
ends takes them away everywhere; its expiry is said with its day when it is not today. An ask replaced by a
newer one about the same thing ends its own message too, which then says "Replaced by a newer question"; the
newer ask shows a message of its own (issue 548). A message left open by an ask that already ended is ended on
the next minute's sweep.
- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to
the channel that reaches the operator away; an ask to every kind that carries one of its answers.
@@ -187,7 +190,8 @@ The controller's `plan-moved` says each walk's start and end: `walk`, `phase` (`
open, one line each; the ones that cleared counted. Delivered with nothing held and nothing open: one line —
and nothing on a kind listed in the setting `only-what-needs-me`. What it carries is marked told on a kind
only once the message was sent there. Until then each is kept owed in the state (`owed`): a message lost with a
restart before it was sent leaves them untold and owed, and the restarted router says each on its own.
restart before it was sent leaves them untold and owed, and the restarted router says each on its own. A
record that clears owes nothing more.
- **The bound:** at its start plus `bound_seconds` (900 when not given), what is held and still open is said,
with "The walk … is still running, since HH:MM", and the cleared counted; the walk holds nothing more. A walk
whose end never comes is forgotten a day after it started. A start heard after its end opens nothing: the
+32 -3
View File
@@ -691,30 +691,58 @@ func (h *Holder) showClosed(rec AskRecord) {
}
told := h.toldLine(rec) // read before the lock: it reads the ask's record
h.mu.Lock()
ownMessage := r.Origin == "ask"
// The first line is how it ended, which an ask's own message shows as its title (sayOn); the rest follows.
switch {
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
r.Answered = chosenLine(rec.Warrant, rec.Closed.In(h.zone())) + "\n" + told
case rec.State == string(asks.OutcomeExpired):
r.Answered = "No longer answerable: nobody answered in time.\n" + strings.TrimSuffix(capital(rec.Ask.OnExpiry), ".") + "."
case rec.State == string(asks.OutcomeReplaced):
case rec.State == string(asks.OutcomeReplaced) && !ownMessage:
// On a condition's message, which the newer ask takes over and shows in place.
r.Answered = ""
case rec.State == string(asks.OutcomeReplaced):
// Its own message: the newer ask looks for its message under its own key and opens one of its own, so
// this one ends here, on every channel that showed it (issue 548).
r.Answered = "Replaced by a newer question.\n" + rec.Asker + " asked again about the same thing; that question is the one to answer."
default:
r.Answered = "No longer needed."
}
ownMessage := r.Origin == "ask"
h.mu.Unlock()
if r.told() {
h.edit(r, "answered", false)
}
if ownMessage && rec.State != string(asks.OutcomeReplaced) {
if ownMessage {
h.mu.Lock()
r.Cleared = h.Now()
r.Owed = "" // an ended question is owed to nobody (the review of PR 238)
h.mu.Unlock()
}
h.persist(r)
}
// endLeftovers ends the own message of an ask that already ended and whose message stayed open: one left by a
// router before issue 548 was fixed, whose replaced asks kept their messages for good. Its copies say how the
// ask ended, as its end would have.
func (h *Holder) endLeftovers() {
h.mu.Lock()
var ended []AskRecord
for _, r := range h.open {
if r.Origin != "ask" || r.Ask == "" || !r.Cleared.IsZero() {
continue
}
if e := h.asks[r.Ask]; e != nil && e.rec.State != StateOpen && e.rec.Record == r.Key {
ended = append(ended, e.rec)
}
}
h.mu.Unlock()
sort.Slice(ended, func(i, j int) bool { return ended[i].Record < ended[j].Record })
for _, rec := range ended {
h.Logf("[messenger] the message of %s stayed open after its ask ended (%s): ended now", rec.Record, rec.State)
h.showClosed(rec)
}
}
// chosenLine is how a chosen ask ended, in the operator's words: "You chose Approve at 13:30 on Telegram."
func chosenLine(w *asks.Warrant, at time.Time) string {
who, where := "Somebody", ""
@@ -775,6 +803,7 @@ func (h *Holder) sweepAsks(now time.Time) {
}
}
h.mu.Unlock()
h.endLeftovers()
sort.Strings(expired)
for _, k := range expired {
e := h.askOf(k)
@@ -687,6 +687,7 @@ func (h *Holder) cleared(key string, quiet bool) {
}
old.Cleared = now
old.Pending = ""
old.Owed = "" // what a walk held of it is owed no more: it ended (the review of PR 238)
old.Folded = false
old.Seen = now
told := old.told()
@@ -0,0 +1,111 @@
package main
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// openAskRecords are the records still open that show an ask as their own message.
func openAskRecords(h *Holder) []string {
h.mu.Lock()
defer h.mu.Unlock()
var out []string
for k, r := range h.open {
if r.Origin == "ask" && r.Cleared.IsZero() {
out = append(out, k)
}
}
return out
}
// Issue 548: an ask replaced by a newer one about the same thing kept its own message open for good, its
// buttons on the phone answering nothing. Two asks with the same About from one asker leave exactly one open
// record, and the older one's message says it was replaced, its buttons gone, on every channel that showed it.
func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := ackAsk(c, "d-1")
a.About = "delivery.hq"
submit(t, h, "mesh-delivery", a)
settle(h, c)
if len(tg.sends) != 1 || len(dt.sends) != 1 {
t.Fatalf("the first ask was not shown: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
}
b := ackAsk(c, "d-2")
b.About = "delivery.hq"
b.Headline = "Delivery of hq held, asked again"
submit(t, h, "mesh-delivery", b)
settle(h, c)
if got := openAskRecords(h); len(got) != 1 || got[0] != "ask.mesh-delivery.d-2" {
t.Fatalf("open ask records: %v; want only the newer ask's", got)
}
for _, ch := range []*fakeChannel{tg, dt} {
id := map[string]string{"telegram": "1", "desktop": "g14=1"}[ch.name]
m, edited := ch.edits[id]
for _, r := range ch.replaced {
if r.id == id {
m, edited = r.m, true
}
}
if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
}
}
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
}
}
// The leftover of issue 548: a record from before the fix whose ask already ended keeps its message open. The
// next tick ends it, as the ask's end would have.
func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := ackAsk(c, "d-1")
a.About = "delivery.hq"
submit(t, h, "mesh-delivery", a)
settle(h, c)
// Ended as replaced the way the router did before the fix: the ask's record only, its message untouched.
key := "mesh-delivery.d-1"
rec := store.asks[key]
rec.State, rec.Closed, rec.Told = string(asks.OutcomeReplaced), c.now(), true
rec.Warrant = &asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", About: a.About, Outcome: asks.OutcomeReplaced,
Words: "a newer ask about the same thing replaced it", At: c.now()}
store.asks[key] = rec
h.keepAsk(key, rec, 99)
if got := openAskRecords(h); len(got) != 1 {
t.Fatalf("the leftover is not set up: %v", got)
}
c.pass(time.Minute)
stand(h, c)
h.Tick()
h.Flush()
if got := openAskRecords(h); len(got) != 0 {
t.Fatalf("the message of an ended ask is still open: %v", got)
}
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
}
}
// The review of PR 238: what a walk owed of a record is dropped when the record clears, so a router that
// restarts never says a held message of something that already ended.
func TestAClearedRecordOwesNothing(t *testing.T) {
h, _, _, c, st := newHolder(t)
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
k.Raised = c.now()
h.Condition(EventRaised, k)
h.mu.Lock()
h.open[k.Key].Owed = "raised"
h.mu.Unlock()
h.cleared(k.Key, false)
if r := h.open[k.Key]; r.Owed != "" {
t.Errorf("a cleared record still owes %q", r.Owed)
}
if r := st.recs[k.Key]; r.Owed != "" {
t.Errorf("a cleared record still owes %q in the state", r.Owed)
}
}