diff --git a/go/clients/nodeengineclient/enginereads/enginereads.go b/go/clients/nodeengineclient/enginereads/enginereads.go index 16e2209..78d02d2 100644 --- a/go/clients/nodeengineclient/enginereads/enginereads.go +++ b/go/clients/nodeengineclient/enginereads/enginereads.go @@ -137,6 +137,9 @@ type Statement struct { Answered bool `json:"answered"` Words string `json:"words,omitempty"` AskAgain *Again `json:"ask-again,omitempty"` + // 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"` } // Again is a person's ask again of one node, written by the tool and answered by the part that keeps the state. @@ -328,6 +331,31 @@ func (k *Keeper) settle(s Statement, why string) { k.take(s) } +// Unanswered is, of nodes, each whose engine the kept call has not answered for bound or longer by now while heard says +// its machine is heard, with since when (novox/hq ADR 0668 §3); a node whose machine is away is none. With no Call, none. +func (k *Keeper) Unanswered(nodes []string, heard func(node string) bool, bound time.Duration, now time.Time) map[string]time.Time { + out := map[string]time.Time{} + if k.Call == nil { + return out + } + k.mu.Lock() + defer k.mu.Unlock() + for _, node := range nodes { + s, ok := k.by[node] + if ok && !s.Answered && !s.UnansweredSince.IsZero() && now.Sub(s.UnansweredSince) >= bound && heard(node) { + out[node] = s.UnansweredSince + } + } + return out +} + +// Asking says an ask of node runs now. +func (k *Keeper) Asking(node string) bool { + k.mu.Lock() + defer k.mu.Unlock() + return k.asking[node] +} + // 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. func (k *Keeper) take(s Statement) { @@ -335,8 +363,14 @@ func (k *Keeper) take(s Statement) { if k.by == nil { k.by = map[string]Statement{} } + if !s.Answered { + s.UnansweredSince = s.AskedAt + if old, ok := k.by[s.Node]; ok && !old.Answered && !old.UnansweredSince.IsZero() { + s.UnansweredSince = old.UnansweredSince + } + } if old, ok := k.by[s.Node]; ok && !s.Answered && old.From != FromNone { - old.Answered, old.AskedAt, old.Words, old.AskAgain = false, s.AskedAt, s.Words, nil + old.Answered, old.AskedAt, old.Words, old.AskAgain, old.UnansweredSince = false, s.AskedAt, s.Words, nil, s.UnansweredSince s = old } k.by[s.Node] = s diff --git a/go/clients/nodeengineclient/enginereads/enginereads_test.go b/go/clients/nodeengineclient/enginereads/enginereads_test.go index 271d491..65f8c91 100644 --- a/go/clients/nodeengineclient/enginereads/enginereads_test.go +++ b/go/clients/nodeengineclient/enginereads/enginereads_test.go @@ -71,6 +71,30 @@ func TestTheAnswerAndTheEventAreJudged(t *testing.T) { if during.From != FromRecord || during.Answered || f.Statements()["g14"].From != FromVerb { t.Errorf("while the kept call asked g14 read %+v; after it %+v", during, f.Statements()["g14"]) } + // ADR 0668 §3: a node unanswered keeps when it first was not, across asks; past the bound it is unanswered while its + // machine is heard, none while away, and none once it answers. + clock := time.Date(2026, 11, 10, 3, 0, 0, 0, time.UTC) + answers := false + u := &Keeper{Sync: true, Now: func() time.Time { return clock }, Call: func(node, _ string, _ any) (json.RawMessage, error) { + if answers { + return json.RawMessage(`{"v":1,"node":"` + node + `","manifest-reads":["a.b"],"stated-at":"` + at + `"}`), nil + } + return nil, context.DeadlineExceeded + }} + first := clock + u.AskNode("ace", "the first view") + clock = clock.Add(6 * time.Minute) + u.AskNode("ace", "asked on") + heard := func(string) bool { return true } + if got := u.Unanswered([]string{"ace"}, heard, 5*time.Minute, clock); !got["ace"].Equal(first) || + len(u.Unanswered([]string{"ace"}, func(string) bool { return false }, 5*time.Minute, clock)) != 0 { + t.Errorf("unanswered past the bound: %v", got) + } + answers = true + u.AskNode("ace", "answered") + if got := u.Unanswered([]string{"ace"}, heard, 5*time.Minute, clock); len(got) != 0 || !u.Statements()["ace"].UnansweredSince.IsZero() { + t.Errorf("an answered node is unanswered: %v", got) + } if Verb != "manifest-reads" || Event != "manifest-reads-changed" || Consumes != "node-engine.manifest-reads-changed" || Invoke != "seat:node-engine.manifest-reads" { t.Errorf("the names are %s %s %s %s", Verb, Event, Consumes, Invoke) }