From 2d28a52d61f9f2924d20c6d465baf3d349fa298b Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 18:14:42 +0100 Subject: [PATCH] Ask each machine's engine for the root-free word again: the engines were deaf, not absent (issue 774) The sweep took the node seat node-engine's root-free for one nothing answers; the engines had been refused their subscriptions by the bus since 12:10 (fixed in mesh-host), so the address was right. This puts it back and keeps what the previous commit added: every failed ask said with its error, a fresh word not thrown away, and the failure as the held ask's reason. --- modules/messenger/cmd/messenger/asks.go | 83 ++++++++++--------- modules/messenger/cmd/messenger/bus_test.go | 10 +-- modules/messenger/cmd/messenger/holder.go | 2 +- .../messenger/cmd/messenger/manifest_test.go | 2 +- .../messenger/cmd/messenger/review_test.go | 39 +++++---- modules/messenger/module.json | 2 +- 6 files changed, 69 insertions(+), 69 deletions(-) diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 301429f6..1fd236c8 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -242,8 +242,9 @@ func (h *Holder) carriers(level asks.Level) []string { return out } -// RootVerdict is the word on one machine (novox/hq ADR 0259 §8): free only when it was measured, recently, that no -// agent can become root there without a person. +// RootVerdict is a machine's engine's word on that machine (the node seat node-engine's root-free, novox/hq ADR 0259 +// §8 as ADR 0563 §6 changes it): free only when it measured, recently, that no agent can become root there without a +// person. type RootVerdict struct { Machine string `json:"machine"` Free bool `json:"free"` @@ -251,61 +252,63 @@ type RootVerdict struct { Judged string `json:"judged,omitempty"` } -// RootFreeVerb is the verb the router asks, as this module, which machines are root-free: the controller's, which -// judges each machine from its node-engine's own verdict (ADR 0259 §8). ADR 0563 §6 moves the word to the node seat -// node-engine and ADR 0571 §11 to the self-check's probe; neither answers on the mesh yet (the engine's acts are left -// out of its declared state, mesh-host #178, and no seat mesh-self-check is held), so asking them held every ask -// that offers an approval (issue 774, the sweep of 2026-10-30). The controller's verb is the one that answers. -const RootFreeVerb = "seat:" + ControllerSeat + ".root-free" +// EngineSeat is the node seat each machine's engine holds, and RootFreeVerb its verb the router asks of a machine, as +// this module: `seat:node-engine.root-free@`, which that machine's engine alone answers (ADR 0563 §6). +const ( + EngineSeat = "node-engine" + RootFreeVerb = "seat:" + EngineSeat + ".root-free" +) -// RootFreeWithin is how long the router waits for the word; RootFreeKept how long a word is used to render a +// RootFreeWithin is how long the router waits for the machines' words; RootFreeKept how long a word is used to render a // message's buttons. An answer is always judged on a word asked then. const ( RootFreeWithin = 10 * time.Second RootFreeKept = 30 * time.Second ) -// askRootFree asks the word on machines at once, failing on no answer in time. Only a verdict on a machine asked is -// taken. +// askRootFree asks each machine's own engine about that machine, at once, failing on no answer in time. A machine's +// word is the verdict its engine gives on it; a verdict an engine gives on another machine is not taken. func (h *Holder) askRootFree(machines []string) (map[string]RootVerdict, error) { if h.RootFree != nil { return h.RootFree(machines) } if h.Ask == nil { - return nil, errors.New("this router cannot ask the controller") + return nil, errors.New("this router cannot ask a machine's engine") } type answer struct { - raw json.RawMessage - err error + machine string + raw json.RawMessage + err error } - done := make(chan answer, 1) - go func() { - raw, err := h.Ask(RootFreeVerb, map[string]any{"machines": machines}) - done <- answer{raw, err} - }() - var a answer - select { - case a = <-done: - case <-time.After(RootFreeWithin): - return nil, fmt.Errorf("%s did not answer within %s", RootFreeVerb, RootFreeWithin) - } - if a.err != nil { - return nil, fmt.Errorf("%s: %w", RootFreeVerb, a.err) - } - var body struct { - Machines []RootVerdict `json:"machines"` - } - if err := json.Unmarshal(a.raw, &body); err != nil { - return nil, fmt.Errorf("%s's answer could not be read: %v", RootFreeVerb, err) - } - asked := map[string]bool{} + done := make(chan answer, len(machines)) for _, m := range machines { - asked[m] = true + go func(m string) { + raw, err := h.Ask(RootFreeVerb+"@"+m, map[string]any{}) + done <- answer{m, raw, err} + }(m) } out := map[string]RootVerdict{} - for _, v := range body.Machines { - if asked[v.Machine] { - out[v.Machine] = v + deadline := time.After(RootFreeWithin) + for range machines { + var a answer + select { + case a = <-done: + case <-deadline: + return nil, fmt.Errorf("a machine's engine did not answer within %s", RootFreeWithin) + } + if a.err != nil { + return nil, fmt.Errorf("%s's engine: %w", a.machine, a.err) + } + var body struct { + Machines []RootVerdict `json:"machines"` + } + if err := json.Unmarshal(a.raw, &body); err != nil { + return nil, fmt.Errorf("%s's engine's answer could not be read: %v", a.machine, err) + } + for _, v := range body.Machines { + if v.Machine == a.machine { + out[v.Machine] = v + } } } return out, nil @@ -382,7 +385,7 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string { v, said := verdicts[m] switch { case !said: - return "the controller said nothing of whether an agent can become root on " + m + return "its engine said nothing of whether an agent can become root on " + m case !v.Free: why := v.Why if why == "" { diff --git a/modules/messenger/cmd/messenger/bus_test.go b/modules/messenger/cmd/messenger/bus_test.go index c42a9ade..c42fb278 100644 --- a/modules/messenger/cmd/messenger/bus_test.go +++ b/modules/messenger/cmd/messenger/bus_test.go @@ -256,14 +256,8 @@ func (r *busRuntime) runtime(id json.RawMessage, method string, params json.RawM case "mesh/seat.publish": return map[string]any{}, r.nc.Publish(p.Subject, p.Body) case "mesh/ask": - if p.Key == RootFreeVerb { - var args struct{ Machines []string } - _ = json.Unmarshal(p.Body, &args) - var out []RootVerdict - for _, m := range args.Machines { - out = append(out, RootVerdict{Machine: m, Free: true}) - } - return map[string]any{"machines": out}, nil + if m, ok := strings.CutPrefix(p.Key, RootFreeVerb+"@"); ok { + return map[string]any{"machines": []RootVerdict{{Machine: m, Free: true}}}, nil } return nil, fmt.Errorf("the test runtime answers nothing for %s", p.Key) } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 4917f08d..b3dff885 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -315,7 +315,7 @@ type Holder struct { Emit func(event string, body any) error // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. Ask Asker - // RootFree asks the root-free word on machines (the controller's verb, RootFreeVerb); nil asks it through Ask + // RootFree asks each machine's engine's root-free (the node seat node-engine's, ADR 0563 §6); nil asks it through Ask // (askRootFree). RootFree func(machines []string) (map[string]RootVerdict, error) // Zone is the operator's time zone, which every time in a message is said in; nil is this machine's. diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 89278493..6e61b0de 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -88,7 +88,7 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { // // It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues // track which conditions. if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions", - "seat:mesh-conditions.conditions", RootFreeVerb, TrackingVerb}) { + "seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } var state []string diff --git a/modules/messenger/cmd/messenger/review_test.go b/modules/messenger/cmd/messenger/review_test.go index 384a7afb..0ecb1b39 100644 --- a/modules/messenger/cmd/messenger/review_test.go +++ b/modules/messenger/cmd/messenger/review_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "sync" "testing" @@ -73,32 +74,34 @@ func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) { } } -// H3: the word on the router's machine and the channel's is asked of the verb that answers it on the mesh, the -// controller's root-free, once with both machines, as this module; a verdict on a machine not asked is not taken. -// The sweep of 2026-10-30 (issue 774): the node seat node-engine's root-free answered nowhere, so every ask that -// offers an approval was held, silently. -func TestTheRootWordIsAskedOfTheVerbThatAnswers(t *testing.T) { +// H3, as ADR 0563 §6 changes it: the word on each machine is asked of that machine's own engine, the node seat +// node-engine's root-free there, as this module, and read from its answer; a machine's word is that machine's alone. +func TestTheRootWordIsAskedOfEachMachinesEngine(t *testing.T) { h, _, _, _, _, _ := newRouter(t) h.RootFree = nil var asked []string h.Ask = func(key string, body any) (json.RawMessage, error) { - ms, _ := body.(map[string]any)["machines"].([]string) - asked = append(asked, key+" "+strings.Join(ms, ",")) - return json.RawMessage(`{"machines":[{"machine":"anchor","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil + asked = append(asked, key) + machine := strings.TrimPrefix(key, "seat:node-engine.root-free@") + if machine == "elsewhere" { + machine = "anchor" // an engine that answers for another machine is not believed + } + return json.RawMessage(`{"machines":[{"machine":"` + machine + `","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil } info, _ := h.Bench.Kind("telegram") if why := h.rootFree(info, false); why != "" { t.Fatalf("free not read: %s", why) } - if len(asked) != 1 || asked[0] != "seat:mesh-controller.root-free anchor" { + if len(asked) != 1 || asked[0] != "seat:node-engine.root-free@anchor" { t.Errorf("asked %v", asked) } asked = nil h.Node = "elsewhere" if why := h.rootFree(info, false); !strings.Contains(why, "said nothing of whether an agent can become root on elsewhere") { - t.Errorf("a machine left out of the answer was a pass: %q", why) + t.Errorf("one machine's word was taken for another's: %q", why) } - if len(asked) != 1 || asked[0] != "seat:mesh-controller.root-free elsewhere,anchor" { + sort.Strings(asked) + if len(asked) != 2 || asked[0] != "seat:node-engine.root-free@anchor" || asked[1] != "seat:node-engine.root-free@elsewhere" { t.Errorf("the router's machine and the channel's were asked as %v", asked) } h.Node = "anchor" @@ -113,8 +116,8 @@ func TestTheRootWordIsAskedOfTheVerbThatAnswers(t *testing.T) { } // A failed ask of the root word is said with its error, keeps the word still fresh for rendering, never passes an -// answer, and is the reason a held ask carries (the sweep of 2026-10-30: the word was deleted silently and the held -// reason said only "not recent"). +// answer, and is the reason a held ask carries (the sweep of 2026-10-30, issue 774: the engines did not answer, the +// word was deleted silently and the held reason said only "not recent"). func TestAFailedRootAskIsSaidAndKeepsAFreshWord(t *testing.T) { h, _, _, c, store, _ := newRouter(t) link(h, store, c, "42", c.now().Add(-2*time.Hour)) @@ -125,13 +128,13 @@ func TestAFailedRootAskIsSaidAndKeepsAFreshWord(t *testing.T) { t.Fatalf("free not read: %s", why) } h.RootFree = func([]string) (map[string]RootVerdict, error) { - return nil, errors.New("nothing in the mesh is called node-engine") + return nil, errors.New("anchor's engine: no responders") } c.pass(RootFreeKept / 3) - if why := h.rootFree(info, false); !strings.Contains(why, "could not be asked: nothing in the mesh is called node-engine") { + if why := h.rootFree(info, false); !strings.Contains(why, "could not be asked: anchor's engine: no responders") { t.Errorf("a failed ask judged an answer: %q", why) } - if len(logs) != 1 || !strings.Contains(logs[0], "nothing in the mesh is called node-engine") || !strings.Contains(logs[0], "kept for rendering") { + if len(logs) != 1 || !strings.Contains(logs[0], "no responders") || !strings.Contains(logs[0], "kept for rendering") { t.Errorf("the failure was not said with its error: %v", logs) } if why := h.rootFree(info, true); why != "" { @@ -139,10 +142,10 @@ func TestAFailedRootAskIsSaidAndKeepsAFreshWord(t *testing.T) { } c.pass(RootFreeKept) stand(h, c) - if why := h.rootFree(info, true); !strings.Contains(why, "failed: nothing in the mesh is called node-engine") { + if why := h.rootFree(info, true); !strings.Contains(why, "failed: anchor's engine: no responders") { t.Errorf("a stale word passed, or its reason hid the failure: %q", why) } - if why := h.holdWhy(heldAsk(c, "d-9")); !strings.Contains(why, "nothing in the mesh is called node-engine") { + if why := h.holdWhy(heldAsk(c, "d-9")); !strings.Contains(why, "no responders") { t.Errorf("a held ask does not say why: %q", why) } } diff --git a/modules/messenger/module.json b/modules/messenger/module.json index ca694b40..eec860f0 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -94,7 +94,7 @@ "invokes": [ "seat:mesh-controller.conditions", "seat:mesh-conditions.conditions", - "seat:mesh-controller.root-free", + "seat:node-engine.root-free", "seat:issue-tracker.tracking" ], "reads": [