V-1: W1.4: a first view says the revision view it holds, what it waits for and since when

A part with no view waits and says so (design 47, A part with no view says it waits): views' Follower gains WaitFor and
Holds, its Status the view held, the one waited for, since when and whether view-waits is written; past WaitsBound (five
minutes, a context's bound) RaiseWaits is told once, and holding the view clears it. ViewWaitsFinding names the part,
the owner and the revision. The existing <module>_first_views tool answers it; no seat type enters views.

Builds: design 47 G §200, §245, §262

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 10:51:07 +01:00
parent 4bcb6f86a4
commit fa16c39057
4 changed files with 147 additions and 1 deletions
+16
View File
@@ -1,6 +1,7 @@
package views
import (
"cmp"
"fmt"
"strings"
"time"
@@ -13,6 +14,9 @@ const (
TooLargeKind = "view-too-large"
UnsaidKind = "view-unsaid"
UnansweredKind = "first-view-unanswered"
// ViewWaitsKind is a part's finding of a view it has waited for past WaitsBound (design 47 *A part with no view says it
// waits*).
ViewWaitsKind = "view-waits"
)
// Finding is what a views part says is wrong with a state it owns or follows, in plain fields: Kind and ID name it (a module-scoped
@@ -73,3 +77,15 @@ func UnansweredFinding(reader string, s Status, at time.Time) Finding {
func finding(observer, kind, id, summary, headline, explanation string, at time.Time) Finding {
return Finding{Observer: observer, Kind: kind, ID: id, Summary: summary, Headline: headline, Explanation: explanation, At: at.UTC()}
}
// ViewWaitsFinding is a part's finding of the view it has waited for since s.WaitingSince (design 47): it names the
// part, the owner and the revision, and clears when the part holds it.
func ViewWaitsFinding(part string, s Status, at time.Time) Finding {
id := part + "." + s.Owner + "." + s.State
summary := fmt.Sprintf("%s waits for %s's view %s since %s and acts on nothing new until it holds it; it holds %s",
part, s.Owner, s.WaitsFor, s.WaitingSince.UTC().Format(time.RFC3339), cmp.Or(s.View, "none"))
return finding(part, ViewWaitsKind, id, summary,
fmt.Sprintf("%s waits for a view of %s", part, s.Owner),
fmt.Sprintf("%s keeps running what it runs and takes up nothing new until %s's view %s reaches it. Look at %s: is it running, and has it "+
"said that revision? %s's first views tool says what it holds and waits for.", part, s.Owner, s.WaitsFor, s.Owner, part), at)
}
+74
View File
@@ -30,6 +30,10 @@ type Follower struct {
// nothing.
Raise func(Status)
Clear func(Status)
// RaiseWaits and ClearWaits are the part's view-waits finding: written past WaitsBound of a WaitFor, cleared when it
// Holds the view. Nil says nothing.
RaiseWaits func(Status)
ClearWaits func(Status)
// Changed is told of each key the picture changes (deleted: the key is gone), after the change is held, with when the
// owner's bus wrote it: the event's time, or for a key a first view brought the time its answer says (Answer.Written;
// zero when the owner does not say it, and for a key the first view no longer holds).
@@ -87,6 +91,76 @@ type Status struct {
// Why is the last reason it asked (start, a gap and which, a hand act) or why the last ask failed.
Why string `json:"why,omitempty"`
Error string `json:"error,omitempty"`
// View is the revision of the revision view the part holds from this first view, as its seat's client names it
// (design 47 *A part with no view says it waits*); WaitsFor the one it waits for and acts on nothing new until it
// holds, since WaitingSince; WaitsRaised says its view-waits finding is written (past WaitsBound).
View string `json:"view,omitempty"`
WaitsFor string `json:"waits-for,omitempty"`
WaitingSince time.Time `json:"waiting-since,omitzero"`
WaitsRaised bool `json:"waits-raised,omitempty"`
}
// WaitsBound is how long a part waits for a view before it writes its finding view-waits (design 47): the bound the seat
// list's readers keep, so an owner down for a restart raises nothing.
const WaitsBound = 5 * time.Minute
// waitBound waits d within ctx: true when d passed, false when ctx ended first. A variable so a test ends it at once.
var waitBound = func(ctx context.Context, d time.Duration) bool {
b, cancel := context.WithTimeout(ctx, d)
defer cancel()
<-b.Done()
return ctx.Err() == nil
}
// WaitFor says the part waits for view, a revision of the revision view its seat's client names, and acts on nothing
// new until it holds it (Holds): the status says what and since when, and if it still waits for it past WaitsBound,
// RaiseWaits is told (the part writes view-waits, ViewWaitsFinding). Waiting for the view it waits for already changes
// nothing, not even since when.
func (f *Follower) WaitFor(ctx context.Context, view string) {
f.mu.Lock()
f.init()
if f.st.WaitsFor == view || view == "" {
f.mu.Unlock()
return
}
f.st.WaitsFor, f.st.WaitingSince, f.st.WaitsRaised = view, f.now(), false
s := f.st
f.mu.Unlock()
f.logf("views: %s waits for %s's view %s, from %s", f.Tool, f.Owner, view, s.WaitingSince.Format(time.RFC3339))
f.say(s)
go func() {
if !waitBound(ctx, WaitsBound) {
return
}
f.mu.Lock()
raise := f.st.WaitsFor == view && !f.st.WaitsRaised
if raise {
f.st.WaitsRaised = true
}
s := f.st
f.mu.Unlock()
if raise {
f.logf("views: %s has waited for %s's view %s since %s", f.Tool, f.Owner, view, s.WaitingSince.Format(time.RFC3339))
if f.RaiseWaits != nil {
f.RaiseWaits(s)
}
f.say(s)
}
}()
}
// Holds says the part holds view now: it waits no more, and a raised view-waits is cleared (ClearWaits).
func (f *Follower) Holds(view string) {
f.mu.Lock()
f.init()
raised := f.st.WaitsRaised
f.st.View, f.st.WaitsFor, f.st.WaitingSince, f.st.WaitsRaised = view, "", time.Time{}, false
s := f.st
f.mu.Unlock()
if raised && f.ClearWaits != nil {
f.ClearWaits(s)
}
f.say(s)
}
func (f *Follower) now() time.Time {
+2 -1
View File
@@ -101,7 +101,8 @@ func StatusTools(reader, read, again string, st stdio.KeptState, acts HandActs,
Description: fmt.Sprintf("%s's first views of the owners' states it follows (novox/hq ADR 0615): per owner and state, "+
"the tool it asks, the revision its picture holds now (events taken in included), the highest revision it heard and why "+
"it is behind it, if it is, when it asked and was answered, how many asks (by hand among them) and whether its "+
"condition (unanswered past two minutes) is raised.", reader),
"condition (unanswered past two minutes) is raised; and the revision of the revision view it holds, the one it waits "+
"for and acts on nothing new until it holds, since when, and whether its view-waits finding (past five minutes) is written.", reader),
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) {
keys, err := st.Keys()
+55
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"runtime"
"slices"
"strings"
"sync"
@@ -299,6 +300,60 @@ func TestViews(t *testing.T) {
t.Errorf("said %v after %d asks", *said, f.Status().Asks)
}
})
// W1.4 (design 47 *A part with no view says it waits*): the status says the view held, the one waited for and since
// when; past the bound the part's view-waits is raised once, and holding the view clears it.
t.Run("a part that waits for a view says so, raises view-waits past its bound, and holding it clears that", func(t *testing.T) {
ended := make(chan bool)
kept := waitBound
waitBound = func(context.Context, time.Duration) bool { return <-ended }
defer func() { waitBound = kept }()
var raised, cleared, said []Status
var mu sync.Mutex
f := &Follower{Owner: "o", Tool: "o.revision", State: "catalog", Now: now,
RaiseWaits: func(s Status) { mu.Lock(); raised = append(raised, s); mu.Unlock() },
ClearWaits: func(s Status) { mu.Lock(); cleared = append(cleared, s); mu.Unlock() },
Said: func(s Status) { mu.Lock(); said = append(said, s); mu.Unlock() }}
f.Holds("trunk.11")
f.WaitFor(context.Background(), "trunk.12")
f.WaitFor(context.Background(), "trunk.12") // the same view again: nothing new, no second bound
if s := f.Status(); s.View != "trunk.11" || s.WaitsFor != "trunk.12" || !s.WaitingSince.Equal(now()) || s.WaitsRaised {
t.Fatalf("status = %+v", s)
}
ended <- true
for i := 0; i < 1000 && !f.Status().WaitsRaised; i++ {
runtime.Gosched()
}
mu.Lock()
if len(raised) != 1 || raised[0].WaitsFor != "trunk.12" {
t.Errorf("raised = %+v", raised)
}
mu.Unlock()
fd := ViewWaitsFinding("mesh-delivery-builder", f.Status(), now())
if fd.Kind != ViewWaitsKind || fd.ID != "mesh-delivery-builder.o.catalog" || !strings.Contains(fd.Summary, "waits for o's view trunk.12") ||
!strings.Contains(fd.Summary, "it holds trunk.11") {
t.Errorf("finding = %+v", fd)
}
f.Holds("trunk.12")
if s := f.Status(); s.View != "trunk.12" || s.WaitsFor != "" || s.WaitsRaised {
t.Errorf("after holding: %+v", s)
}
mu.Lock()
if len(cleared) != 1 || len(said) == 0 || said[len(said)-1].View != "trunk.12" {
t.Errorf("cleared %d, last said %+v", len(cleared), said)
}
mu.Unlock()
f.WaitFor(context.Background(), "trunk.13")
f.Holds("trunk.13")
ended <- true // the bound of a view now held raises nothing
for i := 0; i < 100; i++ {
runtime.Gosched()
}
mu.Lock()
if len(raised) != 1 {
t.Errorf("a view held before its bound raised: %+v", raised)
}
mu.Unlock()
})
t.Run("an event heard while an ask runs is taken again after its answer", func(t *testing.T) {
var f *Follower
f, _ = follower(func(ctx context.Context, after string) (Answer, error) {