From 3448f98000537bbf7575b91502960a6fb27666bb Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:38:41 +0000 Subject: [PATCH] Make a seat pass no repair, read every machine before passing, keep the gate's pass in memory (issue 1317, review round 1) --- cmd/mesh-controller/handacts.go | 4 ++ cmd/mesh-controller/plan.go | 10 +++- cmd/mesh-controller/plan_failed_test.go | 8 +++ cmd/mesh-controller/seat_pass.go | 49 ++++++++++++----- cmd/mesh-controller/seat_pass_test.go | 71 +++++++++++++++++++++++++ internal/inventory/chain.go | 7 +++ internal/inventory/nodes.go | 3 ++ 7 files changed, 138 insertions(+), 14 deletions(-) diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index 6b451ef7..8fb90f96 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -72,6 +72,10 @@ var handActVerbs = []handActVerb{ // An act the operator chose on a warrant (novox/hq ADR 0259): asked by the controller, answered on a // channel that proved who answered, performed by the controller as itself. {Verb: handActWarrant, Decision: "the operator chose it, answering what the controller asked (ADR 0259)"}, + // A seat passed by the controller to its only claimant when its holder on record left (novox/hq ADR 0449 + // §4): the mesh working as decided. A person's own `seat --to` is not recorded under this cause. + {Verb: "seat", Decision: "the controller passes a seat held once to its only claimant when its recorded " + + "holder is assigned nowhere (ADR 0449 §4)", DecidedFor: []string{"seat-passed"}}, {Verb: "broker consumer-reset"}, // Silencing the same condition twice says the condition, or what it watches, wants mending — unless // it is the operator's answer on a notification: a decision to live with it (novox/hq ADR 0258). diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 6507b208..108c6d83 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -289,12 +289,18 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, // seat held once whose holder on record is assigned on no machine needs the whole mesh to be known, and it // passes to its only claimant here, in the composition that finds it. everyAssigned := map[string][]string{} + var assignedErr error for _, n := range nodes { - if theirs, err := inv.Assigned(ctx, n.Name); err == nil && len(theirs) > 0 { + theirs, err := inv.Assigned(ctx, n.Name) + if err != nil { + assignedErr = errors.Join(assignedErr, fmt.Errorf("%s: %w", n.Name, err)) + continue + } + if len(theirs) > 0 { everyAssigned[n.Name] = theirs } } - holdings = theMeshPassesSeatsOn(ctx, inv, shelf, everyAssigned, holdings) + holdings = theMeshPassesSeatsOn(ctx, inv, shelf, everyAssigned, assignedErr, holdings) for _, n := range nodes { if n.Name == exclude { continue diff --git a/cmd/mesh-controller/plan_failed_test.go b/cmd/mesh-controller/plan_failed_test.go index 6ddf0b93..b26542b9 100644 --- a/cmd/mesh-controller/plan_failed_test.go +++ b/cmd/mesh-controller/plan_failed_test.go @@ -93,6 +93,13 @@ func TestAFailedPlanThatCannotBeRetriedHasNoActionAndSaysWhy(t *testing.T) { }) superseded := aFailedPlan("plan-3", func(p *inventory.Plan) { p.State, p.Note = inventory.PlanSuperseded, "superseded by plan-9" }) done := aFailedPlan("plan-4", func(p *inventory.Plan) { p.State = inventory.PlanDone }) + first := time.Date(2026, 10, 19, 18, 20, 0, 0, time.UTC) + gate := aFailedPlan("plan-5", func(p *inventory.Plan) { + p.Note = "openrazer stopped at its first machine" + p.Modules = map[string]*inventory.PlanModule{"openrazer": {State: "built", FirstAt: &first, First: []string{"g14"}, + Why: "the build did not become healthy on g14", Gate: &inventory.PlanGate{Machines: []string{"g14"}, + Verdict: inventory.GateFailed, Why: "not healthy", From: "aaaaaaa", To: "bbbbbbb"}}} + }) for name, c := range map[string]struct { p inventory.Plan others []inventory.Plan @@ -101,6 +108,7 @@ func TestAFailedPlanThatCannotBeRetriedHasNoActionAndSaysWhy(t *testing.T) { "a newer plan": {older, []inventory.Plan{newer}, "supersedes it"}, "superseded": {superseded, nil, "superseded"}, "done": {done, nil, "nothing to retry"}, + "gate failed": {gate, nil, "failed its gate"}, } { f := calm(now) f.failed = []failedPlanFacts{failedFactsFor(c.p, c.others...)} diff --git a/cmd/mesh-controller/seat_pass.go b/cmd/mesh-controller/seat_pass.go index fe9ccf29..8995c530 100644 --- a/cmd/mesh-controller/seat_pass.go +++ b/cmd/mesh-controller/seat_pass.go @@ -73,23 +73,31 @@ func seatPassHandAct(p catalogue.SeatPass) link.HandAct { } } -// theMeshPassesSeatsOn is passSeatsOn as the composition runs it: against the store's record, with the hand act -// on the bus. Said to where the composition says its other lines (sayLeftOut). -func theMeshPassesSeatsOn(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest, - assigned map[string][]string, holdings []catalogue.Held) []catalogue.Held { - var assignments []catalogue.Assignment +// assignmentsOf are the assignments of every machine, as the passing of a seat reads them. **A module assigned and +// missing from the shelf is still assigned**: it is an assignment with no claims, so its holder is not taken for gone. +func assignmentsOf(shelf map[string]catalogue.Manifest, assigned map[string][]string) []catalogue.Assignment { + var out []catalogue.Assignment for node, modules := range assigned { for _, module := range modules { - if m, ok := shelf[module]; ok { - assignments = append(assignments, catalogue.Assignment{Node: node, Manifest: m}) + m, ok := shelf[module] + if !ok { + m = catalogue.Manifest{Module: module} } + out = append(out, catalogue.Assignment{Node: node, Manifest: m}) } } - var w io.Writer = os.Stderr - if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok { - w = lw + return out +} + +// seatPassingOn is how a composition on this inventory passes a seat: against the store's record with the hand act +// on the bus, or, on a throwaway mesh (the merge gate's), in memory only, writing nothing anywhere. +func seatPassingOn(inv *inventory.Inventory, w io.Writer) seatPassing { + if inv.Throwaway() { + return seatPassing{say: w, + pass: func(context.Context, catalogue.SeatPass) (bool, error) { return true, nil }, + act: func(context.Context, catalogue.SeatPass) error { return nil }} } - return passSeatsOn(ctx, seatPassing{ + return seatPassing{ say: w, pass: func(ctx context.Context, p catalogue.SeatPass) (bool, error) { return inv.PassSeat(ctx, p.Seat.Name, p.Seat.Scope, p.From, p.To) @@ -100,5 +108,22 @@ func theMeshPassesSeatsOn(ctx context.Context, inv *inventory.Inventory, shelf m return err }) }, - }, holdings, assignments) + } +} + +// theMeshPassesSeatsOn is passSeatsOn as the composition runs it. **Only when every machine's assignments were +// read** (assignedErr nil): a machine whose read failed would look as if its modules were assigned nowhere, and +// its holder would lose the seat. Said to where the composition says its other lines (sayLeftOut). +func theMeshPassesSeatsOn(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest, + assigned map[string][]string, assignedErr error, holdings []catalogue.Held) []catalogue.Held { + var w io.Writer = os.Stderr + if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok { + w = lw + } + if assignedErr != nil { + fmt.Fprintf(w, "no seat is passed on in this composition: a machine's assignments could not be read, so a "+ + "holder on record might look assigned nowhere: %v\n", assignedErr) + return holdings + } + return passSeatsOn(ctx, seatPassingOn(inv, w), holdings, assignmentsOf(shelf, assigned)) } diff --git a/cmd/mesh-controller/seat_pass_test.go b/cmd/mesh-controller/seat_pass_test.go index b4649044..5181964e 100644 --- a/cmd/mesh-controller/seat_pass_test.go +++ b/cmd/mesh-controller/seat_pass_test.go @@ -3,10 +3,13 @@ package main import ( "bytes" "context" + "errors" "strings" "testing" + "time" "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -120,3 +123,71 @@ func TestALostPassWritesNoActAndAFailedOneLeavesTheRecord(t *testing.T) { t.Fatalf("got %+v said %q", got, said.String()) } } + +// A pass is the mesh working as ADR 0449 §4 decided, never a repair: two of them raise no healer-wanted and +// count as no repair. +func TestTwoSeatPassesAreNoRepairAndRaiseNoHealerWanted(t *testing.T) { + withBusSeat(t) + now := time.Date(2026, 10, 19, 20, 0, 0, 0, time.UTC) + mk := func(at time.Time) link.HandAct { + a := seatPassHandAct(catalogue.SeatPass{Seat: catalogue.Seat{Name: "mesh-broker"}, + From: catalogue.Held{Node: "novox", Module: "old"}, To: catalogue.Held{Node: "novox", Module: "gate"}}) + a.At = at + return a + } + acts := []link.HandAct{mk(now.Add(-26 * time.Hour)), mk(now.Add(-time.Hour))} + if r := repairs(acts); len(r) != 0 { + t.Fatalf("a seat pass counts as a repair: %+v", r) + } + f := calm(now) + f.handActs = acts + if got := watchHandActs(f); len(got) != 0 { + t.Fatalf("two passes raised %+v", got) + } +} + +// A failed read of one machine's assignments passes no seat: its holder would look assigned nowhere. +func TestNoSeatIsPassedWhenAMachinesAssignmentsCouldNotBeRead(t *testing.T) { + withBusSeat(t) + var said bytes.Buffer + ctx := leftOutSaidBy(t.Context(), &said) + holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "ace", Module: "old-broker"}} + shelf := map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")} + got := theMeshPassesSeatsOn(ctx, nil, shelf, map[string][]string{"novox": {"gate"}}, + errors.New("ace: the bus did not answer"), holdings) + if len(got) != 1 || got[0].Module != "old-broker" { + t.Fatalf("the record moved: %+v", got) + } + if !strings.Contains(said.String(), "no seat is passed") || !strings.Contains(said.String(), "ace") { + t.Errorf("not said: %q", said.String()) + } +} + +// A holder that is assigned but missing from the shelf is still assigned. +func TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone(t *testing.T) { + withBusSeat(t) + as := assignmentsOf(map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")}, + map[string][]string{"novox": {"gate", "old-broker"}}) + passes, _ := catalogue.SeatsToPassOn( + []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, as) + if len(passes) != 0 { + t.Fatalf("a holder missing from the shelf lost its seat: %+v", passes) + } +} + +// The merge gate's throwaway mesh passes the seat in memory and writes nothing: no record, no hand act. +func TestAThrowawayMeshPassesTheSeatInMemoryAndWritesNothing(t *testing.T) { + withBusSeat(t) + inv := &inventory.Inventory{} + inv.MarkThrowaway() + var said bytes.Buffer + holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} + got := passSeatsOn(t.Context(), seatPassingOn(inv, &said), holdings, + []catalogue.Assignment{{Node: "novox", Manifest: aBrokerClaiming("gate")}}) + if len(got) != 1 || got[0].Module != "gate" { + t.Fatalf("the gate's view does not hold the pass: %+v", got) + } + if said.Len() != 0 { + t.Errorf("the throwaway composition said %q (a write was tried)", said.String()) + } +} diff --git a/internal/inventory/chain.go b/internal/inventory/chain.go index df09bfa0..0d297639 100644 --- a/internal/inventory/chain.go +++ b/internal/inventory/chain.go @@ -85,6 +85,7 @@ func (i *Inventory) UseChain(c *chain.Chain) { // beside the real one, as the merge gate raises the facts snapshot's mesh in a throwaway store (issue 1121). Nothing of it // reaches the bus. func (i *Inventory) UseThrowawayChain() { + i.MarkThrowaway() i.UseChain(chain.InMemory(nil)) i.markLaidOut() } @@ -129,3 +130,9 @@ func (i *Inventory) theChain(ctx context.Context) (*chain.Chain, error) { } return c, nil } + +// MarkThrowaway says this inventory is a mesh raised for a moment: a composition on it writes nothing outside it. +func (i *Inventory) MarkThrowaway() { i.throwaway = true } + +// Throwaway says whether this inventory is a mesh raised for a moment (UseThrowawayChain). +func (i *Inventory) Throwaway() bool { return i != nil && i.throwaway } diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index 26f57483..08c95fbe 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -39,6 +39,9 @@ type Inventory struct { state *chainState // recorded, when set, takes the durations a save of the chain measures in place of the store: a test with no store. recorded func(context.Context, Duration) error + // throwaway is set on an inventory raised for a moment beside the real mesh (the merge gate's): nothing it + // composes is to be written to the store or the bus. + throwaway bool } // Open connects to the inventory store.