messenger: ask each machine's own engine whether it is root-free, the node seat node-engine's root-free, not the controller's (issue 774, ADR 0563 §6)
This commit is contained in:
@@ -133,7 +133,7 @@ func pruneGrants(grants map[string]Grant, kind string, now time.Time, keep int)
|
||||
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
|
||||
// Anything else is refused, and said there. The same answer is acted on once.
|
||||
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
|
||||
// The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
|
||||
// The machines' engines' root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
|
||||
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
|
||||
// and judged with in it.
|
||||
root := h.rootWordFor(kind)
|
||||
|
||||
@@ -600,6 +600,16 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
|
||||
if _, kept := store.asks["mesh-delivery.d-1"]; kept {
|
||||
t.Error("a refused ask was recorded")
|
||||
}
|
||||
// A node's engine is an asker under node-engine-<node> (novox/hq ADR 0563 §3, changing ADR 0259 §2): its ask is
|
||||
// recorded under that name, with its own bound of open asks; a name that is no asker's is dropped.
|
||||
submit(t, h, "node-engine-ace", heldAsk(c, "hand-over-1"))
|
||||
submit(t, h, "node.ace", heldAsk(c, "hand-over-2"))
|
||||
if rec, kept := store.asks["node-engine-ace.hand-over-1"]; !kept || rec.Asker != "node-engine-ace" {
|
||||
t.Errorf("a node's engine's ask was not recorded under its name: %+v", rec)
|
||||
}
|
||||
if _, kept := store.asks["node.ace.hand-over-2"]; kept {
|
||||
t.Error("an ask under a name that is no asker's was recorded")
|
||||
}
|
||||
// No channel can carry anything: held with the reason (ADR 0397), never queued in silence.
|
||||
h2, _, _, c2, store2, w2 := newRouter(t)
|
||||
h2.Bench.Stand("telegram", Standing{Ready: false, Why: "no bot token", At: c2.now()})
|
||||
|
||||
@@ -242,8 +242,9 @@ func (h *Holder) carriers(level asks.Level) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// RootVerdict is the controller's word on one machine (its verb root-free, novox/hq ADR 0259 §8): free only
|
||||
// when it 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,51 +252,64 @@ type RootVerdict struct {
|
||||
Judged string `json:"judged,omitempty"`
|
||||
}
|
||||
|
||||
// RootFreeVerb is the controller's verb the router asks, as this module, which machines are root-free.
|
||||
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@<machine>`, 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 controller's word; RootFreeKept how long a word is
|
||||
// used to render a message's buttons. An answer is always judged on a word asked then.
|
||||
// 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 controller about machines, failing on no answer in time.
|
||||
// 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("the controller did not answer within %s", RootFreeWithin)
|
||||
}
|
||||
if a.err != nil {
|
||||
return nil, a.err
|
||||
}
|
||||
var body struct {
|
||||
Machines []RootVerdict `json:"machines"`
|
||||
}
|
||||
if err := json.Unmarshal(a.raw, &body); err != nil {
|
||||
return nil, fmt.Errorf("the controller's answer could not be read: %v", err)
|
||||
done := make(chan answer, len(machines))
|
||||
for _, m := range machines {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -329,7 +343,7 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string {
|
||||
case cached:
|
||||
// Read inside the work, which never waits on the controller: no recent word is no pass. warmRootFree
|
||||
// asks outside it.
|
||||
return "the controller's word on whether an agent can become root there is not recent"
|
||||
return "the machines' engines' word on whether an agent can become root there is not recent"
|
||||
default:
|
||||
got, err := h.askRootFree(machines)
|
||||
if err != nil {
|
||||
@@ -351,7 +365,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 == "" {
|
||||
@@ -364,7 +378,7 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// warmRootFree asks the controller's root-free word for every kind that proves its sender, outside the work,
|
||||
// warmRootFree asks the machines' engines' root-free word for every kind that proves its sender, outside the work,
|
||||
// so what is rendered and offered inside it reads a recent word.
|
||||
// force asks even a recent word again: taking an ask, whose refusal its asker keeps until something changes.
|
||||
func (h *Holder) warmRootFree(force bool) {
|
||||
@@ -390,7 +404,7 @@ func (h *Holder) warmRootFree(force bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// rootWordFor asks the controller's root-free word for a kind fresh, before the work is taken, and answers
|
||||
// rootWordFor asks the machines' engines' root-free word for a kind fresh, before the work is taken, and answers
|
||||
// what judging an answer of that kind then reads: the word for that very kind's placement, or why there is none.
|
||||
// A kind that does not prove its sender is asked nothing: its answers never approve.
|
||||
func (h *Holder) rootWordFor(kind string) func(KindInfo) string {
|
||||
@@ -453,7 +467,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
if verb == ConditionVerb {
|
||||
// Before the work lock: Condition takes it, and decides as for the controller's conditions.
|
||||
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
|
||||
h.Logf("[messenger] a condition from %q was taken and dropped: not a module's name", asker)
|
||||
h.Logf("[messenger] a condition from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker)
|
||||
return nil
|
||||
}
|
||||
h.takeCondition(asker, body)
|
||||
@@ -466,7 +480,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
h.init()
|
||||
h.mu.Unlock()
|
||||
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
|
||||
h.Logf("[messenger] an ask from %q was taken and dropped: not a module's name", asker)
|
||||
h.Logf("[messenger] an ask from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker)
|
||||
return nil
|
||||
}
|
||||
if verb == "cancel" {
|
||||
|
||||
@@ -256,16 +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 a struct {
|
||||
Machines []string `json:"machines"`
|
||||
}
|
||||
_ = json.Unmarshal(p.Body, &a)
|
||||
var out []RootVerdict
|
||||
for _, m := range a.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)
|
||||
}
|
||||
|
||||
@@ -315,7 +315,8 @@ 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 controller's verb root-free about machines; nil asks it through Ask (askRootFree).
|
||||
// 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.
|
||||
Zone func() *time.Location
|
||||
|
||||
@@ -87,7 +87,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:" + ControllerSeat + ".root-free", TrackingVerb}) {
|
||||
"seat:" + EngineSeat + ".root-free", TrackingVerb}) {
|
||||
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
|
||||
}
|
||||
var state []string
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -66,26 +67,37 @@ func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// H3: the word is asked of the controller's verb, as this module, and read from its answer.
|
||||
func TestTheRootWordIsAskedOfTheControllersVerb(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) {
|
||||
asked = append(asked, key)
|
||||
raw, _ := json.Marshal(body)
|
||||
if !strings.Contains(string(raw), `"machines":["anchor"]`) {
|
||||
t.Errorf("asked about %s", raw)
|
||||
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":"anchor","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil
|
||||
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" {
|
||||
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("one machine's word was taken for another's: %q", why)
|
||||
}
|
||||
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"
|
||||
h.Ask = func(string, any) (json.RawMessage, error) { return json.RawMessage(`not json`), nil }
|
||||
if why := h.rootFree(info, false); !strings.Contains(why, "could not be read") {
|
||||
t.Errorf("an unreadable answer was a pass: %q", why)
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.conditions",
|
||||
"seat:mesh-controller.root-free",
|
||||
"seat:node-engine.root-free",
|
||||
"seat:issue-tracker.tracking"
|
||||
],
|
||||
"state": [
|
||||
|
||||
Reference in New Issue
Block a user