S1-S3: the node-engine seat's client asks a node once more when an ask came while one ran, and shows when the event was heard (issue 1966 note 170)
mesh/delivery merged: source.main moved to novox/mesh-contracts@f5d76e84ca71
mesh/delivery-group group fix/1966-engine-reads-asked-again merged: every member is merged
mesh/check checking c9ffed70421a on f5d76e84ca71 | novox/mesh-contracts#4 in group fix/1966-engine-reads-asked-again

An event of manifest-reads-changed heard while an ask of the same node ran was dropped
without a line, so a restated engine could stay read at its first statement (ace,
2026-11-10 07:36). Every reader goes through Keeper.AskNode, so the assigner, the
builder and the indexer are fixed at once (ADR 0668 §4: the caller hears it and calls
again; ADR 0634 §1: the seat's client).

- AskNode: an ask while one runs is asked once more after it, one follow-up however
  many came, said in one line (1966/AC170-1, AC170-3). The asks of one node so run one
  after another, and take keeps the last answer, as before (no rule on the engine's
  clock: review round 1).
- Statement.EventAt (event-at): when the node's event was last heard, read by the
  parts' engine-reads tools; forgotten with the node's key.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-10 11:42:05 +01:00
parent 7fbfd77a76
commit c9ffed7042
2 changed files with 90 additions and 6 deletions
@@ -140,6 +140,9 @@ type Statement struct {
// UnansweredSince is when the engine first did not answer, of the asks since its last answer; zero once answered
// (ADR 0668 §3: a kept call unanswered past five minutes is a finding of its caller).
UnansweredSince time.Time `json:"unanswered-since,omitzero"`
// EventAt is when the part last heard the seat's event manifest-reads-changed for the node, beside when it asked and
// what the engine stated then (issue 1966 note 170: was the event heard, and was it asked again after it).
EventAt time.Time `json:"event-at,omitzero"`
}
// Again is a person's ask again of one node, written by the tool and answered by the part that keeps the state.
@@ -243,6 +246,11 @@ type Keeper struct {
mu sync.Mutex
by map[string]Statement
asking map[string]bool
// again is, per node an ask of which runs, the newest why of an ask that came meanwhile: it is asked once more when
// the running ask ends, never dropped (issue 1966 note 170).
again map[string]string
// eventAt is, per node, when its event manifest-reads-changed was last heard (Statement.EventAt).
eventAt map[string]time.Time
}
func (k *Keeper) logf(f string, a ...any) {
@@ -258,14 +266,25 @@ func (k *Keeper) now() time.Time {
return time.Now()
}
// AskNode asks node's engine, unless an ask of it runs already; why is said in the log.
// AskNode asks node's engine; why is said in the log. When an ask of it runs already, the node is asked once more after
// that ask ends, one follow-up however many asks came meanwhile, so an event heard while an ask runs is never lost (issue
// 1966 note 170; ADR 0668 §4: the caller hears it and calls again). A why that starts with Event is the seat's event,
// whose time the node's statement shows as event-at.
func (k *Keeper) AskNode(node, why string) {
k.mu.Lock()
if k.asking == nil {
k.asking = map[string]bool{}
k.asking, k.again, k.eventAt = map[string]bool{}, map[string]string{}, map[string]time.Time{}
}
if strings.HasPrefix(why, Event) {
k.eventAt[node] = k.now().UTC()
}
if k.asking[node] {
_, waits := k.again[node]
k.again[node] = why
k.mu.Unlock()
if !waits {
k.logf("%s on %s: an ask runs, so it is asked once more after it (%s)", Verb, node, why)
}
return
}
k.asking[node] = true
@@ -293,12 +312,25 @@ func (k *Keeper) AtBind(nodes func() []string) func(created time.Time) {
}
}
// ask asks node, and once more for as long as an ask came while it ran (AskNode); only then is the node no longer asking.
func (k *Keeper) ask(node, why string) {
defer func() {
for {
k.askOnce(node, why)
k.mu.Lock()
delete(k.asking, node)
next, again := k.again[node]
delete(k.again, node)
if !again {
delete(k.asking, node)
}
k.mu.Unlock()
}()
if !again {
return
}
why = "asked again: " + next
}
}
func (k *Keeper) askOnce(node, why string) {
if k.Call != nil && k.Once != nil {
first := Ask(k.Once, node, k.now())
if first.Answered {
@@ -357,12 +389,19 @@ func (k *Keeper) Asking(node string) bool {
}
// take keeps s, unless the node did not answer and an earlier statement stands: an away node keeps its last one (ADR 0624
// §1), marked as not answering.
// §1), marked as not answering. The asks of one node run one after another (AskNode), so the last answer is the newest.
// The statement shows when the node's event was last heard.
func (k *Keeper) take(s Statement) {
k.mu.Lock()
if k.by == nil {
k.by = map[string]Statement{}
}
if old, ok := k.by[s.Node]; ok && old.EventAt.After(s.EventAt) {
s.EventAt = old.EventAt
}
if at := k.eventAt[s.Node]; at.After(s.EventAt) {
s.EventAt = at
}
if !s.Answered {
s.UnansweredSince = s.AskedAt
if old, ok := k.by[s.Node]; ok && !old.Answered && !old.UnansweredSince.IsZero() {
@@ -396,6 +435,7 @@ func (k *Keeper) Heard(node string, raw []byte, deleted bool) {
k.mu.Lock()
_, had := k.by[node]
delete(k.by, node)
delete(k.eventAt, node)
k.mu.Unlock()
if had && k.Changed != nil {
k.Changed()
@@ -430,6 +470,7 @@ func (k *Keeper) Forget(node string) {
k.mu.Lock()
_, had := k.by[node]
delete(k.by, node)
delete(k.eventAt, node)
k.mu.Unlock()
if had && k.Delete != nil {
if err := k.Delete(node); err != nil {