From 81bc0cc388ddc7b550429ceaa2805284ffee1908 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 00:29:54 +0100 Subject: [PATCH 1/5] S1: keep the bus's "no responders" beside ErrNotAsked on an ask A request that reached no one is asked again by mesh-sdk's busretry only when it can see the bus's own error; the node-engine's ask dropped it, so the seat list's first read, which fell into its holder's re-serve, was never asked again (issue 1966). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- internal/link/asktool_nats_test.go | 9 +++++++++ internal/link/hearing_nats.go | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/link/asktool_nats_test.go b/internal/link/asktool_nats_test.go index 7e266cf..9d22019 100644 --- a/internal/link/asktool_nats_test.go +++ b/internal/link/asktool_nats_test.go @@ -2,10 +2,12 @@ package link import ( "context" + "errors" "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/busretry" "github.com/nats-io/nats.go" ) @@ -37,4 +39,11 @@ func TestNatsAToolCheckIsAskedOnTheLink(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "nothing on this machine answers") { t.Fatalf("a tool nothing serves read as %v", err) } + // The bus's "no responders" is kept beside ErrNotAsked, so a reader that asks again while its request reached no one + // (busretry.Undelivered) sees it (issue 1966: the seat list's first read fell into its holder's re-serve and was never + // asked again). + _, err = (&natsLink{conn: conn, js: js, node: node}).Ask(ctx, ToolSubject("sensors", "nothing_serves_this", node), nil) + if !errors.Is(err, ErrNotAsked) || !errors.Is(err, nats.ErrNoResponders) || !busretry.Undelivered(err) { + t.Fatalf("an ask nothing serves read as %v: want ErrNotAsked and the bus's no responders, undelivered", err) + } } diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index e9f9fba..50fa1a9 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -324,8 +324,10 @@ func (l *natsLink) Ask(ctx context.Context, subject string, body []byte) ([]byte case err == nil: return reply, nil case errors.Is(err, nats.ErrNoResponders): - // The server says nobody subscribes the subject: delivered to no one. - return nil, fmt.Errorf("%w: nothing on this machine answers %s", ErrNotAsked, subject) + // The server says nobody subscribes the subject: delivered to no one. The bus's own error stays beside ErrNotAsked, + // so a caller that asks again while a request reached no one (mesh-sdk busretry.Undelivered, as the seat list's + // reader does while its holder re-serves; issue 1966) sees it. + return nil, fmt.Errorf("%w: nothing on this machine answers %s (%w)", ErrNotAsked, subject, err) } if refused := l.refusal(subject, before); refused != "" { return nil, fmt.Errorf("%s: %v%s", subject, err, refused) -- 2.54.0 From 7bf7e29a32f8016b3a2d8525071dee215a32b3dc Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 00:32:44 +0100 Subject: [PATCH 2/5] S3: the seat-list verbs answer a proved operator's session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seat-list-again answered person. and the healers, but every call through the mesh MCP server is proved as its machine's tool runner (issue 627), so no operator's session could make a node read its seat list again. node-tools now stands for that session; the read seat-list is open to every proved caller, as ADR 0636 §7 limits only the read again. The verb names come from the contract. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- internal/link/handacts.go | 18 ++++++++++-------- internal/link/handover_test.go | 7 ++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/internal/link/handacts.go b/internal/link/handacts.go index cfffffd..fec231f 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -52,17 +52,19 @@ const ( // ActManifestReads reads the reader capabilities this engine states (novox/hq ADR 0618 §3). ActManifestReads = nodeengine.VerbManifestReads // ActSeatList reads the seat list this engine holds and what it waits for; ActSeatListAgain makes it read the list now - // (novox/hq ADR 0636 §7). The node-engine seat's definition carries both verbs, which answer only the operator and the - // healers. - ActSeatList = "seat-list" - ActSeatListAgain = "seat-list-again" + // (novox/hq ADR 0636 §7). The node-engine seat's definition carries both verbs. + ActSeatList = nodeengine.VerbSeatList + ActSeatListAgain = nodeengine.VerbSeatListAgain ) -// SeatListCallers are who may call the engine's two seat-list verbs: every operator's session and the healers (novox/hq -// ADR 0636 §7, the node-engine seat's definition: the operator and the healers). +// SeatListCallers are who may call the engine's two seat-list verbs (novox/hq ADR 0636 §7). The read is open to every +// proved caller: the record limits only the read again, and the list holds nothing secret. The read again answers the +// operator's sessions and the healers. A call through the mesh MCP server, and nox, is proved only as its machine's tool +// runner (.node-tools, issue 627), so node-tools stands for an operator's session until a session is proved; without +// it the verb answered nobody but the healers, and no healer calls it (issue 1966). var SeatListCallers = stdio.VerbCallers{ - ActSeatList: {stdio.AnyPerson, "mesh-healers"}, - ActSeatListAgain: {stdio.AnyPerson, "mesh-healers"}, + ActSeatList: {stdio.AnyCaller}, + ActSeatListAgain: {stdio.AnyPerson, "node-tools", "mesh-healers"}, } // The states of an act: asked, done and refused are also the seat's events. diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 54665ae..58f4332 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -301,8 +301,13 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`}, {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`}, - // ADR 0636 §7: the seat-list verbs answer every operator's session and the healers, no other module. + // ADR 0636 §7: the read seat-list answers every proved caller; seat-list-again every operator's session, proved + // as a person or, through the mesh MCP server, as its machine's tool runner (issue 627, issue 1966), and the + // healers, no other module. {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.person~jochen", `"held":true`}, + {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.shanks~node-tools", `"held":true`}, + {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `"held":true`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.shanks~node-tools", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.person~jochen", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~mesh-healers", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `not on the holder's caller list for seat-list-again`}, -- 2.54.0 From 4af6c274b8104684037c95af9856b6a767a8a196 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 00:40:53 +0100 Subject: [PATCH 3/5] S4: an unread seat list holds a module before its build is read On ace, a node-engine left on its bootstrapped seat list could not find the artifact store's seat, refused avahi's archive reference and with it the whole pass, so the seat-list hold never ran and every module said "reaches no artifact store". While the list is unread, a placed module whose seats are not all bootstrapped is now held as it runs before anything of its build is read (frombus.Scope.Held), its reason names the bootstrapped seats the node holds, and a pass refused whole says the unread list first (issue 1966). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- internal/compose/frombus/frombus.go | 13 ++++- internal/selfcompose/selfcompose.go | 62 ++++++++++++++++++++-- internal/selfcompose/selfcompose_test.go | 67 +++++++++++++++++++++--- 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 069e370..89e6e93 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -182,6 +182,10 @@ type Scope struct { // decoder (compose.Input.Holds), with the build it would compose it at, so the node says why it holds it (issue 1921, // the composition verb). Nil: said nowhere. Holds func(map[string]compose.Hold) + // Held is the modules the node holds before anything of their builds is read, each with why (a node that has not read + // the seat list, novox/hq ADR 0636 §1): each is held as it runs, as a module refused alone is, so no take-in or + // artifact reference of it can refuse the node (issue 1966). Nil holds none. + Held map[string]string } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -585,7 +589,10 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte // node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served // elsewhere, a stand-in for the node's own) refuses the node, err. func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]engcomp.Composed, skipped []string, held map[string]string, err error) { - held = map[string]string{} + held = maps.Clone(s.Held) + if held == nil { + held = map[string]string{} + } // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774, // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid). defer func() { @@ -933,6 +940,10 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m } } for _, pm := range p.Modules { + if why, held := s.Held[pm.Module]; held && refuse != nil { + stub(pm.Module, errors.New(why)) // nothing of its build is read: it keeps what runs (Scope.Held) + continue + } m, ok := modules[pm.Module] if (!ok || m.Fp2 == "") && s.Unmade != nil && wanted[pm.Module] != "" { s.Unmade(pm.Module, "the indexer's modules hold no build of the new form for it (fingerprint "+m.Fp2+"), so it is composed from the manifest") diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index d07bdfa..3810cab 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -43,6 +43,7 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/resolution" @@ -997,6 +998,10 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { c.mu.Lock() c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.mu.Unlock() + // A node that has not read the seat list takes nothing of a module whose seats are not all bootstrapped from its build + // (ADR 0636 §1): the module is held as it runs before anything of its build is read, so no take-in or artifact + // reference of it (the artifact store's seat is not bootstrapped) can refuse the pass (issue 1966). + scope.Held = c.heldUntilListed(ctx, r, own) missed := c.missed.Load() compositions, skipped, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps) err = c.missedIn(missed, err) @@ -1021,6 +1026,10 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } if err != nil { why := fmt.Sprintf("%v", err) + if line, unread := c.seatListUnread(); unread { + // A refusal while the list is unread may be of a seat the node cannot yet know: said first (issue 1966). + why = line + "; " + why + } // A bus that did not answer past every try is no answer: composed again after a bound (issue 1126, 2026-10-28), // and the refusal says when (issue 774, 2026-10-31). A refusal is final until an input moves. if BusFailed(err) { @@ -1869,8 +1878,54 @@ func mapValues(m map[string]compositions.Composed) []compositions.Composed { return out } -// WaitsForTheSeatList is why a module is held while the node has not read the seat list. -const WaitsForTheSeatList = "the node has not read the seat list, so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" +// WaitsForTheSeatList is why a module is held while the node has not read the seat list, said by a gate that names no +// seat it holds. +const WaitsForTheSeatList = "the node has not read the seat list" + composesNothingNewOfIt + +// WaitsForTheSeatListHeld is why a module is held while the node holds only its bootstrapped seat list (novox/hq ADR 0636 +// §2): the number and the names of the seats it holds, then what it does of the module (issue 1966). +const WaitsForTheSeatListHeld = "the node holds only its bootstrapped seat list (%d seats: %s) and has not read the seat " + + seatlist.Seat + +const composesNothingNewOfIt = ", so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" + +// seatListUnread is, while the node has not read the seat list, the line that says so, naming the bootstrapped seats it +// holds when its gate says them (the reader's Status); false once the list was read, or with no gate. +func (c *Composer) seatListUnread() (string, bool) { + if c.env.SeatList == nil || c.env.SeatList.MayAct() { + return "", false + } + says, ok := c.env.SeatList.(interface{ Status() seatlist.Status }) + if !ok { + return strings.TrimSuffix(WaitsForTheSeatList, composesNothingNewOfIt), true + } + var seats []string + for _, h := range says.Status().Held { + seats = append(seats, h.Seat) + } + return fmt.Sprintf(WaitsForTheSeatListHeld, len(seats), strings.Join(seats, ", ")), true +} + +// heldUntilListed is, while the node has not read the seat list, every module placed on it whose seats are not all +// bootstrapped, each with why: the pass holds them as they run before reading anything of their builds (frombus.Scope.Held). +// A holder of the seat mesh-catalog is composed whatever the list says, since the list is read from it (holdUntilListed, +// holderPart). Nil once the list was read. +func (c *Composer) heldUntilListed(ctx context.Context, r reader, own meshmoduleassigner.Assignments) map[string]string { + line, unread := c.seatListUnread() + if !unread { + return nil + } + holders := c.claimants(ctx, r, meshcatalog.Seat) + held := map[string]string{} + for _, pm := range own.Modules { + if pm.Module == "" || pm.Module == agent.NodeModule || slices.Contains(holders, pm.Module) || + c.env.SeatList.MayAct(c.seatsOf(ctx, r, pm.Module)...) { + continue + } + held[pm.Module] = line + composesNothingNewOfIt + } + return held +} // WaitsForTheSeatListOwn is why the node's own composition is held: it folds modules and accounts whose seats the node // cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1). @@ -1906,7 +1961,8 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m if dig, err := engcomp.DigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { continue } - module, why := comp.Module, WaitsForTheSeatList + line, _ := c.seatListUnread() + module, why := comp.Module, line+composesNothingNewOfIt if module == "" { // the node's own: no seat to judge, so while the list is unread it waits whole if c.env.SeatList.MayAct() { continue diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 277f4e2..f6d1f38 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -32,6 +32,8 @@ import ( "time" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" @@ -1842,6 +1844,20 @@ type gate struct{ open atomic.Bool } func (g *gate) MayAct(...string) bool { return g.open.Load() } +// namedGate is a gate that says the seats it holds, as the reader of the seat list does (seatlist.Reader.Status). +type namedGate struct { + *gate + seats []string +} + +func (g *namedGate) Status() seatlist.Status { + st := seatlist.Status{Heard: g.open.Load()} + for _, s := range g.seats { + st.Held = append(st.Held, seatlist.Held{Seat: s, Source: seatlist.SourceBootstrap}) + } + return st +} + // ADR 0636 §1: a node that has not read the seat list composes nothing new of a module whose seats are not bootstrapped, // says it holds the module (so its have says why) and hands it at the read, which the reader says as an event; what // already runs with the same composition is not held. @@ -1852,22 +1868,38 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs}) key, _ := meshcatalogclient.ManifestsKey(digest) b.kv[buckets.Manifests+"/"+key] = man + // Issue 1966: avahi's archive is in the artifact store, whose seat the bootstrap does not hold, so before the read the + // node reaches no store; avahi is held for the list before its build is read, and the pass is not refused for it. + avahi := `{"module":"avahi","version":"1","capabilities":["systemd"],"resources":[{"id":"tools","type":"archive",` + + `"path":"/opt/avahi","source":"` + catalogue.ArtifactStoreScheme + `avahi/tools/blobs/sha256:` + strings.Repeat("d", 64) + `"}]}` + avahiDigest := meshcatalogclient.ManifestDigest([]byte(avahi)) + avahiMan, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "avahi", Manifest: avahi}) + avahiKey, _ := meshcatalogclient.ManifestsKey(avahiDigest) + b.kv[buckets.Manifests+"/"+avahiKey] = avahiMan ctx, cancel := context.WithCancel(context.Background()) defer cancel() env := b.env() - g := &gate{} + g := &namedGate{gate: &gate{}, seats: []string{"mesh-catalog", "node-engine"}} changed := make(chan struct{}, 1) env.SeatList, env.SeatListChanged = g, changed var mu sync.Mutex heldBy := map[string]string{} - env.Refused = func(_ string, held map[string]string) { mu.Lock(); heldBy = held; mu.Unlock() } + var refusedWhole []string + env.Refused = func(why string, held map[string]string) { + mu.Lock() + defer mu.Unlock() + heldBy = held + if why != "" { + refusedWhole = append(refusedWhole, why) + } + } c := New(env) out, err := c.Watch(ctx, "n1.>") if err != nil { t.Fatal(err) } place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "avahi"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) @@ -1875,6 +1907,8 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Modules, "lemurs", mod) + avahiMod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: avahiDigest, Repository: "novox/x", Commit: "c"}) + b.put(t, buckets.Modules, "avahi", avahiMod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { @@ -1888,11 +1922,32 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { } handed[u.Key] = true } + // The reason names the bootstrapped list the node holds (issue 1966: ace's modules said "reaches no artifact store"). + waits := fmt.Sprintf(WaitsForTheSeatListHeld, 2, "mesh-catalog, node-engine") + composesNothingNewOfIt mu.Lock() - why := heldBy["lemurs"] + why, avahiWhy, whole := heldBy["lemurs"], heldBy["avahi"], slices.Clone(refusedWhole) mu.Unlock() - if why != WaitsForTheSeatList { - t.Errorf("lemurs is held with %q, want %q", why, WaitsForTheSeatList) + if why != waits || avahiWhy != waits || len(whole) != 0 { + t.Errorf("lemurs is held with %q, avahi with %q, the pass refused %q; want both %q and no refusal", why, avahiWhy, whole, waits) + } + // A pass refused whole while the list is unread says the list first (issue 1966). + refusing := b.env() + refusing.SeatList = g + refusing.Get = func(context.Context, string, string) ([]byte, error) { + return nil, errors.New("nats says ${dir:mesh-state}, and nats declares no directory") + } + var refusal string + refusing.Refused = func(w string, _ map[string]string) { refusal = w } + c2 := New(refusing) + c.mu.Lock() + maps.Copy(c2.wants, c.wants) + maps.Copy(c2.assignments, c.assignments) + maps.Copy(c2.records, c.records) + maps.Copy(c2.modules, c.modules) + c.mu.Unlock() + c2.compose(ctx) + if line := fmt.Sprintf(WaitsForTheSeatListHeld, 2, "mesh-catalog, node-engine") + "; "; !strings.HasPrefix(refusal, line) { + t.Errorf("a pass refused while the list is unread says %q, want it to begin %q", refusal, line) } for k := range handed { if strings.Contains(k, "lemurs") { -- 2.54.0 From a484d5f9605067a64f3155420d544a05612de028 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 00:48:14 +0100 Subject: [PATCH 4/5] S5: the whole node reads its seat list after a holder that re-served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live case of ace, in process against a real bus: the stand-in of the seat mesh-catalog answers as nothing serves it (no responders) while the node's reader first asks, then serves; the same reader reads the list within the bus's retry bound, with no event of the seat and no hand act (issue 1966, ADR 0636 §8). The test of an unread list now reads the reason that names the bootstrapped seats. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- cmd/mesh-host/nodeagent_integration_test.go | 36 +++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 7a3551e..f8da072 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -7,6 +7,7 @@ import ( "crypto/ed25519" "encoding/json" "errors" + "fmt" "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" @@ -20,6 +21,7 @@ import ( engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -72,7 +74,10 @@ type itBed struct { seatSilent atomic.Bool // seatAsked counts the node's asks of the stand-in seat, answered or refused: the node reached the bus and asked. seatAsked atomic.Int32 - findings nats.KeyValue + // seatAway makes the stand-in answer as the bus answers a subject nothing serves (no responders), as while the seat's + // holder re-serves (issue 1966). + seatAway atomic.Bool + findings nats.KeyValue } // aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what @@ -918,6 +923,10 @@ func (b *itBed) standInSeat() { } _ = json.Unmarshal(m.Data, &q) b.seatAsked.Add(1) + if b.seatAway.Load() { + _ = m.RespondMsg(&nats.Msg{Header: nats.Header{"Status": []string{"503"}}}) // the bus's no responders + return + } var result any switch { case b.seatSilent.Load(): @@ -1002,7 +1011,14 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { b.mu.Lock() defer b.mu.Unlock() said := strings.Join(b.said, "\n") - return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && strings.Contains(said, selfcompose.WaitsForTheSeatList) + // web's reason names the bootstrapped seats the node holds (issue 1966). + var boot []string + for _, d := range seatlist.Bootstrapped() { + boot = append(boot, d.Seat) + } + sort.Strings(boot) + return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && + strings.Contains(said, "web: "+fmt.Sprintf(selfcompose.WaitsForTheSeatListHeld, len(boot), strings.Join(boot, ", "))) }) if b.file("web") != "" { t.Fatalf("the node applied web before it read the seat list: %q", b.file("web")) @@ -1047,6 +1063,10 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { // The first read of the seat list ends the wait whether or not it changed what the reader held: Start returns at once, and // the node waits on the reader's Ready before it says the read is over (ADR 0636 §1). +// +// Issue 1966, the live case of ace: the seat's holder re-serves while the node starts, so the node's reader is answered as +// nothing serves the seat (no responders); once the holder serves again the reader reads the list within the bus's retry +// bound, with no event of the seat and no hand act. func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { clock := &fiveMinutes{} was := seatWaitClock @@ -1054,8 +1074,17 @@ func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { t.Cleanup(func() { seatWaitClock = was }) b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) + b.seatAway.Store(true) stop, _ := h8(t, b, false) defer stop() + var st *seatListState + within(t, 5*time.Second, "the node started no reader of the seat list", func() bool { st = seatListNow.Load(); return st != nil }) + asked := b.seatAsked.Load() + within(t, 10*time.Second, "the node's reader did not ask the seat while nothing served it", func() bool { + now := st.reader.Status() + return b.seatAsked.Load() > asked || (!now.Reading && !now.Heard) + }) + b.seatAway.Store(false) within(t, 30*time.Second, "the node did not end its wait at the first read of the seat list", func() bool { st := seatListNow.Load() if st == nil { @@ -1065,4 +1094,7 @@ func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { waiting, _ := st.waiter.Waiting() return st.reader.Status().Heard && !waiting }) + if seatListNow.Load() != st { + t.Error("the list was read by the reader of a run begun since, not by the one that met no responders") + } } -- 2.54.0 From 5c353cfc7bf08d1503d5bd6ce28b6d704df51bab Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 01:16:05 +0100 Subject: [PATCH 5/5] S3: the read seat-list answers the operator's sessions and the healers, as its seat says The node-engine seat's definition names "the operator and the healers" for both seat-list verbs, and design 47 G keeps it; the read was opened to every proved caller on a ruling the review withdrew. node-tools stays on both: every console call is proved as its machine's tool runner (issue 627, issue 1966). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- internal/link/handacts.go | 12 ++++++------ internal/link/handover_test.go | 7 +++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/internal/link/handacts.go b/internal/link/handacts.go index fec231f..186c7ca 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -57,13 +57,13 @@ const ( ActSeatListAgain = nodeengine.VerbSeatListAgain ) -// SeatListCallers are who may call the engine's two seat-list verbs (novox/hq ADR 0636 §7). The read is open to every -// proved caller: the record limits only the read again, and the list holds nothing secret. The read again answers the -// operator's sessions and the healers. A call through the mesh MCP server, and nox, is proved only as its machine's tool -// runner (.node-tools, issue 627), so node-tools stands for an operator's session until a session is proved; without -// it the verb answered nobody but the healers, and no healer calls it (issue 1966). +// SeatListCallers are who may call the engine's two seat-list verbs (novox/hq ADR 0636 §7; the node-engine seat's +// definition: the operator and the healers): every operator's session and the healers, both verbs alike. A call through +// the mesh MCP server, and nox, is proved only as its machine's tool runner (.node-tools, issue 627), so node-tools +// stands for an operator's session until a session is proved; without it the verbs answered nobody but the healers, and +// no healer calls them (issue 1966). var SeatListCallers = stdio.VerbCallers{ - ActSeatList: {stdio.AnyCaller}, + ActSeatList: {stdio.AnyPerson, "node-tools", "mesh-healers"}, ActSeatListAgain: {stdio.AnyPerson, "node-tools", "mesh-healers"}, } diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 58f4332..1fc5de3 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -301,12 +301,11 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`}, {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`}, - // ADR 0636 §7: the read seat-list answers every proved caller; seat-list-again every operator's session, proved - // as a person or, through the mesh MCP server, as its machine's tool runner (issue 627, issue 1966), and the - // healers, no other module. + // ADR 0636 §7: both seat-list verbs answer every operator's session, proved as a person or, through the mesh MCP + // server, as its machine's tool runner (issue 627, issue 1966), and the healers, no other module. {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.person~jochen", `"held":true`}, {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.shanks~node-tools", `"held":true`}, - {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `"held":true`}, + {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `not on the holder's caller list for seat-list`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.shanks~node-tools", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.person~jochen", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~mesh-healers", `"read":true`}, -- 2.54.0